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