zcode-acp-server 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (223) hide show
  1. package/README.md +192 -9
  2. package/README.zh-CN.md +143 -8
  3. package/dist/backend/client.d.ts +12 -4
  4. package/dist/backend/client.d.ts.map +1 -1
  5. package/dist/backend/client.js +72 -7
  6. package/dist/backend/client.js.map +1 -1
  7. package/dist/backend/credentials.d.ts.map +1 -1
  8. package/dist/backend/credentials.js +2 -1
  9. package/dist/backend/credentials.js.map +1 -1
  10. package/dist/backend/listener.d.ts +12 -4
  11. package/dist/backend/listener.d.ts.map +1 -1
  12. package/dist/backend/listener.js +68 -12
  13. package/dist/backend/listener.js.map +1 -1
  14. package/dist/backend/types.d.ts +1 -1
  15. package/dist/backend/types.d.ts.map +1 -1
  16. package/dist/bin/hub.d.ts +16 -0
  17. package/dist/bin/hub.d.ts.map +1 -0
  18. package/dist/bin/hub.js +41 -0
  19. package/dist/bin/hub.js.map +1 -0
  20. package/dist/bin/quota.d.ts +54 -0
  21. package/dist/bin/quota.d.ts.map +1 -0
  22. package/dist/bin/quota.js +333 -0
  23. package/dist/bin/quota.js.map +1 -0
  24. package/dist/config/auto-compact.d.ts +23 -0
  25. package/dist/config/auto-compact.d.ts.map +1 -0
  26. package/dist/config/auto-compact.js +67 -0
  27. package/dist/config/auto-compact.js.map +1 -0
  28. package/dist/config/mcp-discovery.d.ts +34 -0
  29. package/dist/config/mcp-discovery.d.ts.map +1 -0
  30. package/dist/config/mcp-discovery.js +153 -0
  31. package/dist/config/mcp-discovery.js.map +1 -0
  32. package/dist/config/model-cache.d.ts +12 -1
  33. package/dist/config/model-cache.d.ts.map +1 -1
  34. package/dist/config/model-cache.js +30 -8
  35. package/dist/config/model-cache.js.map +1 -1
  36. package/dist/config/options.d.ts +87 -9
  37. package/dist/config/options.d.ts.map +1 -1
  38. package/dist/config/options.js +248 -45
  39. package/dist/config/options.js.map +1 -1
  40. package/dist/config/plugin-commands.d.ts +24 -0
  41. package/dist/config/plugin-commands.d.ts.map +1 -0
  42. package/dist/config/plugin-commands.js +107 -0
  43. package/dist/config/plugin-commands.js.map +1 -0
  44. package/dist/config/provider-registry.d.ts +60 -0
  45. package/dist/config/provider-registry.d.ts.map +1 -0
  46. package/dist/config/provider-registry.js +128 -0
  47. package/dist/config/provider-registry.js.map +1 -0
  48. package/dist/config/runtime-model.d.ts +54 -18
  49. package/dist/config/runtime-model.d.ts.map +1 -1
  50. package/dist/config/runtime-model.js +105 -61
  51. package/dist/config/runtime-model.js.map +1 -1
  52. package/dist/config/skill-discovery.d.ts +35 -0
  53. package/dist/config/skill-discovery.d.ts.map +1 -0
  54. package/dist/config/skill-discovery.js +188 -0
  55. package/dist/config/skill-discovery.js.map +1 -0
  56. package/dist/handlers/account.d.ts +43 -0
  57. package/dist/handlers/account.d.ts.map +1 -0
  58. package/dist/handlers/account.js +59 -0
  59. package/dist/handlers/account.js.map +1 -0
  60. package/dist/handlers/background-tasks.d.ts +72 -0
  61. package/dist/handlers/background-tasks.d.ts.map +1 -0
  62. package/dist/handlers/background-tasks.js +330 -0
  63. package/dist/handlers/background-tasks.js.map +1 -0
  64. package/dist/handlers/dispatch.d.ts.map +1 -1
  65. package/dist/handlers/dispatch.js +134 -22
  66. package/dist/handlers/dispatch.js.map +1 -1
  67. package/dist/handlers/extensions.d.ts +15 -0
  68. package/dist/handlers/extensions.d.ts.map +1 -1
  69. package/dist/handlers/extensions.js +43 -27
  70. package/dist/handlers/extensions.js.map +1 -1
  71. package/dist/handlers/io.d.ts +31 -3
  72. package/dist/handlers/io.d.ts.map +1 -1
  73. package/dist/handlers/io.js +108 -9
  74. package/dist/handlers/io.js.map +1 -1
  75. package/dist/handlers/replay.d.ts +79 -0
  76. package/dist/handlers/replay.d.ts.map +1 -0
  77. package/dist/handlers/replay.js +252 -0
  78. package/dist/handlers/replay.js.map +1 -0
  79. package/dist/handlers/server-requests.d.ts +20 -4
  80. package/dist/handlers/server-requests.d.ts.map +1 -1
  81. package/dist/handlers/server-requests.js +293 -63
  82. package/dist/handlers/server-requests.js.map +1 -1
  83. package/dist/handlers/session.d.ts +113 -10
  84. package/dist/handlers/session.d.ts.map +1 -1
  85. package/dist/handlers/session.js +932 -277
  86. package/dist/handlers/session.js.map +1 -1
  87. package/dist/handlers/slash.d.ts +37 -1
  88. package/dist/handlers/slash.d.ts.map +1 -1
  89. package/dist/handlers/slash.js +143 -8
  90. package/dist/handlers/slash.js.map +1 -1
  91. package/dist/index.js +76 -12
  92. package/dist/index.js.map +1 -1
  93. package/dist/interaction/adapter.d.ts +19 -33
  94. package/dist/interaction/adapter.d.ts.map +1 -1
  95. package/dist/interaction/adapter.js +80 -79
  96. package/dist/interaction/adapter.js.map +1 -1
  97. package/dist/lazy-sessions.d.ts +35 -0
  98. package/dist/lazy-sessions.d.ts.map +1 -0
  99. package/dist/lazy-sessions.js +98 -0
  100. package/dist/lazy-sessions.js.map +1 -0
  101. package/dist/quota/cache.d.ts +18 -0
  102. package/dist/quota/cache.d.ts.map +1 -0
  103. package/dist/quota/cache.js +32 -0
  104. package/dist/quota/cache.js.map +1 -0
  105. package/dist/quota/client.d.ts +30 -0
  106. package/dist/quota/client.d.ts.map +1 -0
  107. package/dist/quota/client.js +57 -0
  108. package/dist/quota/client.js.map +1 -0
  109. package/dist/quota/color.d.ts +58 -0
  110. package/dist/quota/color.d.ts.map +1 -0
  111. package/dist/quota/color.js +95 -0
  112. package/dist/quota/color.js.map +1 -0
  113. package/dist/quota/combined.d.ts +66 -0
  114. package/dist/quota/combined.d.ts.map +1 -0
  115. package/dist/quota/combined.js +179 -0
  116. package/dist/quota/combined.js.map +1 -0
  117. package/dist/quota/format.d.ts +105 -0
  118. package/dist/quota/format.d.ts.map +1 -0
  119. package/dist/quota/format.js +219 -0
  120. package/dist/quota/format.js.map +1 -0
  121. package/dist/quota/index.d.ts +23 -0
  122. package/dist/quota/index.d.ts.map +1 -0
  123. package/dist/quota/index.js +42 -0
  124. package/dist/quota/index.js.map +1 -0
  125. package/dist/quota/opencode-go/cache.d.ts +17 -0
  126. package/dist/quota/opencode-go/cache.d.ts.map +1 -0
  127. package/dist/quota/opencode-go/cache.js +31 -0
  128. package/dist/quota/opencode-go/cache.js.map +1 -0
  129. package/dist/quota/opencode-go/client.d.ts +28 -0
  130. package/dist/quota/opencode-go/client.d.ts.map +1 -0
  131. package/dist/quota/opencode-go/client.js +48 -0
  132. package/dist/quota/opencode-go/client.js.map +1 -0
  133. package/dist/quota/opencode-go/config.d.ts +37 -0
  134. package/dist/quota/opencode-go/config.d.ts.map +1 -0
  135. package/dist/quota/opencode-go/config.js +58 -0
  136. package/dist/quota/opencode-go/config.js.map +1 -0
  137. package/dist/quota/opencode-go/format.d.ts +36 -0
  138. package/dist/quota/opencode-go/format.d.ts.map +1 -0
  139. package/dist/quota/opencode-go/format.js +87 -0
  140. package/dist/quota/opencode-go/format.js.map +1 -0
  141. package/dist/quota/opencode-go/index.d.ts +30 -0
  142. package/dist/quota/opencode-go/index.d.ts.map +1 -0
  143. package/dist/quota/opencode-go/index.js +108 -0
  144. package/dist/quota/opencode-go/index.js.map +1 -0
  145. package/dist/quota/opencode-go/parse.d.ts +41 -0
  146. package/dist/quota/opencode-go/parse.d.ts.map +1 -0
  147. package/dist/quota/opencode-go/parse.js +75 -0
  148. package/dist/quota/opencode-go/parse.js.map +1 -0
  149. package/dist/quota/opencode-go/types.d.ts +48 -0
  150. package/dist/quota/opencode-go/types.d.ts.map +1 -0
  151. package/dist/quota/opencode-go/types.js +11 -0
  152. package/dist/quota/opencode-go/types.js.map +1 -0
  153. package/dist/quota/parse.d.ts +33 -0
  154. package/dist/quota/parse.d.ts.map +1 -0
  155. package/dist/quota/parse.js +200 -0
  156. package/dist/quota/parse.js.map +1 -0
  157. package/dist/quota/types.d.ts +72 -0
  158. package/dist/quota/types.d.ts.map +1 -0
  159. package/dist/quota/types.js +10 -0
  160. package/dist/quota/types.js.map +1 -0
  161. package/dist/remote/broadcast.d.ts +47 -0
  162. package/dist/remote/broadcast.d.ts.map +1 -0
  163. package/dist/remote/broadcast.js +121 -0
  164. package/dist/remote/broadcast.js.map +1 -0
  165. package/dist/remote/config.d.ts +32 -0
  166. package/dist/remote/config.d.ts.map +1 -0
  167. package/dist/remote/config.js +65 -0
  168. package/dist/remote/config.js.map +1 -0
  169. package/dist/remote/endpoint.d.ts +30 -0
  170. package/dist/remote/endpoint.d.ts.map +1 -0
  171. package/dist/remote/endpoint.js +213 -0
  172. package/dist/remote/endpoint.js.map +1 -0
  173. package/dist/remote/hub-server.d.ts +41 -0
  174. package/dist/remote/hub-server.d.ts.map +1 -0
  175. package/dist/remote/hub-server.js +346 -0
  176. package/dist/remote/hub-server.js.map +1 -0
  177. package/dist/server.d.ts +139 -1
  178. package/dist/server.d.ts.map +1 -1
  179. package/dist/server.js +179 -4
  180. package/dist/server.js.map +1 -1
  181. package/dist/tasks-index.d.ts +14 -4
  182. package/dist/tasks-index.d.ts.map +1 -1
  183. package/dist/tasks-index.js +145 -46
  184. package/dist/tasks-index.js.map +1 -1
  185. package/dist/translators/event-translator.d.ts +33 -0
  186. package/dist/translators/event-translator.d.ts.map +1 -1
  187. package/dist/translators/event-translator.js +101 -0
  188. package/dist/translators/event-translator.js.map +1 -1
  189. package/dist/translators/index.d.ts +1 -1
  190. package/dist/translators/index.d.ts.map +1 -1
  191. package/dist/translators/index.js +1 -1
  192. package/dist/translators/index.js.map +1 -1
  193. package/dist/translators/projection-differ.d.ts +8 -0
  194. package/dist/translators/projection-differ.d.ts.map +1 -1
  195. package/dist/translators/projection-differ.js +19 -9
  196. package/dist/translators/projection-differ.js.map +1 -1
  197. package/dist/translators/tool-helpers.d.ts +28 -0
  198. package/dist/translators/tool-helpers.d.ts.map +1 -1
  199. package/dist/translators/tool-helpers.js +104 -0
  200. package/dist/translators/tool-helpers.js.map +1 -1
  201. package/dist/translators/types.d.ts +38 -1
  202. package/dist/translators/types.d.ts.map +1 -1
  203. package/dist/translators/types.js.map +1 -1
  204. package/dist/utils.d.ts +36 -7
  205. package/dist/utils.d.ts.map +1 -1
  206. package/dist/utils.js +69 -5
  207. package/dist/utils.js.map +1 -1
  208. package/docs/ARCHITECTURE.md +130 -40
  209. package/docs/BACKLOG.md +59 -0
  210. package/docs/DEVELOPMENT.md +31 -0
  211. package/docs/PROTOCOL.md +328 -23
  212. package/docs/REMOTE-CLIENTS.md +260 -0
  213. package/docs/REPLAY-GUIDE.md +131 -0
  214. package/docs/TROUBLESHOOTING.md +131 -14
  215. package/docs/adr/0001-bridge-lifetime-follows-primary-client.md +14 -0
  216. package/docs/adr/0002-stateless-hub-over-per-bridge-acp-endpoints.md +23 -0
  217. package/docs/adr/0003-tail-replay-meta-and-cursor-pagination.md +40 -0
  218. package/docs/agents/domain.md +51 -0
  219. package/docs/agents/issue-tracker.md +22 -0
  220. package/docs/agents/triage-labels.md +15 -0
  221. package/docs/proposals/0001-tail-session-replay.md +136 -0
  222. package/docs/proposals/0002-plan-quota-usage.md +81 -0
  223. package/package.json +10 -6
@@ -1,10 +1,14 @@
1
1
  /**
2
2
  * Session lifecycle handlers: initialize, new, list, resume, load, prompt, cancel.
3
3
  *
4
- * These map ACP session methods to ZCode app-server calls. `session/prompt` runs
5
- * the event-driven turn loop (subscribe-before-send ordering, no-progress
6
- * timeout, stall reconciliation). ZCode events are translated via
7
- * EventTranslator and dispatched as ACP `session/update` notifications.
4
+ * These map ACP session methods to ZCode app-server calls. `session/new` is
5
+ * lazy: it returns a placeholder id and defers zcode `session/create` to the
6
+ * session's first use (`ensureRealSession`), so an editor startup that never
7
+ * prompts leaves no empty session in the backend or the App's task index.
8
+ * `session/prompt` runs the event-driven turn loop (subscribe-before-send
9
+ * ordering, no-progress timeout, stall reconciliation). ZCode events are
10
+ * translated via EventTranslator and dispatched as ACP `session/update`
11
+ * notifications.
8
12
  */
9
13
  import process from "node:process";
10
14
  import { randomUUID } from "node:crypto";
@@ -12,56 +16,176 @@ import { RequestError } from "@agentclientprotocol/sdk";
12
16
  import { EventStreamListener, TurnMonitor } from "../backend/listener.js";
13
17
  import { buildModes, buildConfigOptions } from "../config/options.js";
14
18
  import { emitInitialUsage } from "../config/model-cache.js";
19
+ import { buildProviderRegistry } from "../config/provider-registry.js";
15
20
  import { buildResumeRuntimeModel } from "../config/runtime-model.js";
16
- import { buildDiffContent, EventTranslator, extractLocations, formatTurnError, ProjectionDiffer, } from "../translators/index.js";
21
+ import { lookupLazySession, recordMaterializedSession, rememberLazySession, } from "../lazy-sessions.js";
22
+ import { buildDiffContent, EventTranslator, extractLocations, formatTurnError, isTransientTurnError, ProjectionDiffer, } from "../translators/index.js";
17
23
  import { log, warn } from "../utils.js";
18
24
  import { dispatchEvent } from "./dispatch.js";
19
- import { sendSessionUpdate, sendTextChunk } from "./io.js";
25
+ import { sendSessionUpdate, sendTextChunk, withReplayBatch } from "./io.js";
26
+ import { fetchMessages, fullSlice, readTailLimit, replayMessages, sliceTail } from "./replay.js";
20
27
  import { handleServerRequests } from "./server-requests.js";
21
28
  /** Workspace descriptor used in session create/resume calls. */
22
29
  function workspaceFor(cwd) {
23
30
  const p = cwd || process.cwd();
24
31
  return { workspacePath: p, workspaceKey: p };
25
32
  }
33
+ /**
34
+ * Push the provider registry to the backend so third-party providers (those in
35
+ * config.json) are recognised. The V4 backend doesn't auto-load them from
36
+ * config.json — without this RPC a session switching to a third-party model
37
+ * fails with `provider_not_configured`. Best-effort: failures are logged, not
38
+ * thrown, so a registry push problem never blocks session creation.
39
+ */
40
+ async function syncProviderRegistry(server, cwd) {
41
+ try {
42
+ const registry = buildProviderRegistry();
43
+ const resp = await server
44
+ .ensureBackend()
45
+ .request(server.nextId(), "workspace/updateProviderRegistry", { workspace: workspaceFor(cwd), registry }, 10000);
46
+ if (resp.error) {
47
+ warn(`provider-registry: sync failed: ${resp.error.message}`);
48
+ return;
49
+ }
50
+ log("provider-registry: synced to backend");
51
+ }
52
+ catch (e) {
53
+ warn(`provider-registry: sync threw (${e instanceof Error ? e.message : String(e)})`);
54
+ }
55
+ }
26
56
  /** Convert a millisecond timestamp to ISO 8601 (for session list). */
27
57
  function toIso(ms) {
28
58
  if (typeof ms !== "number")
29
59
  return undefined;
30
60
  return new Date(ms).toISOString();
31
61
  }
32
- /** `session/new` → zcode `session/create` (mode hardcoded yolo). */
62
+ /**
63
+ * `session/new` → local placeholder id. The real zcode `session/create` is
64
+ * deferred to first use (`ensureRealSession`) so an editor startup that never
65
+ * sends a message leaves no empty session in the backend or the App's task
66
+ * index. The created session uses mode yolo (hardcoded).
67
+ */
33
68
  export async function newSession(server, params) {
34
- const backend = server.ensureBackend();
35
69
  const cwd = params.cwd ?? process.cwd();
36
- log(`session/new: cwd=${cwd}`);
37
- const resp = await backend.request(server.nextId(), "session/create", { workspace: workspaceFor(cwd), mode: "yolo" }, 15000);
38
- if (resp.error) {
39
- throw new Error(`zcode create failed: ${resp.error.message ?? ""}`);
40
- }
41
- const result = (resp.result ?? {});
42
- const session = result.session ?? {};
43
- const sid = session.sessionId;
44
- if (!sid)
45
- throw new Error("zcode create returned no sessionId");
46
- server.sessionMap.set(sid, sid);
47
- log(`session/new ${sid}`);
48
- // Sync to the App's tasks-index.sqlite so the App UI shows this session.
49
- // Best-effort; failures are logged inside upsertSessionTask and swallowed.
50
- const { upsertSessionTask } = await import("../tasks-index.js");
51
- void upsertSessionTask({
52
- workspaceKey: cwd,
53
- taskId: sid,
54
- title: session.title ?? "",
55
- traceId: session.traceId,
56
- });
57
- const modes = await buildModes(server, sid);
58
- server.lastMode.set(sid, modes.currentModeId);
70
+ // Placeholder id — the client addresses this session with it until the
71
+ // backend session materializes; never shown in session/list.
72
+ const acpSid = randomUUID();
73
+ server.pendingSessions.set(acpSid, { cwd, mcpServers: params.mcpServers });
74
+ // Persists past materialization (pendingSessions is cleared on first use) so
75
+ // the remote discovery payload can still label the workspace.
76
+ server.sessionCwds.set(acpSid, cwd);
77
+ // Durable alias so the placeholder survives a bridge restart and session/
78
+ // resume can still resolve it (best-effort; failures are swallowed inside
79
+ // the store).
80
+ rememberLazySession(acpSid, cwd);
81
+ // Only freshly-created sessions are eligible for auto-title on first
82
+ // end_turn; resumed/loaded sessions already have a title and must keep it.
83
+ server.titleEligibleSessions.add(acpSid);
84
+ log(`session/new (lazy) ${acpSid} cwd=${cwd}`);
85
+ // No backend RPC yet: modes/configOptions are built from defaults (the
86
+ // pending session's real values arrive via updates once materialized).
87
+ const modes = await buildModes(server, null);
88
+ server.lastMode.set(acpSid, modes.currentModeId);
59
89
  return {
60
- sessionId: sid,
90
+ sessionId: acpSid,
61
91
  modes,
62
- configOptions: await buildConfigOptions(server, sid),
92
+ configOptions: await buildConfigOptions(server, null),
63
93
  };
64
94
  }
95
+ /**
96
+ * Materialize a lazy `session/new` placeholder into a real backend session on
97
+ * first use (prompt / set_config_option / extension methods). Idempotent:
98
+ * returns the existing mapping for already-created sessions, and concurrent
99
+ * first-uses share a single `session/create` via the pending entry's `creating`
100
+ * promise. Unknown ids throw.
101
+ */
102
+ export async function ensureRealSession(server, acpSid) {
103
+ const existing = server.resolveSid(acpSid);
104
+ if (existing)
105
+ return existing;
106
+ let pending = server.pendingSessions.get(acpSid);
107
+ if (!pending) {
108
+ // Placeholder from a previous bridge lifetime: recover it from the durable
109
+ // store. A record that already carries a zcodeSid maps straight through
110
+ // (the backend session still exists — re-register the alias); one without
111
+ // re-hydrates the pending entry so the create path below runs.
112
+ const record = lookupLazySession(acpSid);
113
+ if (record?.zcodeSid) {
114
+ server.registerSession(acpSid, record.zcodeSid);
115
+ return record.zcodeSid;
116
+ }
117
+ if (record) {
118
+ pending = { cwd: record.cwd };
119
+ server.pendingSessions.set(acpSid, pending);
120
+ server.sessionCwds.set(acpSid, record.cwd);
121
+ }
122
+ }
123
+ if (!pending)
124
+ throw new Error(`session ${acpSid} not found`);
125
+ if (pending.creating)
126
+ return pending.creating;
127
+ // The create body runs synchronously up to its first await, so the `creating`
128
+ // promise is stored before any concurrent caller can observe the entry.
129
+ const creating = (async () => {
130
+ const backend = server.ensureBackend();
131
+ // Push the provider registry BEFORE session/create: the backend resolves
132
+ // the session's default model against the registry, and without the
133
+ // provider's reasoning/model definitions it falls back to the bare
134
+ // anthropic channel (2-state thought: enabled/disabled) instead of the
135
+ // real provider (max/high/low). Also covers third-party providers for
136
+ // later model switches (provider_not_configured). Best-effort — a failed
137
+ // push logs and continues, the session still works over the fallback.
138
+ await syncProviderRegistry(server, pending.cwd);
139
+ // Client-provided MCP servers (ACP session/new mcpServers) ride along
140
+ // when the lazy session materializes. The backend accepts the ACP array
141
+ // shape verbatim; the verified merge behaviour is additive (client
142
+ // entries appear next to the runtime's own local config). Same-name
143
+ // clash behaviour is the backend's own and unasserted here.
144
+ const createParams = {
145
+ workspace: workspaceFor(pending.cwd),
146
+ mode: "yolo",
147
+ };
148
+ if (pending.mcpServers && pending.mcpServers.length > 0) {
149
+ createParams.mcpServers = pending.mcpServers;
150
+ log(`session/create carrying ${pending.mcpServers.length} client MCP server(s)`);
151
+ }
152
+ const resp = await backend.request(server.nextId(), "session/create", createParams, 15000);
153
+ if (resp.error) {
154
+ throw new Error(`zcode create failed: ${resp.error.message ?? ""}`);
155
+ }
156
+ const result = (resp.result ?? {});
157
+ const session = result.session ?? {};
158
+ const sid = session.sessionId;
159
+ if (!sid)
160
+ throw new Error("zcode create returned no sessionId");
161
+ server.pendingSessions.delete(acpSid);
162
+ server.registerSession(acpSid, sid);
163
+ // Keep the durable alias in sync so a later bridge restart can still
164
+ // resume this session via the placeholder id.
165
+ recordMaterializedSession(acpSid, sid, pending.cwd);
166
+ log(`session/new ${acpSid} → created ${sid} (lazy, on first use)`);
167
+ server.ensureBackgroundListener(sid);
168
+ // Sync to the App's tasks-index.sqlite so the App UI shows this session.
169
+ // Best-effort; failures are logged inside upsertSessionTask and swallowed.
170
+ const { upsertSessionTask } = await import("../tasks-index.js");
171
+ void upsertSessionTask({
172
+ workspaceKey: pending.cwd,
173
+ taskId: sid,
174
+ title: session.title ?? "",
175
+ traceId: session.traceId,
176
+ });
177
+ return sid;
178
+ })();
179
+ pending.creating = creating;
180
+ try {
181
+ return await creating;
182
+ }
183
+ finally {
184
+ // Reset the in-flight marker (on success the sessionMap short-circuits
185
+ // later calls; on failure this lets the next use retry the create).
186
+ pending.creating = undefined;
187
+ }
188
+ }
65
189
  /** `session/list` → zcode `session/list`. */
66
190
  export async function listSessions(server, params) {
67
191
  const backend = server.ensureBackend();
@@ -82,36 +206,117 @@ export async function listSessions(server, params) {
82
206
  log(`session/list → ${sessions.length} sessions`);
83
207
  return { sessions };
84
208
  }
85
- /** `session/resume` → zcode `session/resume` (with runtimeModel overlay for resumed sessions). */
209
+ /**
210
+ * Adopt the backend's stored title for a loaded/resumed session.
211
+ *
212
+ * The prompt loop's auto-title only fires for freshly created sessions
213
+ * (`titleEligibleSessions`), so a session resumed across a bridge restart
214
+ * would otherwise appear title-less in the hub's discovery API — remote
215
+ * clients have no editor-side session storage to fall back on. The backend's
216
+ * session/list is the only title source for sessions born in a previous
217
+ * bridge lifetime. Best-effort: failures log and leave the session untitled.
218
+ */
219
+ async function adoptStoredTitle(server, acpSid, zcodeSid) {
220
+ if (server.sessionTitles.has(acpSid))
221
+ return;
222
+ try {
223
+ const backend = server.ensureBackend();
224
+ const resp = await backend.request(server.nextId(), "session/list", {}, 15000);
225
+ if (resp.error)
226
+ return;
227
+ const result = (resp.result ?? {});
228
+ const hit = (result.sessions ?? []).find((s) => s.sessionId === zcodeSid);
229
+ if (hit?.title) {
230
+ server.sessionTitles.set(acpSid, hit.title);
231
+ server.touchSessionSummary(acpSid, hit.title);
232
+ log(`adopted stored title for ${acpSid.slice(0, 8)}: ${hit.title}`);
233
+ }
234
+ }
235
+ catch (e) {
236
+ log(`stored title lookup failed (non-fatal): ${e instanceof Error ? e.message : String(e)}`);
237
+ }
238
+ }
239
+ /**
240
+ * Resolve the backend session id for `session/resume` / `session/load`.
241
+ *
242
+ * A `session/new` placeholder has no backend counterpart until first use, yet
243
+ * the editor may resume it anyway (panel reopen, bridge restart) — resolving it
244
+ * here prevents an otherwise unavoidable "Session not found". Resolution order:
245
+ * 1. in-memory mapping → the session is already live in this subprocess;
246
+ * 2. pending placeholder → materialize it (an empty session, matching the
247
+ * pre-lazy behavior where a never-used session/new always resumed);
248
+ * 3. durable store → a placeholder from a previous bridge lifetime: with a
249
+ * recorded zcodeSid the backend session still exists but isn't loaded into
250
+ * this subprocess (the resume RPC is needed); without one, materialize
251
+ * fresh;
252
+ * 4. anything else (a real id from session/list, or a stale id) → pass
253
+ * through unchanged; genuinely missing sessions still error downstream.
254
+ */
255
+ async function resolveResumeTarget(server, acpSid) {
256
+ const mapped = server.resolveSid(acpSid);
257
+ if (mapped)
258
+ return { zcodeSid: mapped, alreadyLive: true };
259
+ if (server.pendingSessions.has(acpSid)) {
260
+ return { zcodeSid: await ensureRealSession(server, acpSid), alreadyLive: true };
261
+ }
262
+ const record = lookupLazySession(acpSid);
263
+ if (record) {
264
+ // ensureRealSession recovers the record: with a zcodeSid it re-registers
265
+ // the alias (no create), without one it materializes a fresh session.
266
+ return {
267
+ zcodeSid: await ensureRealSession(server, acpSid),
268
+ alreadyLive: !record.zcodeSid,
269
+ };
270
+ }
271
+ return { zcodeSid: acpSid, alreadyLive: false };
272
+ }
273
+ /** `session/resume` → zcode `session/resume` (with runtimeModel overlay). */
86
274
  export async function resumeSession(server, params, cx) {
87
- const backend = server.ensureBackend();
88
- const targetSid = params.sessionId;
275
+ const acpSid = params.sessionId;
89
276
  const cwd = params.cwd ?? process.cwd();
90
- if (!targetSid)
277
+ if (!acpSid)
91
278
  throw new Error("sessionId required");
92
- const zcParams = {
93
- sessionId: targetSid,
94
- workspace: workspaceFor(cwd),
95
- };
96
- // runtimeModel overlay: a resumed session may carry a stale provider id in
97
- // its history backend can't auth. Send the current enabled provider so the
98
- // backend overlays it and uses its own OAuth creds.
99
- const runtimeModel = buildResumeRuntimeModel();
100
- if (runtimeModel !== null)
101
- zcParams.runtimeModel = runtimeModel;
102
- const resp = await backend.request(server.nextId(), "session/resume", zcParams, 15000);
103
- if (resp.error)
104
- throw new Error(`zcode resume failed: ${resp.error.message ?? ""}`);
105
- server.sessionMap.set(targetSid, targetSid);
106
- log(`session/resume -> ${targetSid}`);
279
+ // Lazy placeholders (session/new) resolve to their real backend session
280
+ // here; alreadyLive targets skip the resume RPC because the session is live
281
+ // in this backend subprocess.
282
+ const { zcodeSid, alreadyLive } = await resolveResumeTarget(server, acpSid);
283
+ if (!alreadyLive) {
284
+ // runtimeModel overlay: a resumed session may carry a stale/revoked model in
285
+ // its history send fails with "历史模型不可用". Overlaying the current
286
+ // enabled provider redirects the session onto a working model. The overlay
287
+ // deliberately carries NO apiKey (the backend's schema rejects it; it resolves
288
+ // auth from its own config/OAuth store).
289
+ const zcParams = {
290
+ sessionId: zcodeSid,
291
+ workspace: workspaceFor(cwd),
292
+ };
293
+ // ACP session/resume may also carry mcpServers; the backend's resume
294
+ // schema accepts the same array shape (verified: an unknown key would be
295
+ // rejected before the session lookup).
296
+ if (params.mcpServers && params.mcpServers.length > 0) {
297
+ zcParams.mcpServers = params.mcpServers;
298
+ }
299
+ const runtimeModel = buildResumeRuntimeModel();
300
+ if (runtimeModel !== null)
301
+ zcParams.runtimeModel = runtimeModel;
302
+ // Push the provider registry BEFORE resume: a resumed session may carry a
303
+ // third-party model in its history, and the backend needs the provider
304
+ // registered to even process the resume turn.
305
+ await syncProviderRegistry(server, cwd);
306
+ await resumeBackendSession(server, zcParams);
307
+ }
308
+ server.registerSession(acpSid, zcodeSid);
309
+ log(`session/resume -> ${zcodeSid}`);
310
+ server.ensureBackgroundListener(zcodeSid);
311
+ await adoptStoredTitle(server, acpSid, zcodeSid);
107
312
  // Initial usage_update so the editor shows the context bar immediately for a
108
313
  // resumed session (mirrors Python _on_session_resume → _emit_initial_usage).
109
- await emitInitialUsage(server, cx, targetSid, targetSid, getOrCreateDiffer(server, targetSid));
110
- const modes = await buildModes(server, targetSid);
111
- server.lastMode.set(targetSid, modes.currentModeId);
314
+ await emitInitialUsage(server, cx, acpSid, zcodeSid, getOrCreateDiffer(server, zcodeSid));
315
+ const modes = await buildModes(server, zcodeSid);
316
+ server.lastMode.set(acpSid, modes.currentModeId);
112
317
  return {
113
318
  modes,
114
- configOptions: await buildConfigOptions(server, targetSid),
319
+ configOptions: await buildConfigOptions(server, zcodeSid),
115
320
  };
116
321
  }
117
322
  /**
@@ -119,113 +324,91 @@ export async function resumeSession(server, params, cx) {
119
324
  * `session/update` notifications (text/reasoning/简化 tool_call).
120
325
  */
121
326
  export async function loadSession(server, params, cx) {
122
- const backend = server.ensureBackend();
123
- const targetSid = params.sessionId;
327
+ const acpSid = params.sessionId;
124
328
  const cwd = params.cwd ?? process.cwd();
125
- if (!targetSid)
329
+ if (!acpSid)
126
330
  throw new Error("sessionId required");
127
- const zcParams = {
128
- sessionId: targetSid,
129
- workspace: workspaceFor(cwd),
130
- };
131
- const runtimeModel = buildResumeRuntimeModel();
132
- if (runtimeModel !== null)
133
- zcParams.runtimeModel = runtimeModel;
134
- const resp = await backend.request(server.nextId(), "session/resume", zcParams, 15000);
135
- if (resp.error)
136
- throw new Error(`zcode resume failed: ${resp.error.message ?? ""}`);
137
- server.sessionMap.set(targetSid, targetSid);
138
- log(`session/load ${targetSid}`);
139
- const messages = await fetchMessages(server, targetSid);
140
- let replayed = 0;
141
- for (const m of messages) {
142
- const info = m.info ?? {};
143
- const role = info.role;
144
- const mid = info.id ?? `hist_${randomUUID().slice(0, 12)}`;
145
- for (const p of m.parts ?? []) {
146
- if (!p || typeof p !== "object")
147
- continue;
148
- const ptype = p.type;
149
- if (ptype === "text") {
150
- const text = p.text ?? "";
151
- if (!text)
152
- continue;
153
- const sessionUpdate = role === "user" ? "user_message_chunk" : "agent_message_chunk";
154
- await sendSessionUpdate(cx, targetSid, {
155
- sessionUpdate,
156
- content: { type: "text", text },
157
- messageId: mid,
158
- });
159
- }
160
- else if (ptype === "reasoning") {
161
- const rp = p;
162
- const text = rp.text ?? rp.content ?? "";
163
- if (text) {
164
- await sendSessionUpdate(cx, targetSid, {
165
- sessionUpdate: "agent_thought_chunk",
166
- content: { type: "text", text },
167
- messageId: `thought_${mid}`,
168
- });
169
- }
170
- }
171
- else if (ptype === "tool") {
172
- const tp = p;
173
- const title = tp.title ?? tp.tool ?? "tool call";
174
- const histToolName = tp.tool ?? "";
175
- const update = {
176
- sessionUpdate: "tool_call",
177
- toolCallId: tp.id ?? `histtool_${randomUUID().slice(0, 8)}`,
178
- title,
179
- kind: "other",
180
- status: tp.status ?? "completed",
181
- ...(histToolName ? { _meta: { claudeCode: { toolName: histToolName } } } : {}),
182
- };
183
- await sendSessionUpdate(cx, targetSid, update);
184
- }
185
- // patch / step-start / other: skipped (history replay focuses on text + tool summary)
186
- }
187
- replayed += 1;
331
+ // Same placeholder resolution as resumeSession; alreadyLive targets skip the
332
+ // backend resume RPC (the session is live in this subprocess).
333
+ const { zcodeSid, alreadyLive } = await resolveResumeTarget(server, acpSid);
334
+ if (!alreadyLive) {
335
+ const zcParams = {
336
+ sessionId: zcodeSid,
337
+ workspace: workspaceFor(cwd),
338
+ };
339
+ const runtimeModel = buildResumeRuntimeModel();
340
+ if (runtimeModel !== null)
341
+ zcParams.runtimeModel = runtimeModel;
342
+ // Push the provider registry BEFORE resume: a loaded session may carry a
343
+ // third-party model in its history, and the backend needs the provider
344
+ // registered to process it.
345
+ await syncProviderRegistry(server, cwd);
346
+ await resumeBackendSession(server, zcParams);
188
347
  }
189
- log(`session/load: replayed ${replayed} messages`);
348
+ server.registerSession(acpSid, zcodeSid);
349
+ log(`session/load → ${zcodeSid}`);
350
+ server.ensureBackgroundListener(zcodeSid);
351
+ await adoptStoredTitle(server, acpSid, zcodeSid);
352
+ const messages = await fetchMessages(server, zcodeSid);
353
+ // Tail replay (Proposal 0001): a `_meta.zcode.limit` replays only the last
354
+ // N messages aligned to turn boundaries — the full replay stays the default
355
+ // for editors that send no `_meta` (Zed path unchanged).
356
+ const limit = readTailLimit(params);
357
+ const slice = limit === null ? fullSlice(messages) : sliceTail(messages, limit);
358
+ await withReplayBatch(acpSid, () => replayMessages(cx, acpSid, slice.batch));
359
+ log(`session/load: replayed ${slice.meta.replayedMessages} messages` +
360
+ `${limit === null ? "" : ` (tail limit ${limit}, total ${slice.meta.totalMessages})`}`);
190
361
  // Replay the existing todo list as an initial plan so a loaded session shows
191
362
  // its todos immediately (filter to PlanUpdate only — text/tools were already
192
363
  // replayed above and the differ hasn't mark_seen'd this history).
193
364
  try {
194
- const snapshot = await buildSnapshot(server, targetSid);
195
- const loadDiffer = getOrCreateDiffer(server, targetSid);
365
+ const snapshot = await buildSnapshot(server, zcodeSid);
366
+ const loadDiffer = getOrCreateDiffer(server, zcodeSid);
196
367
  const planEvents = loadDiffer.diff(snapshot).filter((e) => e.kind === "PlanUpdate");
197
368
  for (const iev of planEvents) {
198
- await dispatchEvent(server, cx, targetSid, iev, `load_${randomUUID().slice(0, 8)}`);
369
+ await dispatchEvent(server, cx, acpSid, iev, `load_${randomUUID().slice(0, 8)}`);
199
370
  }
200
371
  }
201
372
  catch (e) {
202
373
  log(`session/load: initial plan read failed (non-fatal): ${e instanceof Error ? e.message : String(e)}`);
203
374
  }
204
375
  // Initial usage_update so the editor shows the context bar immediately.
205
- await emitInitialUsage(server, cx, targetSid, targetSid, getOrCreateDiffer(server, targetSid));
206
- const modes = await buildModes(server, targetSid);
207
- server.lastMode.set(targetSid, modes.currentModeId);
208
- return {
376
+ await emitInitialUsage(server, cx, acpSid, zcodeSid, getOrCreateDiffer(server, zcodeSid));
377
+ const modes = await buildModes(server, zcodeSid);
378
+ server.lastMode.set(acpSid, modes.currentModeId);
379
+ const result = {
209
380
  modes,
210
- configOptions: await buildConfigOptions(server, targetSid),
381
+ configOptions: await buildConfigOptions(server, zcodeSid),
382
+ // Additive replay metadata — the anchor for load_earlier pagination.
383
+ replayMeta: slice.meta,
211
384
  };
385
+ return result;
212
386
  }
213
387
  /** `session/prompt` → subscribe-before-send, run the event-driven turn loop. */
214
388
  export async function prompt(server, params, cx, requestId) {
215
389
  const backend = server.ensureBackend();
216
- const zcodeSid = server.resolveSid(params.sessionId);
217
- if (!zcodeSid)
218
- throw new Error(`session ${params.sessionId} not found`);
219
- // Extract prompt text from ACP ContentBlock[].
390
+ // Extract prompt text + image attachments from ACP ContentBlock[].
220
391
  const text = extractPromptText(params.prompt);
221
- if (!text)
392
+ const attachments = extractAttachments(params.prompt);
393
+ // A prompt is valid if it has text OR at least one image attachment (a user
394
+ // may drag in an image with no accompanying text).
395
+ if (!text && attachments.length === 0)
222
396
  throw new Error("empty prompt");
397
+ // Materialize a lazy session/new placeholder on first use. Placed after the
398
+ // empty-prompt check so an invalid request doesn't create a backend session.
399
+ const zcodeSid = await ensureRealSession(server, params.sessionId);
223
400
  // Slash-command interception: dispatches directly to ZCode methods and
224
- // returns end_turn without entering the turn loop. Unknown /x falls through.
225
- const { handleSlashCommand } = await import("./slash.js");
401
+ // returns end_turn without entering the turn loop. Known passthrough
402
+ // commands and unknown /x both return null for the normal turn loop.
403
+ const { handleSlashCommand, neutralizeSlashText } = await import("./slash.js");
226
404
  const intercepted = await handleSlashCommand(server, cx, params.sessionId, zcodeSid, text);
227
405
  if (intercepted)
228
406
  return intercepted;
407
+ // Wire text for the backend: unknown `/x` prompts (not advertised commands)
408
+ // are neutralized so the backend's command resolver never sees them — an
409
+ // unresolvable name can hard-fail the turn. Known commands pass through
410
+ // unchanged. The title/auto-compact paths below keep using the raw `text`.
411
+ const sendText = neutralizeSlashText(text);
229
412
  // Register self + preempt others under a per-session lock. The lock
230
413
  // serializes the critical section so that two concurrent prompts (B, C) for
231
414
  // the same session can't both miss each other and register at once: C waits
@@ -236,9 +419,15 @@ export async function prompt(server, params, cx, requestId) {
236
419
  zcodeSid,
237
420
  cancelled: false,
238
421
  };
239
- await withPreemptLock(server, zcodeSid, () => {
422
+ // True when this send cancelled another in-flight prompt (preempt/stop).
423
+ // Drives the turn-attribution gate: only a preempted prompt can see leftover
424
+ // events from a prior turn in its listener queue; without preemption any
425
+ // events before this turn's turn.started belong to a backend-owned turn
426
+ // (e.g. auto-resumed after compaction) that this send was steered into.
427
+ let preempted = false;
428
+ await withPreemptLock(server, zcodeSid, async () => {
240
429
  server.pendingTurns.set(requestId, turn);
241
- return preemptInFlightTurn(server, zcodeSid, requestId);
430
+ preempted = preemptInFlightTurn(server, zcodeSid, requestId);
242
431
  });
243
432
  const listener = new EventStreamListener(backend, zcodeSid);
244
433
  const monitor = new TurnMonitor(backend, zcodeSid, () => server.nextId());
@@ -248,45 +437,180 @@ export async function prompt(server, params, cx, requestId) {
248
437
  const baselineMsgs = await fetchMessages(server, zcodeSid);
249
438
  differ.markSeen(baselineMsgs);
250
439
  // Subscribe BEFORE send so we don't lose early turn.completed on short turns.
251
- const snapshot = await listener.subscribe(() => server.nextId());
252
- if (snapshot === null) {
440
+ // subscribe() throws on failure, surfacing the backend's real error (reader
441
+ // dead, timeout, pipe broken, method-not-found on old CLI, session error) so
442
+ // the cause is distinguishable. Clean up the pending turn before propagating
443
+ // — this call site is outside the try/finally below.
444
+ let snapshot;
445
+ try {
446
+ snapshot = await listener.subscribe(() => server.nextId());
447
+ }
448
+ catch (e) {
253
449
  server.pendingTurns.delete(requestId);
254
- throw new Error("session/subscribe failed (ZCode CLI 0.14.8+ required)");
450
+ throw e;
255
451
  }
452
+ // subscribe() requests includeSnapshot:false (it only needs the eventSeq
453
+ // watermark to arm the event stream), so `snapshot` is an empty fallback.
454
+ // The real projection baseline comes from fetchMessages + differ.markSeen
455
+ // above. Kept as a binding only so the call fits the Promise-returning shape.
456
+ void snapshot;
256
457
  backend.registerEventListener(zcodeSid, listener);
257
- const chunkMsgId = randomUUID();
258
458
  try {
259
- const sendResp = await backend.request(server.nextId(), "session/send", { sessionId: zcodeSid, content: text }, 15000);
260
- if (sendResp.error) {
261
- // send failed/timeout. Don't fire stop here: a send failure usually
262
- // means the turn never started (no lock to leak). Mirrors Python which
263
- // just returns the error without stopping.
264
- throw new Error(`zcode send failed: ${sendResp.error.message ?? ""}`);
265
- }
266
- const accepted = (sendResp.result ?? {});
267
- if (!accepted.accepted)
268
- throw new Error("zcode send not accepted");
269
- // Event-driven turn loop: translate events via EventTranslator + dispatch.
270
- const result = await runEventTurn(server, listener, monitor, differ, cx, params.sessionId, chunkMsgId, turn);
271
- // Session title: set once on the first end_turn of this session. The title
272
- // is the first prompt text (truncated). Subsequent turns never overwrite it
273
- // (set-once gate), and the App's title_overridden flag always wins.
274
- if (result.stopReason === "end_turn" && !server.sessionTitles.has(params.sessionId)) {
275
- const title = text.slice(0, 80);
276
- server.sessionTitles.set(params.sessionId, title);
277
- const { updateSessionTitle } = await import("../tasks-index.js");
278
- void updateSessionTitle(zcodeSid, title);
279
- await sendSessionUpdate(cx, params.sessionId, {
280
- sessionUpdate: "session_info_update",
281
- title,
282
- updatedAt: new Date().toISOString(),
283
- });
459
+ // Transient turn failures (e.g. provider network blips surfaced as
460
+ // turn.failed with cause code model_request_failed) are retried by
461
+ // re-sending the prompt and re-running the event loop, instead of
462
+ // surfacing a hard error that stops the session. Non-transient failures
463
+ // (send rejected, non-transient turn error) propagate immediately. After
464
+ // exhausting retries on a transient error we degrade gracefully: emit a
465
+ // user-visible message and return end_turn so the session stays usable.
466
+ // 1 initial attempt + 5 retries. Backoff grows exponentially then caps so
467
+ // later retries don't keep stretching: 1s, 2s, 4s, 4s, 4s.
468
+ const MAX_TURN_ATTEMPTS = 6;
469
+ const MAX_BACKOFF_MS = 4000;
470
+ const backoffMs = (attempt) => Math.min(1000 * 2 ** (attempt - 1), MAX_BACKOFF_MS);
471
+ let lastTurnError = null;
472
+ for (let attempt = 1; attempt <= MAX_TURN_ATTEMPTS; attempt++) {
473
+ if (attempt > 1) {
474
+ // A prior transient turn ended the backend turn; before re-sending,
475
+ // reconcile the differ baseline so the retried turn's new messages
476
+ // aren't treated as already-seen, surface a retry hint, then back off.
477
+ if (turn.cancelled) {
478
+ stopBackendTurn(server, zcodeSid);
479
+ return { stopReason: "cancelled" };
480
+ }
481
+ differ.markSeen(await fetchMessages(server, zcodeSid));
482
+ await sendTextChunk(cx, params.sessionId, `[网络异常,正在重试 (${attempt - 1}/${MAX_TURN_ATTEMPTS - 1})…]`, randomUUID());
483
+ log(` [retry] transient turn failed, re-sending (attempt ${attempt}/${MAX_TURN_ATTEMPTS})`);
484
+ await sleep(backoffMs(attempt - 1));
485
+ }
486
+ const chunkMsgId = randomUUID();
487
+ // Send the prompt, retrying while the backend reports it's still busy.
488
+ // The backend's prompt lock is the single authoritative readiness signal:
489
+ // a rejected send (code 1308 "prompt is running") means a previous turn
490
+ // (cancelled, preempted, or still finalising) hasn't released the lock
491
+ // yet. Rather than guessing when the backend is ready — or blocking on a
492
+ // local shadow flag — we retry with a fixed delay until the backend
493
+ // accepts. This covers the preempt path (new prompt interrupting an
494
+ // in-flight one) and the stop-recovery window after a manual cancel.
495
+ const SEND_RETRY_INTERVAL_MS = 500;
496
+ const SEND_RETRY_TIMEOUT_MS = 30_000;
497
+ const sendParams = attachments.length > 0
498
+ ? { sessionId: zcodeSid, content: sendText, attachments }
499
+ : { sessionId: zcodeSid, content: sendText };
500
+ const sendT0 = Date.now();
501
+ let sendAttempt = 0;
502
+ while (true) {
503
+ if (turn.cancelled) {
504
+ stopBackendTurn(server, zcodeSid);
505
+ return { stopReason: "cancelled" };
506
+ }
507
+ sendAttempt++;
508
+ // Wait before sending when a recent cancel/preempt makes a busy reject
509
+ // likely — right after stop the backend is in its recovery window and
510
+ // will reject an immediate send. On the first attempt with no recent
511
+ // cancel, send immediately so normal prompts aren't delayed.
512
+ const recentCancel = server.lastCancelledAt.get(zcodeSid);
513
+ const expectBusy = sendAttempt > 1 ||
514
+ (recentCancel !== undefined && Date.now() - recentCancel < SEND_RETRY_TIMEOUT_MS);
515
+ if (expectBusy) {
516
+ await sleep(SEND_RETRY_INTERVAL_MS);
517
+ if (turn.cancelled) {
518
+ stopBackendTurn(server, zcodeSid);
519
+ return { stopReason: "cancelled" };
520
+ }
521
+ }
522
+ const sendResp = await backend.request(server.nextId(), "session/send", sendParams, 15000);
523
+ if (!sendResp.error) {
524
+ const accepted = (sendResp.result ?? {});
525
+ if (accepted.accepted)
526
+ break; // backend took it → turn starts
527
+ throw new Error("zcode send not accepted");
528
+ }
529
+ const sendErrCode = sendResp.error.code;
530
+ const sendErrMsg = (sendResp.error.message ?? "").toLowerCase();
531
+ const isBusy = sendErrCode === 1308 ||
532
+ sendErrMsg.includes("prompt is running") ||
533
+ sendErrMsg.includes("already running");
534
+ if (!isBusy) {
535
+ // Non-busy error (auth, malformed, etc.) — don't retry, surface it.
536
+ throw new Error(`zcode send failed: ${sendResp.error.message ?? ""}`);
537
+ }
538
+ if (Date.now() - sendT0 > SEND_RETRY_TIMEOUT_MS) {
539
+ throw new Error(`zcode send failed: backend still busy after ${Math.round(SEND_RETRY_TIMEOUT_MS / 1000)}s (${sendResp.error.message ?? ""})`);
540
+ }
541
+ log(` [send] backend busy (${sendResp.error.message ?? ""}), retrying in ${SEND_RETRY_INTERVAL_MS}ms`);
542
+ }
543
+ try {
544
+ // Event-driven turn loop: translate events via EventTranslator + dispatch.
545
+ const result = await runEventTurn(server, listener, monitor, differ, cx, params.sessionId, chunkMsgId, turn, preempted);
546
+ // Session title: set once on the first end_turn, but ONLY for freshly
547
+ // created sessions. Resumed/loaded sessions already carry a title from
548
+ // their history and must not be overwritten by the first post-load
549
+ // message. sessionTitles enforces set-once within a session;
550
+ // titleEligibleSessions gates which sessions are titled at all.
551
+ if (result.stopReason === "end_turn" &&
552
+ server.titleEligibleSessions.has(params.sessionId) &&
553
+ !server.sessionTitles.has(params.sessionId)) {
554
+ // Title = first non-empty line of the prompt, truncated to 80 chars.
555
+ // Multi-line prompts must not leak newlines into the session title.
556
+ // Split on any line break (\r\n, \n, \r) so all platforms are covered.
557
+ const title = text
558
+ .split(/\r\n|\r|\n/)
559
+ .map((l) => l.trim())
560
+ .find((l) => l.length > 0)
561
+ ?.slice(0, 80) ?? text.slice(0, 80);
562
+ server.sessionTitles.set(params.sessionId, title);
563
+ server.touchSessionSummary(params.sessionId, title);
564
+ const { updateSessionTitle } = await import("../tasks-index.js");
565
+ void updateSessionTitle(zcodeSid, title, text);
566
+ await sendSessionUpdate(cx, params.sessionId, {
567
+ sessionUpdate: "session_info_update",
568
+ title,
569
+ updatedAt: new Date().toISOString(),
570
+ });
571
+ }
572
+ // Auto-compact: if context usage exceeds the threshold, compact before
573
+ // returning so the next prompt has room. Configured via
574
+ // ZCODE_ACP_AUTO_COMPACT_THRESHOLD (absolute token count; 0/unset =
575
+ // disabled). Only on end_turn — cancelled/max_turn_requests skips
576
+ // compaction, as does a stall-recovered end_turn (the completion was
577
+ // inferred by the stall heuristic, not confirmed by turn.completed —
578
+ // compressing an in-flight task's context would destroy the work).
579
+ // Best-effort: failures are logged inside maybeAutoCompact, never thrown.
580
+ if (result.stopReason === "end_turn" && !turn.stallRecovered) {
581
+ const { maybeAutoCompact } = await import("../config/auto-compact.js");
582
+ await maybeAutoCompact(server, cx, params.sessionId, zcodeSid);
583
+ }
584
+ return result;
585
+ }
586
+ catch (e) {
587
+ // Only a transient TurnFailedError is retryable; everything else (send
588
+ // failures, non-transient turn errors, exhausted retries, cancellation)
589
+ // propagates to the caller.
590
+ if (e instanceof TurnFailedError &&
591
+ attempt < MAX_TURN_ATTEMPTS &&
592
+ !turn.cancelled &&
593
+ isTransientTurnError(e.turnError)) {
594
+ lastTurnError = e.turnError;
595
+ continue;
596
+ }
597
+ throw e;
598
+ }
284
599
  }
285
- return result;
600
+ // All retries exhausted on a transient error → degrade gracefully. Keep the
601
+ // session usable so the user can resend the message instead of the editor
602
+ // surfacing a hard error and stopping. Skip auto-compact here: compaction
603
+ // after a failed turn is more likely to confuse state than help.
604
+ const errMsg = formatTurnError(lastTurnError) || "turn failed after retries";
605
+ await sendTextChunk(cx, params.sessionId, `[请求失败:${errMsg}。会话仍可用,请重新发送消息重试。]`, randomUUID());
606
+ return { stopReason: "end_turn" };
286
607
  }
287
608
  finally {
288
- backend.unregisterEventListener(zcodeSid);
609
+ backend.unregisterEventListener(zcodeSid, listener);
289
610
  server.pendingTurns.delete(requestId);
611
+ // Turn end = session activity — refresh the discovery summary timestamp
612
+ // regardless of outcome (end_turn, cancelled, retries exhausted).
613
+ server.touchSessionSummary(params.sessionId);
290
614
  }
291
615
  }
292
616
  /**
@@ -294,12 +618,11 @@ export async function prompt(server, params, cx, requestId) {
294
618
  * resulting config_option_update (+ current_mode_update for mode).
295
619
  */
296
620
  export async function setConfigOptionHandler(server, params, cx) {
297
- const zcodeSid = server.resolveSid(params.sessionId);
298
- if (!zcodeSid)
299
- throw new Error(`session ${params.sessionId} not found`);
300
621
  if (typeof params.value !== "string") {
301
622
  throw new Error(`unsupported config value type: ${String(params.value)}`);
302
623
  }
624
+ // Materialize a lazy session/new placeholder on first use.
625
+ const zcodeSid = await ensureRealSession(server, params.sessionId);
303
626
  const { setConfigOption, emitConfigOptionUpdate } = await import("../config/options.js");
304
627
  const result = await setConfigOption(server, zcodeSid, params.configId, params.value);
305
628
  if (!result) {
@@ -309,32 +632,71 @@ export async function setConfigOptionHandler(server, params, cx) {
309
632
  return { configOptions: options };
310
633
  }
311
634
  /**
312
- * `session/cancel` → mark the pending turn cancelled. The turn loop observes
313
- * the flag and forwards `session/stop` itself (mirrors Python: cancel only
314
- * sets the flag; stop is sent by `_run_event_turn`). Eagerly sending stop
315
- * here would race with a turn that already completed.
635
+ * `session/cancel` → stop the in-flight turn immediately. Mirrors the ZCode
636
+ * App's stop button, which sends a stop command directly (there is no
637
+ * "cancel" concept on the client only stop).
638
+ *
639
+ * We fire `session/stop` here instead of deferring it to the turn loop. The
640
+ * loop is blocked for seconds at a time behind awaits (handleServerRequests
641
+ * waiting on a permission popup; dispatchEvent running per-event; the
642
+ * tool-result path awaiting dispatchEditDiff/dispatchPlanIfChanged backend
643
+ * calls with up to 8s timeouts). A deferred stop only fires once the loop
644
+ * finishes whatever await it is stuck in, so the user's press of stop can lag
645
+ * by the full remaining await window — the turn visibly "keeps running".
646
+ * `session/stop` is fire-and-forget and fully idempotent (the backend no-ops
647
+ * on a session with no active turn, and on a turn already aborted), so firing
648
+ * it eagerly is safe; the loop's `stopSent` guard prevents a second send.
649
+ *
650
+ * `turn.cancelled` is still set so the turn loop switches to its silent-drain
651
+ * path (translate to detect turnDone, but discard every internal event — no
652
+ * text/tool/usage is pushed after the user stopped).
316
653
  */
317
654
  export async function cancel(server, params) {
318
655
  const zcodeSid = server.resolveSid(params.sessionId);
319
656
  if (!zcodeSid)
320
657
  return;
658
+ // Cancel ALL matching turns for this session (not just the first). While a
659
+ // prior turn is still finalising, pendingTurns holds both it and any newer
660
+ // prompt waiting on the backend's prompt lock; breaking on the first match
661
+ // could leave the live one running. The stopSent guard dedupes the backend
662
+ // stop call across turns and repeated cancels.
321
663
  for (const [, turn] of server.pendingTurns) {
322
664
  if (turn.zcodeSid === zcodeSid) {
323
665
  turn.cancelled = true;
324
- break; // one turn per session at a time
666
+ if (!turn.stopSent) {
667
+ stopBackendTurn(server, zcodeSid);
668
+ turn.stopSent = true;
669
+ }
670
+ // Record cancel time so a prompt arriving in the backend's ~20s
671
+ // model-connection recovery window can fast-fail instead of hanging.
672
+ server.lastCancelledAt.set(zcodeSid, Date.now());
325
673
  }
326
674
  }
327
675
  log(`session/cancel → ${zcodeSid}`);
328
676
  }
677
+ /**
678
+ * Raised by `runEventTurn` when the backend emits `turn.failed`. Carries the
679
+ * structured error object (with its nested `cause`) so `prompt`'s retry loop
680
+ * can classify transient vs fatal via `isTransientTurnError`. The display
681
+ * message is derived from `formatTurnError` at construction time.
682
+ */
683
+ class TurnFailedError extends Error {
684
+ turnError;
685
+ constructor(turnError) {
686
+ super(formatTurnError(turnError) || "turn failed");
687
+ this.name = "TurnFailedError";
688
+ this.turnError = turnError;
689
+ }
690
+ }
329
691
  /**
330
692
  * Fire-and-forget `session/stop` to the backend. Mirrors Python's
331
693
  * `_cancel_backend_turn`: send stop with an id (some backends route by id
332
694
  * presence), never wait for a response, never throw.
333
695
  *
334
- * The backend's turn loop will emit turn.completed(cancelled) on its own;
335
- * the ACP turn loop observes that event and exits. No probing needed on the
336
- * prompt path an earlier ensureTurnStopped probed session/goal show for 30s
337
- * but returned inconsistent values and caused severe stalls.
696
+ * The turn-loop cancel site calls this once (guarded by turn.stopSent), then
697
+ * keeps looping until the backend emits turn.completed/turn.failed. The
698
+ * backend's prompt lock releases when ITS finalisation completes that,
699
+ * not any bridge-side signal, is what the next prompt's send-retry waits on.
338
700
  */
339
701
  function stopBackendTurn(server, zcodeSid) {
340
702
  try {
@@ -354,9 +716,9 @@ function stopBackendTurn(server, zcodeSid) {
354
716
  * entering its section sees this turn in its preempt scan. Without this lock,
355
717
  * two near-simultaneous prompts could both scan before either registers.
356
718
  *
357
- * The body may be async and long-running (preempt waits up to 35s for the old
358
- * turn to exit); that is acceptable because the turn loop itself runs OUTSIDE
359
- * this lock — only registration + preempt-in-wait are serialized.
719
+ * The body is async only to satisfy the lock chain (registration is
720
+ * synchronous; preempt no longer waits). The turn loop itself runs OUTSIDE
721
+ * this lock — only registration + preempt are serialized.
360
722
  */
361
723
  function withPreemptLock(server, zcodeSid, body) {
362
724
  const prev = server.preemptLocks.get(zcodeSid) ?? Promise.resolve();
@@ -379,75 +741,186 @@ function withPreemptLock(server, zcodeSid, body) {
379
741
  return next;
380
742
  }
381
743
  /**
382
- * Cancel any other in-flight turn for this zcodeSid and wait for it to fully
383
- * exit (listener unregistered + pendingTurns cleaned) before returning.
744
+ * Cancel any other in-flight turn for this zcodeSid: fire `session/stop` and
745
+ * signal the old turn to stop retrying, then return immediately.
384
746
  *
385
- * Must be called from inside a preempt lock section (the caller has already
386
- * registered itself in pendingTurns), so a concurrent prompt entering its own
387
- * section is guaranteed to see this caller's turn and cancel it.
747
+ * We do NOT wait for the old turn's runEventTurn to exit. Previously this spun
748
+ * on `pendingTurns` deletion (the old turn's finally), but that signal only
749
+ * proves "the old turn's loop returned" NOT "the backend is ready for a new
750
+ * turn". Waiting on it blocked the new prompt in a long loading state while
751
+ * the backend's stop-recovery window elapsed, and it still didn't prevent the
752
+ * next send from racing the backend. The backend's prompt lock is the only
753
+ * authoritative readiness signal: the new prompt's `session/send` retries
754
+ * until the lock releases, so there is nothing useful to wait for here.
388
755
  *
389
- * Why wait for the map entry to disappear (not just fire stop): registering
390
- * a second EventStreamListener overwrites the first (Map.set in client.ts),
391
- * so the old turn loop must have run its finally block before we subscribe.
392
- * The map cleanup in that finally block is the synchronization point.
756
+ * The old turn's runEventTurn ends on its own once it sees a terminal event
757
+ * from the backend (turn.completed/turn.failed after stop). Until then it
758
+ * keeps dispatching whatever the backend sends for this session which is
759
+ * correct, because within a single session the backend is the single source
760
+ * of truth and its events should reach the client.
393
761
  *
394
- * Best-effort: never throws. On timeout, continues anyway — session/send
395
- * will then hit the lock and take the existing error path.
762
+ * Exported for unit tests (multi-turn pendingTurns scenarios).
396
763
  */
397
- async function preemptInFlightTurn(server, zcodeSid, selfRequestId) {
398
- // Find any in-flight turn for this session that isn't this request.
399
- let oldRequestId;
764
+ export function preemptInFlightTurn(server, zcodeSid, selfRequestId) {
765
+ // Cancel ALL matching turns (mirrors cancel()): pendingTurns can hold more
766
+ // than one entry for this session — e.g. an already-cancelled turn still
767
+ // finalising plus the live one. Breaking on the first match could hit the
768
+ // stale entry and leave the live turn running, so the new prompt's send
769
+ // would retry against a busy backend for 30s and fail. The stopSent guard
770
+ // dedupes the backend stop call across turns.
771
+ let found = false;
400
772
  for (const [reqId, turn] of server.pendingTurns) {
401
- if (turn.zcodeSid === zcodeSid && reqId !== selfRequestId) {
402
- oldRequestId = reqId;
403
- turn.cancelled = true; // signal the old turn loop to exit
404
- break;
405
- }
406
- }
407
- if (oldRequestId === undefined)
408
- return; // no in-flight turn, proceed
409
- log(` [preempt] in-flight turn ${oldRequestId} found, stopping it`);
410
- // Fire-and-forget stop (mirrors Python's _cancel_backend_turn). The old
411
- // turn loop will receive turn.completed(cancelled) and exit on its own.
412
- stopBackendTurn(server, zcodeSid);
413
- // Wait for the old turn's prompt() to fully exit (its finally block deletes
414
- // the pendingTurns entry). This is the synchronization point that guarantees
415
- // both lock release (backend turn ended) and listener unregistration before
416
- // we subscribe/send. More reliable than probing session/goal show.
417
- const PREEMPT_TIMEOUT_MS = 35_000;
418
- const t0 = Date.now();
419
- while (server.pendingTurns.has(oldRequestId)) {
420
- if (Date.now() - t0 > PREEMPT_TIMEOUT_MS) {
421
- warn(` [preempt] timed out waiting for old turn ${oldRequestId} to exit`);
422
- return; // best-effort: continue anyway, session/send may fail
773
+ if (turn.zcodeSid !== zcodeSid || reqId === selfRequestId)
774
+ continue;
775
+ turn.cancelled = true; // signal the old turn to stop its retry loops
776
+ if (!turn.stopSent) {
777
+ stopBackendTurn(server, zcodeSid);
778
+ turn.stopSent = true;
423
779
  }
424
- await sleep(200);
780
+ // Record cancel time so the prompt()'s send-retry can use the recovery
781
+ // window as a hint (see session/send retry loop).
782
+ server.lastCancelledAt.set(zcodeSid, Date.now());
783
+ log(` [preempt] in-flight turn ${reqId} cancelled, proceeding without waiting`);
784
+ found = true;
425
785
  }
426
- log(` [preempt] old turn ${oldRequestId} exited, proceeding`);
786
+ return found;
427
787
  }
428
788
  // ---------- internals ----------
429
- /** Concatenate text from ACP ContentBlocks into a prompt string. */
430
- function extractPromptText(blocks) {
789
+ /** Concatenate text from ACP ContentBlock[] into a prompt string.
790
+ * Exported for unit testing (the resource_link path is easy to break). */
791
+ export function extractPromptText(blocks) {
431
792
  const parts = [];
432
793
  for (const block of blocks ?? []) {
794
+ // ACP ContentBlock is a discriminated union on `type`. The resource_link
795
+ // variant carries `name` + `uri` flat on the block itself (NOT nested under
796
+ // a `resource_link` key — see ACP schema $defs.ResourceLink). Accessing
797
+ // `block.resource_link` silently dropped every dragged-file attachment.
433
798
  const b = block;
434
799
  if (b.type === "text" && b.text) {
435
800
  parts.push(b.text);
436
801
  }
437
- else if (b.type === "resource_link" && b.resource_link) {
438
- parts.push(`[related resource: ${b.resource_link.name ?? b.resource_link.uri ?? ""}](${b.resource_link.uri ?? ""})`);
802
+ else if (b.type === "resource_link" && b.uri) {
803
+ // Convert file:// URIs to absolute paths so the model treats them as
804
+ // readable filesystem locations rather than opaque hyperlinks. Fall
805
+ // back to the path when name is missing OR empty — the ACP schema
806
+ // requires `name`, but a non-compliant client still deserves useful
807
+ // prompt text rather than `[related resource: ](/path)`.
808
+ const path = b.uri.startsWith("file://") ? fileUriToPath(b.uri) : b.uri;
809
+ const label = b.name || path;
810
+ parts.push(`[related resource: ${label}](${path})`);
811
+ }
812
+ else if (b.type === "resource" && b.resource) {
813
+ // Embedded resource. We don't advertise embeddedContext, but accept text
814
+ // payloads defensively in case a client sends them anyway. Binary
815
+ // payloads (BlobResourceContents) are never decoded — the base64 blob is
816
+ // useless to the model — so rewrite the resource uri into a readable
817
+ // filesystem location (same treatment as resource_link). Dropping it
818
+ // entirely left the prompt empty, which errored on a binary-only drag.
819
+ const r = b.resource;
820
+ if (r.text) {
821
+ parts.push(r.text);
822
+ }
823
+ else if (r.blob && r.uri) {
824
+ const path = r.uri.startsWith("file://") ? fileUriToPath(r.uri) : r.uri;
825
+ const label = basename(path) || path;
826
+ parts.push(`[related resource: ${label}](${path})`);
827
+ }
439
828
  }
440
829
  }
441
830
  return parts.join("\n").trim();
442
831
  }
443
- /** Fetch session/messages from zcode. */
444
- async function fetchMessages(server, zcodeSid) {
832
+ /** Extension inferred from mimeType for synthesizing a filename. */
833
+ const MIME_EXT = {
834
+ "image/png": "png",
835
+ "image/jpeg": "jpg",
836
+ "image/gif": "gif",
837
+ "image/webp": "webp",
838
+ "image/bmp": "bmp",
839
+ "image/svg+xml": "svg",
840
+ };
841
+ /**
842
+ * Extract image attachments from ACP ContentBlock[]. Non-image blocks are
843
+ * ignored (text/resource_link/resource stay owned by `extractPromptText`).
844
+ * Exported for unit testing.
845
+ */
846
+ export function extractAttachments(blocks) {
847
+ const out = [];
848
+ let imageIndex = 0;
849
+ for (const block of blocks ?? []) {
850
+ const b = block;
851
+ if (b.type !== "image")
852
+ continue;
853
+ imageIndex += 1;
854
+ const mimeType = b.mimeType ?? "image/png";
855
+ // Prefer a file:// uri → localPath so the backend streams from disk.
856
+ const uri = typeof b.uri === "string" ? b.uri : "";
857
+ if (uri.startsWith("file://")) {
858
+ const localPath = fileUriToPath(uri);
859
+ out.push({
860
+ kind: "image",
861
+ filename: basename(localPath) ?? `image-${imageIndex}.${MIME_EXT[mimeType] ?? "png"}`,
862
+ mimeType,
863
+ localPath,
864
+ });
865
+ continue;
866
+ }
867
+ // Otherwise fall back to the base64 payload.
868
+ if (b.data) {
869
+ out.push({
870
+ kind: "image",
871
+ filename: uri
872
+ ? (basename(uri) ?? `image-${imageIndex}.${MIME_EXT[mimeType] ?? "png"}`)
873
+ : `image-${imageIndex}.${MIME_EXT[mimeType] ?? "png"}`,
874
+ mimeType,
875
+ dataBase64: b.data,
876
+ sizeBytes: Math.floor((b.data.length * 3) / 4),
877
+ });
878
+ }
879
+ // An image block with neither a usable uri nor data is dropped defensively.
880
+ }
881
+ return out;
882
+ }
883
+ /** Best-effort basename from a path/uri (no node:path import for a tiny helper). */
884
+ function basename(p) {
885
+ const clean = p.replace(/\/+$/, "");
886
+ const slash = clean.lastIndexOf("/");
887
+ const name = slash >= 0 ? clean.slice(slash + 1) : clean;
888
+ return name || null;
889
+ }
890
+ /** Convert a file:// URI to an absolute filesystem path. */
891
+ function fileUriToPath(uri) {
892
+ try {
893
+ return decodeURIComponent(new URL(uri).pathname);
894
+ }
895
+ catch {
896
+ // Not a valid URL — return as-is (best-effort).
897
+ return uri;
898
+ }
899
+ }
900
+ /**
901
+ * Resume a zcode session with retry on transient timeouts.
902
+ *
903
+ * The backend drops RPCs issued during its cold-start window (between process
904
+ * spawn and `startup.completed`). The first resume after a fresh backend spawn
905
+ * can land in that gap and time out without the backend ever seeing it. A single
906
+ * retry — issued after the startup window has elapsed — succeeds. Non-timeout
907
+ * errors (Invalid params, session not found) fail fast.
908
+ */
909
+ async function resumeBackendSession(server, zcParams) {
445
910
  const backend = server.ensureBackend();
446
- const resp = await backend.request(server.nextId(), "session/messages", { sessionId: zcodeSid }, 8000);
447
- if (resp.error)
448
- return [];
449
- const result = (resp.result ?? {});
450
- return result.messages ?? [];
911
+ const MAX_ATTEMPTS = 2;
912
+ const ATTEMPT_TIMEOUT_MS = 15_000;
913
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
914
+ const resp = await backend.request(server.nextId(), "session/resume", zcParams, ATTEMPT_TIMEOUT_MS);
915
+ if (!resp.error)
916
+ return;
917
+ const isTimeout = resp.error.message === "timeout";
918
+ if (!isTimeout || attempt === MAX_ATTEMPTS) {
919
+ throw new Error(`zcode resume failed: ${resp.error.message ?? ""}`);
920
+ }
921
+ log(`session/resume attempt ${attempt}/${MAX_ATTEMPTS} timed out, retrying (backend cold-start window)`);
922
+ await sleep(1000);
923
+ }
451
924
  }
452
925
  /** Get or create the session-level ProjectionDiffer (persists across turns). */
453
926
  function getOrCreateDiffer(server, zcodeSid) {
@@ -469,7 +942,7 @@ function getOrCreateDiffer(server, zcodeSid) {
469
942
  * handling (requestPermission / ExitPlanMode / AskUserQuestion) lands in
470
943
  * Commit 6 — for now they're polled to keep the inbox clear.
471
944
  */
472
- async function runEventTurn(server, listener, monitor, differ, cx, acpSid, chunkMsgId, turn) {
945
+ async function runEventTurn(server, listener, monitor, differ, cx, acpSid, chunkMsgId, turn, preempted) {
473
946
  const backend = server.ensureBackend();
474
947
  const translator = new EventTranslator();
475
948
  differ.resetTurn();
@@ -478,6 +951,16 @@ async function runEventTurn(server, listener, monitor, differ, cx, acpSid, chunk
478
951
  let lastStallCheck = Date.now();
479
952
  let emittedText = false;
480
953
  let emittedOutput = false;
954
+ // Thinking-phase feedback: GLM models spend seconds in CoT before emitting
955
+ // any model.streaming event, during which the backend is silent and the
956
+ // editor shows nothing — users perceive this as "frozen". To bridge that
957
+ // gap we emit ONE agent_thought_chunk hint shortly after the turn starts,
958
+ // but only if no real output (text / reasoning / tool) has arrived yet.
959
+ // It uses a dedicated messageId so it never collides with the real reasoning
960
+ // stream (thought_<chunkMsgId>) and is naturally superseded once content flows.
961
+ let turnStartedAt = null;
962
+ let thinkingHintSent = false;
963
+ const THINKING_HINT_DELAY_MS = 1200;
481
964
  while (Date.now() - lastProgress < NO_PROGRESS_MS) {
482
965
  // Drain + handle server→client requests (interaction/*). Refreshes the
483
966
  // no-progress timer when any are handled. Pass `turn` so interaction
@@ -486,33 +969,97 @@ async function runEventTurn(server, listener, monitor, differ, cx, acpSid, chunk
486
969
  lastProgress = Date.now();
487
970
  }
488
971
  if (turn.cancelled) {
489
- stopBackendTurn(server, turn.zcodeSid);
490
- return { stopReason: "cancelled" };
972
+ // Cancel requested: ensure stop was fired (cancel()/preempt normally do
973
+ // this, but guard anyway). We do NOT silence subsequent events here — if
974
+ // the backend ignored the stop and kept producing, that content is still
975
+ // valuable to the user and should be displayed (the backend is the single
976
+ // source of truth within a session). Cross-turn contamination is handled
977
+ // separately by the turn-attribution gate below, which discards this
978
+ // turn's leftover events from the *next* turn's queue. The loop exits
979
+ // normally on the terminal event (translator.turnDone below).
980
+ if (!turn.stopSent) {
981
+ stopBackendTurn(server, turn.zcodeSid);
982
+ turn.stopSent = true;
983
+ }
491
984
  }
492
985
  const ev = await listener.pollEvent(500);
493
986
  if (ev === null) {
987
+ // Thinking-phase hint: if the turn has started but produced no output
988
+ // yet (no text/reasoning/tool streamed), and we've been silent longer
989
+ // than the threshold, emit a single "thinking" thought chunk so the
990
+ // editor shows activity instead of a frozen screen. Skipped once any
991
+ // real output has been dispatched, and never sent after cancellation.
992
+ if (!turn.cancelled &&
993
+ !thinkingHintSent &&
994
+ turnStartedAt !== null &&
995
+ !emittedText &&
996
+ !emittedOutput &&
997
+ Date.now() - turnStartedAt > THINKING_HINT_DELAY_MS) {
998
+ thinkingHintSent = true;
999
+ await sendSessionUpdate(cx, acpSid, {
1000
+ sessionUpdate: "agent_thought_chunk",
1001
+ content: { type: "text", text: "正在思考…" },
1002
+ messageId: `thinking_${chunkMsgId}`,
1003
+ });
1004
+ }
494
1005
  // Stall reconciliation: probe authoritative status after 15s of silence.
495
- if (translator.turnStarted &&
1006
+ // Skipped while cancelled: we've already fired stop, so the backend will
1007
+ // emit its own completion event, and this branch would otherwise push
1008
+ // stale output or return a wrong stopReason (end_turn / throw) after the
1009
+ // user stopped.
1010
+ if (!turn.cancelled &&
1011
+ translator.turnStarted &&
496
1012
  Date.now() - lastProgress > 15_000 &&
497
1013
  Date.now() - lastStallCheck > 15_000) {
498
1014
  lastStallCheck = Date.now();
499
1015
  const proj = await monitor.pollOnce();
500
1016
  if (proj?.status === "idle") {
501
- // Turn completed but the event was lost.
502
- if (!emittedText) {
503
- const reply = await fetchLastReply(server, turn.zcodeSid, differ);
504
- if (reply) {
505
- await sendTextChunk(cx, acpSid, reply, chunkMsgId);
506
- }
507
- else if (!emittedOutput) {
508
- // No text and no output → suspected failure.
509
- stopBackendTurn(server, turn.zcodeSid);
510
- throw new RequestError(-32603, "turn produced no output");
1017
+ // A single idle probe can also fire mid-work: the backend is silent
1018
+ // during the model's thinking/connection phase and may report idle
1019
+ // while the turn is still alive. Confirm before trusting it — wait
1020
+ // briefly, then probe once more. Only a second idle WITH no queued
1021
+ // events ends the turn: an event arriving in the window proves the
1022
+ // turn is alive (it stays queued for the next poll).
1023
+ await sleep(1500);
1024
+ if (listener.hasQueuedEvents()) {
1025
+ lastProgress = Date.now();
1026
+ continue; // alive events will be consumed by the next poll
1027
+ }
1028
+ const proj2 = await monitor.pollOnce();
1029
+ if (proj2?.status === "idle" && !listener.hasQueuedEvents()) {
1030
+ // Turn completed but the event was lost (double-confirmed).
1031
+ if (!emittedText) {
1032
+ const reply = await fetchLastReply(server, turn.zcodeSid, differ);
1033
+ if (reply) {
1034
+ registerFetchedReply(translator, reply);
1035
+ await sendTextChunk(cx, acpSid, reply.text, chunkMsgId);
1036
+ }
1037
+ else if (!emittedOutput) {
1038
+ // No text and no output → suspected failure.
1039
+ stopBackendTurn(server, turn.zcodeSid);
1040
+ throw new RequestError(-32603, "turn produced no output");
1041
+ }
511
1042
  }
1043
+ // Heuristic ending: prompt() must skip auto-compact for this
1044
+ // turn — the completion was inferred, and compressing an
1045
+ // in-flight task's context would destroy the work.
1046
+ turn.stallRecovered = true;
1047
+ return { stopReason: "end_turn" };
1048
+ }
1049
+ // Second probe says the backend is still working (or events arrived
1050
+ // mid-probe) — keep waiting; queued events are consumed by the next
1051
+ // poll iteration.
1052
+ lastProgress = Date.now();
1053
+ if (proj2?.status === "running") {
1054
+ await listener.resubscribe(() => server.nextId());
512
1055
  }
513
- return { stopReason: "end_turn" };
1056
+ continue;
514
1057
  }
515
1058
  if (proj?.status === "running") {
1059
+ // Backend still working (or recovering from a stop) — keep waiting.
1060
+ // The send-retry loop in prompt() already covers the recovery window
1061
+ // for the NEXT turn; for this in-flight turn we just resubscribe and
1062
+ // let the backend emit its terminal event when ready.
516
1063
  lastProgress = Date.now();
517
1064
  await listener.resubscribe(() => server.nextId());
518
1065
  }
@@ -520,9 +1067,35 @@ async function runEventTurn(server, listener, monitor, differ, cx, acpSid, chunk
520
1067
  continue;
521
1068
  }
522
1069
  lastProgress = Date.now();
1070
+ // Turn-attribution gate: before this turn's own turn.started arrives, any
1071
+ // event is leftover from a prior turn (cancelled/preempted but still
1072
+ // finalising) that landed in the queue while send was retrying on a busy
1073
+ // backend. Discard it — including a prior turn's turn.completed, which
1074
+ // would otherwise make this turn exit (cancelled) before it even begins.
1075
+ //
1076
+ // The gate must run BEFORE translate(): translator flags (turnDone /
1077
+ // turnFailed / turnResultType) are sticky, so translating a prior turn's
1078
+ // terminal event here would flip them and make THIS turn exit prematurely
1079
+ // at the first check after its own turn.started passes the gate.
1080
+ //
1081
+ // The gate is armed ONLY when this send preempted another prompt. Without
1082
+ // preemption no prior-turn residue can exist: the queue can only contain
1083
+ // events of a backend-owned turn that was already active at send time
1084
+ // (e.g. the main-branch turn auto-resumed after a compaction) — this send
1085
+ // was steered into it and produces NO new turn.started, so dropping those
1086
+ // events would silently swallow the entire turn's output in the UI.
1087
+ if (shouldDropEventForTurnAttribution(ev, translator.turnStarted, preempted)) {
1088
+ continue;
1089
+ }
523
1090
  const internalEvents = translator.translate(ev);
1091
+ // Capture the turn-start timestamp for the thinking-phase hint above.
1092
+ // Done after translate so the flag flip on the turn.started event is
1093
+ // observed on the same iteration that processes it.
1094
+ if (turnStartedAt === null && translator.turnStarted) {
1095
+ turnStartedAt = Date.now();
1096
+ }
524
1097
  for (const iev of internalEvents) {
525
- if (iev.kind === "TextDelta")
1098
+ if (iev.kind === "TextDelta" || iev.kind === "ReasoningDelta")
526
1099
  emittedText = true;
527
1100
  if (iev.kind === "ToolCallNew" || iev.kind === "ToolCallUpdate")
528
1101
  emittedOutput = true;
@@ -533,18 +1106,34 @@ async function runEventTurn(server, listener, monitor, differ, cx, acpSid, chunk
533
1106
  differ.setLastUsage(iev.used);
534
1107
  await dispatchEvent(server, cx, acpSid, iev, chunkMsgId);
535
1108
  }
536
- // Edit/Write diff eager dispatch: on tool.updated result for Edit/Write,
537
- // grab the structured patch from session/messages immediately (don't wait
538
- // for turn completion — model rate-limiting could delay it indefinitely).
1109
+ // Edit/Write diff eager dispatch: on tool.updated result, grab the
1110
+ // structured patch from session/messages immediately (don't wait for turn
1111
+ // completion — model rate-limiting could delay it indefinitely).
1112
+ //
1113
+ // Newer ZCode backends omit toolName on "result" events (only "scheduled"
1114
+ // and "started" carry it), so we no longer filter by tool name here —
1115
+ // dispatchEditDiff itself checks the tool part's display and skips
1116
+ // non-file-diff tools harmlessly.
539
1117
  if (ev.type === "tool.updated") {
540
1118
  const payload = ev.payload;
541
- if (payload.kind === "result" &&
542
- payload.toolCallId &&
543
- (payload.toolName === "Edit" ||
544
- payload.toolName === "Write" ||
545
- payload.toolName === "edit" ||
546
- payload.toolName === "write")) {
547
- await dispatchEditDiff(server, cx, acpSid, turn.zcodeSid, payload.toolCallId, differ, chunkMsgId);
1119
+ if (payload.kind === "result" && payload.toolCallId) {
1120
+ // Fire edit-diff and plan-sync in parallel — they hit independent
1121
+ // backend methods (session/messages vs session/read) so there's no
1122
+ // ordering dependency between them.
1123
+ const sideTasks = [
1124
+ dispatchEditDiff(server, cx, acpSid, turn.zcodeSid, payload.toolCallId, differ, chunkMsgId),
1125
+ // Push plan (TODO list) updates immediately on tool completion so the
1126
+ // editor doesn't lag behind — without this, TODO changes only surface at
1127
+ // turn completion, which can be delayed by the model's remaining output.
1128
+ dispatchPlanIfChanged(server, cx, acpSid, turn.zcodeSid, differ, chunkMsgId),
1129
+ ];
1130
+ // EnterPlanMode switches the session mode mid-turn without a
1131
+ // session/setMode notification; reconcile immediately so the editor's
1132
+ // mode indicator flips without waiting for turn completion.
1133
+ if (translator.toolNames.get(payload.toolCallId) === "EnterPlanMode") {
1134
+ sideTasks.push(emitModeIfChanged(server, cx, acpSid, turn.zcodeSid));
1135
+ }
1136
+ await Promise.all(sideTasks);
548
1137
  }
549
1138
  }
550
1139
  // Sync translator → differ seen-tool-ids so the turn-completion differ.diff
@@ -555,38 +1144,53 @@ async function runEventTurn(server, listener, monitor, differ, cx, acpSid, chunk
555
1144
  differ.markToolSeen(seenId);
556
1145
  }
557
1146
  if (translator.turnDone) {
558
- // Cancel signalled via turn.completed(resultType:"cancelled"). The
559
- // backend turn has already ended and released the lock — no stop needed.
560
- if (translator.turnResultType === "cancelled") {
1147
+ // User requested cancel (via cancel()/preempt). Whatever the backend's
1148
+ // terminal resultType (cancelled / success / failed), honour the user's
1149
+ // intent and report cancelled.
1150
+ if (turn.cancelled || translator.turnResultType === "cancelled") {
561
1151
  return { stopReason: "cancelled" };
562
1152
  }
563
1153
  if (translator.turnFailed) {
564
1154
  // Best-effort stop in case the failed turn left a residual lock.
565
1155
  stopBackendTurn(server, turn.zcodeSid);
566
- throw new RequestError(-32603, formatTurnError(translator.turnError));
1156
+ // Throw a TurnFailedError carrying the structured error so the caller
1157
+ // (prompt's retry loop) can classify transient vs fatal. The error
1158
+ // message is formatted for display when it ultimately reaches the user.
1159
+ throw new TurnFailedError(translator.turnError ?? {});
567
1160
  }
568
1161
  // Fallback: if no text streamed, surface the last assistant reply.
569
1162
  if (!emittedText) {
570
1163
  const reply = await fetchLastReply(server, turn.zcodeSid, differ);
571
- if (reply)
572
- await sendTextChunk(cx, acpSid, reply, chunkMsgId);
1164
+ if (reply) {
1165
+ registerFetchedReply(translator, reply);
1166
+ await sendTextChunk(cx, acpSid, reply.text, chunkMsgId);
1167
+ }
573
1168
  }
574
1169
  // Turn-completion diff: emits PlanUpdate (todos) + final usage_update,
575
- // reconciles any snapshot-only tool events.
1170
+ // reconciles any snapshot-only tool events, and replays assistant text
1171
+ // that never reached the live event stream.
1172
+ //
1173
+ // TextDelta/ReasoningDelta are filtered only when the same message was
1174
+ // ALREADY streamed live (dedup by backend message id — `translator`
1175
+ // records `assistantMessageId` per streamed delta, the differ tags its
1176
+ // replay with the same id). The differ's seenMessageIds dedup cannot
1177
+ // bridge the two paths because the streaming path uses a client-generated
1178
+ // chunkMsgId while the differ keys on the backend's message info.id.
576
1179
  //
577
- // TextDelta and ReasoningDelta are deliberately filtered out here: the
578
- // event path already streamed the assistant reply and reasoning via
579
- // model.streaming (chunkMsgId). The differ's seenMessageIds dedup cannot
580
- // bridge the two paths because they use different id spaces — the
581
- // streaming path uses a client-generated chunkMsgId while the differ
582
- // keys on the backend's message info.id. Without this filter the whole
583
- // reply and reasoning are dispatched a second time. `fetchLastReply`
584
- // above already covers the case where the event path delivered no text.
1180
+ // Without this per-message dedup the whole reply would be dispatched a
1181
+ // second time; without the replay, a backend turn resumed while no
1182
+ // listener was attached (e.g. the main-branch turn auto-resumed after
1183
+ // compaction, before the user's next send) would leave its entire output
1184
+ // invisible in the UI. `fetchLastReply` above only covers the last
1185
+ // assistant message, not the whole missing span.
585
1186
  const snapshot = await buildSnapshot(server, turn.zcodeSid);
586
1187
  const completionEvents = differ.diff(snapshot);
587
1188
  for (const iev of completionEvents) {
588
- if (iev.kind === "TextDelta" || iev.kind === "ReasoningDelta")
1189
+ if ((iev.kind === "TextDelta" || iev.kind === "ReasoningDelta") &&
1190
+ iev.messageId &&
1191
+ translator.deliveredMessageIds.has(iev.messageId)) {
589
1192
  continue;
1193
+ }
590
1194
  await dispatchEvent(server, cx, acpSid, iev, chunkMsgId);
591
1195
  }
592
1196
  // Mode reconciliation: an in-turn tool (EnterPlanMode/ExitPlanMode) can
@@ -602,6 +1206,32 @@ async function runEventTurn(server, listener, monitor, differ, cx, acpSid, chunk
602
1206
  stopBackendTurn(server, turn.zcodeSid);
603
1207
  return { stopReason: "max_turn_requests" };
604
1208
  }
1209
+ /**
1210
+ * Turn-attribution gate decision (pure, exported for tests): whether an event
1211
+ * observed before this turn's own `turn.started` should be dropped as leftover
1212
+ * residue of a prior turn.
1213
+ *
1214
+ * Residue only exists when this send preempted/cancelled another prompt (its
1215
+ * finalising events land in the new listener's queue). Without preemption the
1216
+ * queue can only carry events of a backend-owned turn already active at send
1217
+ * time — e.g. the main-branch turn auto-resumed after a compaction — which
1218
+ * this send was steered into and which emits no new `turn.started`; dropping
1219
+ * those events would silently swallow the whole turn's output in the UI.
1220
+ */
1221
+ export function shouldDropEventForTurnAttribution(ev, turnStarted, preempted) {
1222
+ return !turnStarted && preempted && ev.type !== "turn.started";
1223
+ }
1224
+ /**
1225
+ * Register a fetchLastReply-delivered message as text-delivered so the
1226
+ * turn-completion diff replay doesn't dispatch the same text a second time
1227
+ * (the differ never saw this message — its live events were lost — so its
1228
+ * diff would re-emit the TextDelta). Reasoning is NOT registered: it was
1229
+ * never streamed either, so the replay dispatching it is pure gain.
1230
+ */
1231
+ function registerFetchedReply(translator, reply) {
1232
+ if (reply.messageId)
1233
+ translator.deliveredMessageIds.add(reply.messageId);
1234
+ }
605
1235
  /**
606
1236
  * Fetch the last assistant message text as a fallback for lost text events.
607
1237
  *
@@ -627,7 +1257,7 @@ async function fetchLastReply(server, zcodeSid, differ) {
627
1257
  if (p && typeof p === "object" && p.type === "text") {
628
1258
  const text = p.text ?? "";
629
1259
  if (text.trim())
630
- return text;
1260
+ return { text, messageId: m.info?.id ?? null };
631
1261
  }
632
1262
  }
633
1263
  }
@@ -673,7 +1303,7 @@ async function buildSnapshot(server, zcodeSid) {
673
1303
  * emit no notification of their own. Best-effort: failures are logged and
674
1304
  * swallowed so they never break the turn-completion path.
675
1305
  */
676
- async function emitModeIfChanged(server, cx, acpSid, zcodeSid) {
1306
+ export async function emitModeIfChanged(server, cx, acpSid, zcodeSid) {
677
1307
  try {
678
1308
  const modes = await buildModes(server, zcodeSid);
679
1309
  const last = server.lastMode.get(acpSid);
@@ -732,6 +1362,31 @@ async function dispatchEditDiff(server, cx, acpSid, zcodeSid, callId, differ, ch
732
1362
  }
733
1363
  differ.markToolSeen(callId);
734
1364
  }
1365
+ /**
1366
+ * Read the authoritative todos from `session/read` and push a PlanUpdate if the
1367
+ * signature changed since the last check. Called mid-turn (right after each
1368
+ * tool completes) so the editor sees TODO updates immediately instead of
1369
+ * waiting for turn completion — the turn-completion diff would otherwise lag
1370
+ * behind by the rest of the model's output.
1371
+ *
1372
+ * Uses a lightweight `session/read` (no session/messages fetch). Failures are
1373
+ * logged and swallowed: plan staleness is cosmetic, not worth crashing the turn.
1374
+ */
1375
+ async function dispatchPlanIfChanged(server, cx, acpSid, zcodeSid, differ, chunkMsgId) {
1376
+ try {
1377
+ const backend = server.ensureBackend();
1378
+ const readResp = await backend.request(server.nextId(), "session/read", { sessionId: zcodeSid }, 8000);
1379
+ const read = (readResp.result ?? {});
1380
+ const todos = flattenTodos(read.todos, read.todoGroups);
1381
+ const events = differ.diffPlan(todos);
1382
+ for (const iev of events) {
1383
+ await dispatchEvent(server, cx, acpSid, iev, chunkMsgId);
1384
+ }
1385
+ }
1386
+ catch (e) {
1387
+ log(`dispatchPlanIfChanged: skipped (${e instanceof Error ? e.message : String(e)})`);
1388
+ }
1389
+ }
735
1390
  function sleep(ms) {
736
1391
  return new Promise((r) => setTimeout(r, ms));
737
1392
  }