Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 54 additions & 5 deletions .github/workflows/package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -136,18 +136,20 @@ jobs:
cmake_extra_args: -DACECODE_BUILD_DESKTOP=ON
- id: macos-x64
os: macos-15
installer_arch: x64
triplet: x64-osx
executable: acecode
archive_extension: tar.gz
build_desktop: true
cmake_extra_args: -DCMAKE_OSX_ARCHITECTURES=x86_64 -DACECODE_BUILD_DESKTOP=ON
- id: macos-arm64
os: macos-15
installer_arch: arm64
triplet: arm64-osx
executable: acecode
archive_extension: tar.gz
build_desktop: true
cmake_extra_args: -DACECODE_BUILD_DESKTOP=ON
cmake_extra_args: -DCMAKE_OSX_ARCHITECTURES=arm64 -DACECODE_BUILD_DESKTOP=ON

steps:
- name: Checkout
Expand Down Expand Up @@ -508,6 +510,31 @@ jobs:
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
run: scripts/macos_notarize_app.sh --app "build/ACECode.app"

- name: Test macOS installer integration
if: runner.os == 'macOS'
shell: bash
run: |
bash tests/scripts/macos_installer_test.sh
python3 tests/scripts/macos_installer_release_test.py

- name: Build signed and notarized macOS installer DMG
if: runner.os == 'macOS' && steps.macos-release.outputs.enabled == 'true'
id: macos-dmg
shell: bash
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
run: |
dmg_path="ACECode-${{ steps.package-version.outputs.version }}-${{ matrix.id }}.dmg"
bash scripts/macos_create_installer_dmg.sh \
--app "build/ACECode.app" \
--output "$dmg_path" \
--arch "${{ matrix.installer_arch }}" \
--identity "${{ steps.macos-keychain.outputs.identity }}" \
--keychain "${{ steps.macos-keychain.outputs.keychain }}"
echo "path=$dmg_path" >> "$GITHUB_OUTPUT"

- name: Create macOS self-update archive
if: runner.os == 'macOS'
id: macos-update
Expand Down Expand Up @@ -781,6 +808,14 @@ jobs:
path: ${{ steps.linux-update.outputs.path }}
if-no-files-found: error

- name: Upload macOS installer DMG
if: runner.os == 'macOS' && steps.macos-release.outputs.enabled == 'true'
uses: actions/upload-artifact@v4
with:
name: acecode-${{ matrix.id }}-dmg
path: ${{ steps.macos-dmg.outputs.path }}
if-no-files-found: error

- name: Upload macOS PKG
if: runner.os == 'macOS' && steps.macos-release.outputs.pkg_enabled == 'true'
uses: actions/upload-artifact@v4
Expand Down Expand Up @@ -1156,6 +1191,9 @@ jobs:
- build-linux-old-package
runs-on: ubuntu-latest
steps:
- name: Checkout release asset selector
uses: actions/checkout@v4

- name: Download all artifacts
uses: actions/download-artifact@v4
with:
Expand All @@ -1167,11 +1205,22 @@ jobs:
run: |
mkdir -p release-assets

unexpected_dmg="$(find artifacts -type f -name '*.dmg' -print -quit)"
if [ -n "$unexpected_dmg" ]; then
echo "::error::DMG artifacts are no longer permitted in tagged releases: $unexpected_dmg"
release_version="${GITHUB_REF_NAME#v}"
# Only exact update ZIPs may be selected for updater publication.
python3 scripts/select_macos_update_assets.py artifacts "$release_version"
dmg_count="$(find artifacts -type f -iname '*.dmg' | wc -l | tr -d '[:space:]')"
if [ "$dmg_count" -ne 2 ]; then
echo "::error::Tagged releases require exactly two signed macOS installer DMGs; found $dmg_count"
exit 1
fi
for arch in x64 arm64; do
expected_dmg="ACECode-${release_version}-macos-${arch}.dmg"
matching_count="$(find artifacts -type f -name "$expected_dmg" | wc -l | tr -d '[:space:]')"
if [ "$matching_count" -ne 1 ]; then
echo "::error::Expected exactly one $expected_dmg; found $matching_count"
exit 1
fi
done

unsigned_pkg="$(find artifacts -type f -name '*-unsigned.pkg' -print -quit)"
if [ -n "$unsigned_pkg" ]; then
Expand Down Expand Up @@ -1204,7 +1253,7 @@ jobs:
echo "::notice::No macOS PKGs were produced; publishing the remaining trusted assets"
fi

find artifacts -type f \( -name '*.tar.gz' -o -name '*.zip' -o -name '*.pkg' \) -print0 |
find artifacts -type f \( -name '*.tar.gz' -o -name '*.zip' -o -name '*.pkg' -o -name '*.dmg' \) -print0 |
while IFS= read -r -d '' file; do
cp "$file" "release-assets/$(basename "$file")"
done
Expand Down
17 changes: 17 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,23 @@ on:
- master

jobs:
macos-installer-tests:
name: macos-installer-tests
runs-on: macos-15
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Test native installer and both UI architectures
run: bash tests/scripts/macos_installer_test.sh

- name: Test offline macOS release scripts
run: |
python3 tests/scripts/macos_installer_release_test.py
python3 tests/scripts/macos_portable_package_test.py
bash tests/scripts/macos_release_scripts_test.sh

web-tests:
name: web-tests (linux-x64)
runs-on: ubuntu-22.04
Expand Down
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -476,6 +476,8 @@ web/

**实时 transcript 状态是单写者,渲染层只能订阅。** `web/src/lib/singleWriterStore.js` 的 `commit(producer)` 是唯一写入口,producer 入参恒为最新已提交状态;`useSessionTranscript` 经 `useSyncExternalStore` 订阅,hook 内没有 `useState`/`setState`。曾经的写法是 `useState` 镜像 + `useEffect(() => { stateRef.current = state; }, [state])`:被动 effect 在提交后异步刷新,一旦它带着旧渲染快照回写,其后到达的 token 就从旧快照继续拼接,中间已收到的片段被永久覆盖 —— 表现为长会话喷字时正文中间随机缺汉字/标点/换行(Markdown 把它放大成"乱码"),回合结束后被完整 message 与 self-heal 覆盖回正确结果。这条路径在传输层完全隐形:`seq` 仍单调增长、去重不报警、服务端 replay 完整。因此 `commit` 刻意只收 producer(值形式的回写在入口就被拒),`subscribe` 的通知不带状态(订阅者手里不会留下可回写的旧快照),历史加载/追赶重放/定时刷新/self-heal 覆写全部收敛成单次原子提交。**同一个 store 也承载 ChatView 的排队输入状态**(`queueStateRef` 曾是同款双写,可见后果是排队消息被吞或已取消的重新出现);`drainQueuedInput` 的取件与标记 sending、以及用户消息落库后的整轮完成标记,都收敛成单次提交。回归:`singleWriterStore.test.js` + `transcriptStreamIntegrity.test.js`(高速重放的流式前缀不变量 + 旧双写模型的失效见证)+ `chatInputQueueIntegrity.test.js` + `singleWriterOwnershipArchitecture.test.js`。详见 [docs/web-chat/transcript-state.md](docs/web-chat/transcript-state.md)。

**事件回放去重要以 daemon 的真实事件序为准(fix: 回答问题后上一轮 AI 正文重复显示)。** 切进正在运行的会话时,REST `GET messages?since=0` 先给出已落盘历史,再因 busy 用 `since=1` 把整个事件环(1024 条,含上一轮)回放进同一份 transcript;WS 用 sessionStorage 里的旧游标订阅时也会逐条回放。回放依赖去重:同一条 assistant 的 token 流与 message 事件必须整体落到 REST 已有的那条上。daemon 的顺序是 `token… → usage → message(assistant) → tool_start`(`Usage` 在 `chat_stream` 一返回就 emit,assistant `Message` 要到 `execute_tool_calls` / 文本回合收尾才发;工具回合的 tool-call 增量还会夹 `agent_progress(tool_planning)`)。`sessionTranscript.js` 的两个回放循环(`applyTranscriptReplayEvents` / `loadTranscriptHistory`)曾经「遇到任何非流式事件就 flush 攒着的 token」,于是 usage 把 token 提前流成新草稿,紧接着按 id 判重的 message 再把草稿定稿成第二份 —— 用户看到的就是「AI 上次的回答在我的消息后面又出现一次,切走再切回才消失」(实测用 [U1, A1, U2] + 环回放能稳定复现)。现在:(1) 只有会追加 / 收尾 transcript 条目的事件才 flush,`STREAM_NEUTRAL_EVENT_TYPES`(usage / agent_progress / model_step_* / goal / todo / permission / question)一律不 flush;(2) 推迟应用的 token 经 `reduceDeferredStreamEvent` 绕过被旁路事件推高的 seq 水位,否则被中断回合的正文会整段消失;(3) reducer 里 `replayed:true` 的 assistant message 命中已有条目时丢掉草稿、原位更新,`replayed:true` 的 `tool_start` 命中已落盘的同 call id 工具项时绑到它身上而不是再追加(答完的 AskUserQuestion 卡片否则会出现两张)。**去重只认 `replayed` 帧**:assistant 消息 id 是内容哈希(`sha1(role + content)`,与 JSONL 重读一致),实时回合里模型说一句与旧消息一字不差的话 id 也会撞上,那是真的新回复;有的 provider 还会跨回合复用 tool call id。回归测试在 `sessionTranscript.test.js`(「live catch-up replay:token 与已持久化 message 之间夹着 usage…」一组)。

**Transcript 尾部窗口(渐进虚拟化)**:大会话切入时只渲染最近 `INITIAL_TAIL_ITEMS`(120)条投影行,更早的行折成顶部「显示更早的 N 条消息 / 显示全部」入口(`web/src/lib/transcriptWindow.js` 纯函数 + Node 测试)。窗口按**首个可见行的 user 行 id** 锚定(user 行不参与活动折叠,id 跨投影稳定;流式追加在尾部,锚不动、窗口自然增长;锚丢失 fail-open 全量)。渲染 map 走 `windowedItems`,但 `itemsRef`/scrubber 回合列表/turnFileListPlacement 仍吃全量 `renderedItems` —— scrubber 跳转与搜索深链(`searchJumpOrdinal`)目标不在 DOM 时先 `expandTranscriptWindow()` 再重试;Ctrl+F 查找是 DOM 文本搜索,`GlobalFindOverlay.openFind` 广播 `acecode:conversation-find-open` 事件让 ChatView 先全量展开。「显示更早」向上补条目需手动滚动补偿(`windowRevealScrollRef` 按 scrollHeight 差值回补,原生锚定已被 overflow-anchor:none 关掉)。窗口状态是 render-phase 调整(React 文档模式)—— 用 useEffect 会先全量渲染一帧,虚拟化就白做了;transcript 重置(条目清零)回到未初始化重定锚,因为 item id 会被重载复用。

`lib/usePreference.js` 是 UI 偏好读写的统一入口(`usePreference(key, defaults, validator?)` 返回 `[value, setValue]`,setter 接受 partial 浅合并 / 函数式更新,localStorage 写失败静默吞)。当前订阅:`ace.theme`(由 `theme.jsx::ThemeProvider`)、`acecode.singleLayoutWidths.v1`(由 `App.jsx`,`{sidebar, sidePanel}` 像素宽)、`acecode.uiPrefs.v1`(由 `App.jsx`,`{view: 'single'|'grid4'|'grid9', sidePanelCollapsed: boolean}` — view 模式与 SidePanel 折叠态跨刷新持久化)。同一 key 在整个 App 内只允许一个 hook 实例订阅,避免多 useState 之间互覆盖。
Expand Down
65 changes: 53 additions & 12 deletions docs/daemon-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -931,17 +931,33 @@ storage directly; they do not call the daemon HTTP API:
|---|---|
| `create_thread` | create a background thread and queue its initial prompt |
| `fork_thread` | fork completed persisted history into a new thread |
| `list_threads` | return pinned threads plus a bounded recent list |
| `read_thread` | read bounded, cursor-paginated turns |
| `list_threads` | globally list pinned threads plus cursor-paginated recent threads |
| `read_thread` | read bounded, cursor-paginated turns from any workspace |
| `send_message_to_thread` | queue a follow-up prompt |
| `wait_threads` | wait for up to eight targets using event cursors |
| `wait_threads` | wait for up to eight targets across workspaces using event cursors |
| `set_thread_title` | rename a thread |
| `set_thread_pinned` | update the existing pinned-session state |
| `set_thread_archived` | archive or unarchive a thread |
| `delete_thread` | permanently delete a thread and all descendants |
| `repair_thread` | append a deterministic repair checkpoint to another thread |
| `create_workspace` | register an existing absolute directory as a visible workspace |

`list_threads`, `read_thread`, and `wait_threads` discover sessions across the
entire ACECode data directory, including hidden/unregistered workspaces and
workspace-free sessions. A caller cwd is not required. List rows include
`workspaceHash` (the storage project hash, also for workspace-free sessions),
`cwd`, `noWorkspace`, `workspaceName`, and `workspaceVisible` as metadata.
Child sessions retain `parentThreadId`. The list excludes archived sessions
unless `includeArchived:true`; explicit reads can access archived sessions.
All non-archived pins are returned in `pinnedThreads`, ordered by project hash
and then that project's persisted pin order. Only `threads` is subject to
`limit` (default 20, maximum 50). Pass `nextCursor` back as `cursor` to read
the next page; it is an offset into the current ordering, not a frozen snapshot.
`errors` reports incomplete project scans. `read_thread` and each `wait_threads`
target accept optional `workspaceHash` from the list for direct lookup and
disambiguation when the same `threadId` exists in multiple projects.
Creation and mutation tools retain their existing calling-workspace semantics.

`delete_thread` also removes search-index and pin records. It may target its
calling thread, including a cascade whose tree contains the caller. In that
case it records the canonical tool result and turn timing, emits the terminal
Expand Down Expand Up @@ -1209,6 +1225,26 @@ Structured attachment references in the retained history are also copied and
remapped, including their provider content parts and preview records; recalling
those messages does not depend on the source session's attachment storage.

### `POST /api/sessions/:id/messages/retry`

Retries the exact trailing user message in an idle live session. Body:

```json
{"expected_user_message_id":"persisted-user-message-id"}
```

The request accepts only this field. The backend verifies the full visible
transcript and model history end with that user message, with no active or
queued work. It checks again when the worker starts. Hidden bookkeeping
records do not count as transcript messages; assistant (including empty
messages), tool, system and error messages prevent retry.

The original message, attachments and context are reused without appending
another user record. Returns `202 {"queued":true,"user_message_id":"..."}`;
malformed requests return `400`, and unavailable sessions, stale message IDs
or active/queued work return `409`. This endpoint does not create or resume a
session, expand commands, or accept new input.

### `POST /api/sessions/:id/messages`

Queues a user input turn. Body:
Expand Down Expand Up @@ -3683,16 +3719,18 @@ packages also verify the installed backend before reporting success; a failed
post-copy verification rolls back the installation. Version mismatch, timeout,
invalid output and unsuccessful probe exit fail the job with an actionable error.

On macOS, a daemon running from either the current-user
`~/Applications/ACECode.app/Contents/MacOS/acecode-daemon` location or the
supported system `/Applications/ACECode.app/Contents/MacOS/acecode-daemon`
location installs a complete
On macOS, a daemon running from `ACECode.app/Contents/MacOS/acecode-daemon`
installs a complete
`ACECode.app` update ZIP rather than copying files into `Contents/MacOS`. Before
replacement, the daemon requires one of those exact non-symlinked install paths,
a writable containing directory, a strict nested Apple signature, bundle
replacement, the daemon requires an absolute, canonical, real `ACECode.app`
with a real, writable containing directory, a strict nested Apple signature, bundle
identifier `dev.acecode.desktop`, the selected manifest version, and the same
Developer Team ID as the installed app. An app running from any other location
fails the job without mutating that bundle.
Developer Team ID and designated signing requirement as the installed app.
Custom folders are supported as well as `~/Applications` and `/Applications`;
App Translocation, symlinked paths, and apps nested in another `.app` are rejected.
Read-only or otherwise unwritable locations require moving the app to a writable
folder or installing manually; the updater does not elevate privileges. This does
not change the separate personal-install destination policy.

### `GET /api/update/job`

Expand Down Expand Up @@ -3738,7 +3776,10 @@ Choosing restart later leaves the current process running. Normal browser and
Edge-app compatibility clients do not own the desktop lifecycle, so they show
manual full-exit-and-relaunch guidance instead of an automatic restart action.
For a successful macOS bundle update, `backup_dir` identifies the retained
`.ACECode.previous.app` beside the running installation.
`.ACECode-<UUID>.previous.app` beside the running installation. Existing backups
are not deleted or overwritten; retained backups require manual cleanup when no
longer needed. The updater lock is opened without following symlinks and must be
a regular, singly linked file owned by the current user.

### `GET /api/mcp`

Expand Down
Loading
Loading