zcode-acp-server 0.1.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 (108) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +167 -0
  3. package/README.zh-CN.md +163 -0
  4. package/dist/backend/client.d.ts +103 -0
  5. package/dist/backend/client.d.ts.map +1 -0
  6. package/dist/backend/client.js +344 -0
  7. package/dist/backend/client.js.map +1 -0
  8. package/dist/backend/credentials.d.ts +31 -0
  9. package/dist/backend/credentials.d.ts.map +1 -0
  10. package/dist/backend/credentials.js +93 -0
  11. package/dist/backend/credentials.js.map +1 -0
  12. package/dist/backend/index.d.ts +7 -0
  13. package/dist/backend/index.d.ts.map +1 -0
  14. package/dist/backend/index.js +6 -0
  15. package/dist/backend/index.js.map +1 -0
  16. package/dist/backend/listener.d.ts +63 -0
  17. package/dist/backend/listener.d.ts.map +1 -0
  18. package/dist/backend/listener.js +138 -0
  19. package/dist/backend/listener.js.map +1 -0
  20. package/dist/backend/resolve.d.ts +11 -0
  21. package/dist/backend/resolve.d.ts.map +1 -0
  22. package/dist/backend/resolve.js +116 -0
  23. package/dist/backend/resolve.js.map +1 -0
  24. package/dist/backend/types.d.ts +164 -0
  25. package/dist/backend/types.d.ts.map +1 -0
  26. package/dist/backend/types.js +15 -0
  27. package/dist/backend/types.js.map +1 -0
  28. package/dist/config/model-cache.d.ts +20 -0
  29. package/dist/config/model-cache.d.ts.map +1 -0
  30. package/dist/config/model-cache.js +62 -0
  31. package/dist/config/model-cache.js.map +1 -0
  32. package/dist/config/options.d.ts +36 -0
  33. package/dist/config/options.d.ts.map +1 -0
  34. package/dist/config/options.js +171 -0
  35. package/dist/config/options.js.map +1 -0
  36. package/dist/config/runtime-model.d.ts +33 -0
  37. package/dist/config/runtime-model.d.ts.map +1 -0
  38. package/dist/config/runtime-model.js +96 -0
  39. package/dist/config/runtime-model.js.map +1 -0
  40. package/dist/handlers/dispatch.d.ts +15 -0
  41. package/dist/handlers/dispatch.d.ts.map +1 -0
  42. package/dist/handlers/dispatch.js +183 -0
  43. package/dist/handlers/dispatch.js.map +1 -0
  44. package/dist/handlers/extensions.d.ts +43 -0
  45. package/dist/handlers/extensions.d.ts.map +1 -0
  46. package/dist/handlers/extensions.js +310 -0
  47. package/dist/handlers/extensions.js.map +1 -0
  48. package/dist/handlers/io.d.ts +40 -0
  49. package/dist/handlers/io.d.ts.map +1 -0
  50. package/dist/handlers/io.js +55 -0
  51. package/dist/handlers/io.js.map +1 -0
  52. package/dist/handlers/server-requests.d.ts +34 -0
  53. package/dist/handlers/server-requests.d.ts.map +1 -0
  54. package/dist/handlers/server-requests.js +357 -0
  55. package/dist/handlers/server-requests.js.map +1 -0
  56. package/dist/handlers/session.d.ts +46 -0
  57. package/dist/handlers/session.d.ts.map +1 -0
  58. package/dist/handlers/session.js +738 -0
  59. package/dist/handlers/session.js.map +1 -0
  60. package/dist/handlers/slash.d.ts +16 -0
  61. package/dist/handlers/slash.d.ts.map +1 -0
  62. package/dist/handlers/slash.js +107 -0
  63. package/dist/handlers/slash.js.map +1 -0
  64. package/dist/index.d.ts +11 -0
  65. package/dist/index.d.ts.map +1 -0
  66. package/dist/index.js +93 -0
  67. package/dist/index.js.map +1 -0
  68. package/dist/interaction/adapter.d.ts +136 -0
  69. package/dist/interaction/adapter.d.ts.map +1 -0
  70. package/dist/interaction/adapter.js +353 -0
  71. package/dist/interaction/adapter.js.map +1 -0
  72. package/dist/server.d.ts +73 -0
  73. package/dist/server.d.ts.map +1 -0
  74. package/dist/server.js +97 -0
  75. package/dist/server.js.map +1 -0
  76. package/dist/tasks-index.d.ts +39 -0
  77. package/dist/tasks-index.d.ts.map +1 -0
  78. package/dist/tasks-index.js +152 -0
  79. package/dist/tasks-index.js.map +1 -0
  80. package/dist/translators/event-translator.d.ts +40 -0
  81. package/dist/translators/event-translator.d.ts.map +1 -0
  82. package/dist/translators/event-translator.js +214 -0
  83. package/dist/translators/event-translator.js.map +1 -0
  84. package/dist/translators/index.d.ts +6 -0
  85. package/dist/translators/index.d.ts.map +1 -0
  86. package/dist/translators/index.js +5 -0
  87. package/dist/translators/index.js.map +1 -0
  88. package/dist/translators/projection-differ.d.ts +48 -0
  89. package/dist/translators/projection-differ.d.ts.map +1 -0
  90. package/dist/translators/projection-differ.js +239 -0
  91. package/dist/translators/projection-differ.js.map +1 -0
  92. package/dist/translators/tool-helpers.d.ts +60 -0
  93. package/dist/translators/tool-helpers.d.ts.map +1 -0
  94. package/dist/translators/tool-helpers.js +308 -0
  95. package/dist/translators/tool-helpers.js.map +1 -0
  96. package/dist/translators/types.d.ts +58 -0
  97. package/dist/translators/types.d.ts.map +1 -0
  98. package/dist/translators/types.js +27 -0
  99. package/dist/translators/types.js.map +1 -0
  100. package/dist/utils.d.ts +111 -0
  101. package/dist/utils.d.ts.map +1 -0
  102. package/dist/utils.js +110 -0
  103. package/dist/utils.js.map +1 -0
  104. package/docs/ARCHITECTURE.md +299 -0
  105. package/docs/DEVELOPMENT.md +193 -0
  106. package/docs/PROTOCOL.md +649 -0
  107. package/docs/TROUBLESHOOTING.md +251 -0
  108. package/package.json +66 -0
@@ -0,0 +1,738 @@
1
+ /**
2
+ * Session lifecycle handlers: initialize, new, list, resume, load, prompt, cancel.
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.
8
+ */
9
+ import process from "node:process";
10
+ import { randomUUID } from "node:crypto";
11
+ import { RequestError } from "@agentclientprotocol/sdk";
12
+ import { EventStreamListener, TurnMonitor } from "../backend/listener.js";
13
+ import { buildModes, buildConfigOptions } from "../config/options.js";
14
+ import { emitInitialUsage } from "../config/model-cache.js";
15
+ import { buildResumeRuntimeModel } from "../config/runtime-model.js";
16
+ import { buildDiffContent, EventTranslator, extractLocations, formatTurnError, ProjectionDiffer, } from "../translators/index.js";
17
+ import { log, warn } from "../utils.js";
18
+ import { dispatchEvent } from "./dispatch.js";
19
+ import { sendSessionUpdate, sendTextChunk } from "./io.js";
20
+ import { handleServerRequests } from "./server-requests.js";
21
+ /** Workspace descriptor used in session create/resume calls. */
22
+ function workspaceFor(cwd) {
23
+ const p = cwd || process.cwd();
24
+ return { workspacePath: p, workspaceKey: p };
25
+ }
26
+ /** Convert a millisecond timestamp to ISO 8601 (for session list). */
27
+ function toIso(ms) {
28
+ if (typeof ms !== "number")
29
+ return undefined;
30
+ return new Date(ms).toISOString();
31
+ }
32
+ /** `session/new` → zcode `session/create` (mode hardcoded yolo). */
33
+ export async function newSession(server, params) {
34
+ const backend = server.ensureBackend();
35
+ 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);
59
+ return {
60
+ sessionId: sid,
61
+ modes,
62
+ configOptions: await buildConfigOptions(server, sid),
63
+ };
64
+ }
65
+ /** `session/list` → zcode `session/list`. */
66
+ export async function listSessions(server, params) {
67
+ const backend = server.ensureBackend();
68
+ const zcParams = {};
69
+ if (params.cwd) {
70
+ zcParams.workspace = workspaceFor(params.cwd);
71
+ }
72
+ const resp = await backend.request(server.nextId(), "session/list", zcParams, 15000);
73
+ if (resp.error)
74
+ throw new Error(`zcode list failed: ${resp.error.message ?? ""}`);
75
+ const result = (resp.result ?? {});
76
+ const sessions = (result.sessions ?? []).map((s) => ({
77
+ sessionId: s.sessionId ?? "",
78
+ cwd: s.workspace?.workspacePath ?? "",
79
+ title: s.title,
80
+ updatedAt: toIso(s.updatedAt),
81
+ }));
82
+ log(`session/list → ${sessions.length} sessions`);
83
+ return { sessions };
84
+ }
85
+ /** `session/resume` → zcode `session/resume` (with runtimeModel overlay for resumed sessions). */
86
+ export async function resumeSession(server, params, cx) {
87
+ const backend = server.ensureBackend();
88
+ const targetSid = params.sessionId;
89
+ const cwd = params.cwd ?? process.cwd();
90
+ if (!targetSid)
91
+ 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}`);
107
+ // Initial usage_update so the editor shows the context bar immediately for a
108
+ // 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);
112
+ return {
113
+ modes,
114
+ configOptions: await buildConfigOptions(server, targetSid),
115
+ };
116
+ }
117
+ /**
118
+ * `session/load` → zcode `session/resume` + stream conversation history back as
119
+ * `session/update` notifications (text/reasoning/简化 tool_call).
120
+ */
121
+ export async function loadSession(server, params, cx) {
122
+ const backend = server.ensureBackend();
123
+ const targetSid = params.sessionId;
124
+ const cwd = params.cwd ?? process.cwd();
125
+ if (!targetSid)
126
+ 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;
188
+ }
189
+ log(`session/load: replayed ${replayed} messages`);
190
+ // Replay the existing todo list as an initial plan so a loaded session shows
191
+ // its todos immediately (filter to PlanUpdate only — text/tools were already
192
+ // replayed above and the differ hasn't mark_seen'd this history).
193
+ try {
194
+ const snapshot = await buildSnapshot(server, targetSid);
195
+ const loadDiffer = getOrCreateDiffer(server, targetSid);
196
+ const planEvents = loadDiffer.diff(snapshot).filter((e) => e.kind === "PlanUpdate");
197
+ for (const iev of planEvents) {
198
+ await dispatchEvent(server, cx, targetSid, iev, `load_${randomUUID().slice(0, 8)}`);
199
+ }
200
+ }
201
+ catch (e) {
202
+ log(`session/load: initial plan read failed (non-fatal): ${e instanceof Error ? e.message : String(e)}`);
203
+ }
204
+ // 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 {
209
+ modes,
210
+ configOptions: await buildConfigOptions(server, targetSid),
211
+ };
212
+ }
213
+ /** `session/prompt` → subscribe-before-send, run the event-driven turn loop. */
214
+ export async function prompt(server, params, cx, requestId) {
215
+ 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[].
220
+ const text = extractPromptText(params.prompt);
221
+ if (!text)
222
+ throw new Error("empty prompt");
223
+ // 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");
226
+ const intercepted = await handleSlashCommand(server, cx, params.sessionId, zcodeSid, text);
227
+ if (intercepted)
228
+ return intercepted;
229
+ // Register self + preempt others under a per-session lock. The lock
230
+ // serializes the critical section so that two concurrent prompts (B, C) for
231
+ // the same session can't both miss each other and register at once: C waits
232
+ // for B's section, by which point B is in pendingTurns, so C's preempt finds
233
+ // and cancels B. Registering INSIDE the lock is what makes the new turn
234
+ // visible to the next prompt's preempt scan.
235
+ const turn = {
236
+ zcodeSid,
237
+ cancelled: false,
238
+ };
239
+ await withPreemptLock(server, zcodeSid, () => {
240
+ server.pendingTurns.set(requestId, turn);
241
+ return preemptInFlightTurn(server, zcodeSid, requestId);
242
+ });
243
+ const listener = new EventStreamListener(backend, zcodeSid);
244
+ const monitor = new TurnMonitor(backend, zcodeSid, () => server.nextId());
245
+ // Per-session ProjectionDiffer (persists across turns). The baseline mark_seen
246
+ // prevents the differ from re-emitting history at turn completion.
247
+ const differ = getOrCreateDiffer(server, zcodeSid);
248
+ const baselineMsgs = await fetchMessages(server, zcodeSid);
249
+ differ.markSeen(baselineMsgs);
250
+ // 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) {
253
+ server.pendingTurns.delete(requestId);
254
+ throw new Error("session/subscribe failed (ZCode CLI 0.14.8+ required)");
255
+ }
256
+ backend.registerEventListener(zcodeSid, listener);
257
+ const chunkMsgId = randomUUID();
258
+ 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
+ });
284
+ }
285
+ return result;
286
+ }
287
+ finally {
288
+ backend.unregisterEventListener(zcodeSid);
289
+ server.pendingTurns.delete(requestId);
290
+ }
291
+ }
292
+ /**
293
+ * `session/set_config_option` → dispatch model/mode/thought and emit the
294
+ * resulting config_option_update (+ current_mode_update for mode).
295
+ */
296
+ 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
+ if (typeof params.value !== "string") {
301
+ throw new Error(`unsupported config value type: ${String(params.value)}`);
302
+ }
303
+ const { setConfigOption, emitConfigOptionUpdate } = await import("../config/options.js");
304
+ const result = await setConfigOption(server, zcodeSid, params.configId, params.value);
305
+ if (!result) {
306
+ throw new Error(`unsupported config option or switch failed: ${params.configId}`);
307
+ }
308
+ const options = await emitConfigOptionUpdate(server, cx, params.sessionId, zcodeSid, result.kind);
309
+ return { configOptions: options };
310
+ }
311
+ /**
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.
316
+ */
317
+ export async function cancel(server, params) {
318
+ const zcodeSid = server.resolveSid(params.sessionId);
319
+ if (!zcodeSid)
320
+ return;
321
+ for (const [, turn] of server.pendingTurns) {
322
+ if (turn.zcodeSid === zcodeSid) {
323
+ turn.cancelled = true;
324
+ break; // one turn per session at a time
325
+ }
326
+ }
327
+ log(`session/cancel → ${zcodeSid}`);
328
+ }
329
+ /**
330
+ * Fire-and-forget `session/stop` to the backend. Mirrors Python's
331
+ * `_cancel_backend_turn`: send stop with an id (some backends route by id
332
+ * presence), never wait for a response, never throw.
333
+ *
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.
338
+ */
339
+ function stopBackendTurn(server, zcodeSid) {
340
+ try {
341
+ server.ensureBackend().send("session/stop", { sessionId: zcodeSid });
342
+ }
343
+ catch (e) {
344
+ log(` [stop] session/stop send failed (ignored): ${e instanceof Error ? e.message : String(e)}`);
345
+ }
346
+ }
347
+ /**
348
+ * Serialize a per-session critical section. Each section awaits the previous
349
+ * one's promise before running, so concurrent prompts for the same session
350
+ * execute register+preempt strictly one after another.
351
+ *
352
+ * Used by prompt() to wrap "register self in pendingTurns + preempt others":
353
+ * the registration must land before the section releases, so the next prompt
354
+ * entering its section sees this turn in its preempt scan. Without this lock,
355
+ * two near-simultaneous prompts could both scan before either registers.
356
+ *
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.
360
+ */
361
+ function withPreemptLock(server, zcodeSid, body) {
362
+ const prev = server.preemptLocks.get(zcodeSid) ?? Promise.resolve();
363
+ const next = prev.then(body, body); // run body regardless of prior rejection
364
+ server.preemptLocks.set(zcodeSid, next);
365
+ // Clean up the entry once settled so a later idle session doesn't retain a
366
+ // dangling promise. Only delete if still ours (a newer section may have
367
+ // chained on top of us). The `.catch` swallows any rejection propagated by
368
+ // `finally` (it returns a new promise that rejects if `next` rejected) —
369
+ // otherwise Node would raise an UnhandledPromiseRejection and crash.
370
+ next
371
+ .finally(() => {
372
+ if (server.preemptLocks.get(zcodeSid) === next) {
373
+ server.preemptLocks.delete(zcodeSid);
374
+ }
375
+ })
376
+ .catch(() => {
377
+ /* body rejection already surfaced by the returned `next`; swallow here */
378
+ });
379
+ return next;
380
+ }
381
+ /**
382
+ * Cancel any other in-flight turn for this zcodeSid and wait for it to fully
383
+ * exit (listener unregistered + pendingTurns cleaned) before returning.
384
+ *
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.
388
+ *
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.
393
+ *
394
+ * Best-effort: never throws. On timeout, continues anyway — session/send
395
+ * will then hit the lock and take the existing error path.
396
+ */
397
+ async function preemptInFlightTurn(server, zcodeSid, selfRequestId) {
398
+ // Find any in-flight turn for this session that isn't this request.
399
+ let oldRequestId;
400
+ 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
423
+ }
424
+ await sleep(200);
425
+ }
426
+ log(` [preempt] old turn ${oldRequestId} exited, proceeding`);
427
+ }
428
+ // ---------- internals ----------
429
+ /** Concatenate text from ACP ContentBlocks into a prompt string. */
430
+ function extractPromptText(blocks) {
431
+ const parts = [];
432
+ for (const block of blocks ?? []) {
433
+ const b = block;
434
+ if (b.type === "text" && b.text) {
435
+ parts.push(b.text);
436
+ }
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 ?? ""})`);
439
+ }
440
+ }
441
+ return parts.join("\n").trim();
442
+ }
443
+ /** Fetch session/messages from zcode. */
444
+ async function fetchMessages(server, zcodeSid) {
445
+ 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 ?? [];
451
+ }
452
+ /** Get or create the session-level ProjectionDiffer (persists across turns). */
453
+ function getOrCreateDiffer(server, zcodeSid) {
454
+ let d = server.differs.get(zcodeSid);
455
+ if (!d) {
456
+ d = new ProjectionDiffer();
457
+ server.differs.set(zcodeSid, d);
458
+ }
459
+ const differ = d;
460
+ differ.resetTurn();
461
+ return differ;
462
+ }
463
+ /**
464
+ * Event-driven turn loop: translate zcode events via EventTranslator and
465
+ * dispatch each internal event to the ACP client. No-progress timeout is 120s
466
+ * (refreshed by any event). Cancel is honoured on each iteration.
467
+ *
468
+ * Server→client requests (interaction/*) are drained each iteration; full
469
+ * handling (requestPermission / ExitPlanMode / AskUserQuestion) lands in
470
+ * Commit 6 — for now they're polled to keep the inbox clear.
471
+ */
472
+ async function runEventTurn(server, listener, monitor, differ, cx, acpSid, chunkMsgId, turn) {
473
+ const backend = server.ensureBackend();
474
+ const translator = new EventTranslator();
475
+ differ.resetTurn();
476
+ const NO_PROGRESS_MS = 120_000;
477
+ let lastProgress = Date.now();
478
+ let lastStallCheck = Date.now();
479
+ let emittedText = false;
480
+ let emittedOutput = false;
481
+ while (Date.now() - lastProgress < NO_PROGRESS_MS) {
482
+ // Drain + handle server→client requests (interaction/*). Refreshes the
483
+ // no-progress timer when any are handled. Pass `turn` so interaction
484
+ // requests become turn-cancel aware (user stop aborts pending popups).
485
+ if (await handleServerRequests(server, backend, cx, acpSid, turn)) {
486
+ lastProgress = Date.now();
487
+ }
488
+ if (turn.cancelled) {
489
+ stopBackendTurn(server, turn.zcodeSid);
490
+ return { stopReason: "cancelled" };
491
+ }
492
+ const ev = await listener.pollEvent(500);
493
+ if (ev === null) {
494
+ // Stall reconciliation: probe authoritative status after 15s of silence.
495
+ if (translator.turnStarted &&
496
+ Date.now() - lastProgress > 15_000 &&
497
+ Date.now() - lastStallCheck > 15_000) {
498
+ lastStallCheck = Date.now();
499
+ const proj = await monitor.pollOnce();
500
+ 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");
511
+ }
512
+ }
513
+ return { stopReason: "end_turn" };
514
+ }
515
+ if (proj?.status === "running") {
516
+ lastProgress = Date.now();
517
+ await listener.resubscribe(() => server.nextId());
518
+ }
519
+ }
520
+ continue;
521
+ }
522
+ lastProgress = Date.now();
523
+ const internalEvents = translator.translate(ev);
524
+ for (const iev of internalEvents) {
525
+ if (iev.kind === "TextDelta")
526
+ emittedText = true;
527
+ if (iev.kind === "ToolCallNew" || iev.kind === "ToolCallUpdate")
528
+ emittedOutput = true;
529
+ // Sync usage to the differ so the turn-completion diff doesn't re-emit a
530
+ // UsageDelta for the same value (the differ's lastUsage baseline is
531
+ // otherwise only set by its own diff / emitInitialUsage).
532
+ if (iev.kind === "UsageDelta")
533
+ differ.setLastUsage(iev.used);
534
+ await dispatchEvent(server, cx, acpSid, iev, chunkMsgId);
535
+ }
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).
539
+ if (ev.type === "tool.updated") {
540
+ 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);
548
+ }
549
+ }
550
+ // Sync translator → differ seen-tool-ids so the turn-completion differ.diff
551
+ // doesn't re-emit tools the event path already sent (which would clear
552
+ // Bash terminal output via a content-less ToolCallNew through the terminal
553
+ // path). Without this, Bash output is wiped on the next turn.
554
+ for (const seenId of translator.seenToolIds) {
555
+ differ.markToolSeen(seenId);
556
+ }
557
+ 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") {
561
+ return { stopReason: "cancelled" };
562
+ }
563
+ if (translator.turnFailed) {
564
+ // Best-effort stop in case the failed turn left a residual lock.
565
+ stopBackendTurn(server, turn.zcodeSid);
566
+ throw new RequestError(-32603, formatTurnError(translator.turnError));
567
+ }
568
+ // Fallback: if no text streamed, surface the last assistant reply.
569
+ if (!emittedText) {
570
+ const reply = await fetchLastReply(server, turn.zcodeSid, differ);
571
+ if (reply)
572
+ await sendTextChunk(cx, acpSid, reply, chunkMsgId);
573
+ }
574
+ // Turn-completion diff: emits PlanUpdate (todos) + final usage_update,
575
+ // reconciles any snapshot-only tool events.
576
+ //
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.
585
+ const snapshot = await buildSnapshot(server, turn.zcodeSid);
586
+ const completionEvents = differ.diff(snapshot);
587
+ for (const iev of completionEvents) {
588
+ if (iev.kind === "TextDelta" || iev.kind === "ReasoningDelta")
589
+ continue;
590
+ await dispatchEvent(server, cx, acpSid, iev, chunkMsgId);
591
+ }
592
+ // Mode reconciliation: an in-turn tool (EnterPlanMode/ExitPlanMode) can
593
+ // switch the session mode without the bridge intermediating, so no
594
+ // session/setMode notification fires. Re-read the authoritative mode and
595
+ // push current_mode_update + config_option_update when it changed since
596
+ // the last value advertised to the client.
597
+ await emitModeIfChanged(server, cx, acpSid, turn.zcodeSid);
598
+ return { stopReason: "end_turn" };
599
+ }
600
+ }
601
+ // 120s no progress: abandon.
602
+ stopBackendTurn(server, turn.zcodeSid);
603
+ return { stopReason: "max_turn_requests" };
604
+ }
605
+ /**
606
+ * Fetch the last assistant message text as a fallback for lost text events.
607
+ *
608
+ * Retries up to 4 times with a short delay because zcode has a data-consistency
609
+ * window after `status:idle` where `session/messages` may not yet include the
610
+ * just-finished reply. Skips assistant messages the differ already saw (by
611
+ * dedup key) so a previous turn's reply is never re-emitted as this turn's.
612
+ */
613
+ async function fetchLastReply(server, zcodeSid, differ) {
614
+ for (let attempt = 0; attempt < 4; attempt++) {
615
+ const messages = await fetchMessages(server, zcodeSid);
616
+ for (let i = messages.length - 1; i >= 0; i--) {
617
+ const m = messages[i];
618
+ if (!m)
619
+ continue;
620
+ if (m.info?.role !== "assistant")
621
+ continue;
622
+ // Skip messages the differ already processed (previous turns).
623
+ if (differ.hasSeenMessage(m))
624
+ continue;
625
+ for (let j = (m.parts ?? []).length - 1; j >= 0; j--) {
626
+ const p = m.parts[j];
627
+ if (p && typeof p === "object" && p.type === "text") {
628
+ const text = p.text ?? "";
629
+ if (text.trim())
630
+ return text;
631
+ }
632
+ }
633
+ }
634
+ if (attempt < 3)
635
+ await sleep(400);
636
+ }
637
+ return null;
638
+ }
639
+ /**
640
+ * Flatten the todos payload from `session/read`. Prefers the top-level `todos`;
641
+ * when empty, flattens `todoGroups` — the real backend dump carries todos as a
642
+ * list of groups (each with `entries` or `todos`), not a single object. Mirrors
643
+ * Python `_build_snapshot`. Exported for unit testing.
644
+ */
645
+ export function flattenTodos(todos, todoGroups) {
646
+ const top = todos ?? [];
647
+ if (top.length > 0 || !Array.isArray(todoGroups))
648
+ return top;
649
+ let flat = [];
650
+ for (const g of todoGroups) {
651
+ if (!g)
652
+ continue;
653
+ flat = flat.concat(g.entries ?? g.todos ?? []);
654
+ }
655
+ return flat;
656
+ }
657
+ /** Build a {projection, messages, todos} snapshot from session/messages + session/read. */
658
+ async function buildSnapshot(server, zcodeSid) {
659
+ const backend = server.ensureBackend();
660
+ const [msgs, readResp] = await Promise.all([
661
+ fetchMessages(server, zcodeSid),
662
+ backend.request(server.nextId(), "session/read", { sessionId: zcodeSid }, 8000),
663
+ ]);
664
+ const read = (readResp.result ?? {});
665
+ const todos = flattenTodos(read.todos, read.todoGroups);
666
+ return { projection: read.projection, messages: msgs, todos };
667
+ }
668
+ /**
669
+ * Re-read the authoritative session mode and, if it changed since the last
670
+ * value advertised to the client, emit `current_mode_update` +
671
+ * `config_option_update`. Covers in-turn mode switches performed by internal
672
+ * tools (EnterPlanMode/ExitPlanMode) that bypass `session/setMode` and thus
673
+ * emit no notification of their own. Best-effort: failures are logged and
674
+ * swallowed so they never break the turn-completion path.
675
+ */
676
+ async function emitModeIfChanged(server, cx, acpSid, zcodeSid) {
677
+ try {
678
+ const modes = await buildModes(server, zcodeSid);
679
+ const last = server.lastMode.get(acpSid);
680
+ if (last === modes.currentModeId)
681
+ return;
682
+ server.lastMode.set(acpSid, modes.currentModeId);
683
+ const options = await buildConfigOptions(server, zcodeSid);
684
+ await sendSessionUpdate(cx, acpSid, {
685
+ sessionUpdate: "config_option_update",
686
+ configOptions: options,
687
+ });
688
+ await sendSessionUpdate(cx, acpSid, {
689
+ sessionUpdate: "current_mode_update",
690
+ currentModeId: modes.currentModeId,
691
+ });
692
+ log(`session/prompt: mode changed → ${modes.currentModeId}`);
693
+ }
694
+ catch (e) {
695
+ warn(`emitModeIfChanged failed: ${e instanceof Error ? e.message : String(e)}`);
696
+ }
697
+ }
698
+ /**
699
+ * Edit/Write result → grab the structured patch from session/messages and emit
700
+ * a ToolCallUpdate with diff content immediately (don't wait for turn
701
+ * completion — model rate-limiting could delay it indefinitely). Always marks
702
+ * the tool seen in the differ so turn-completion diff won't re-emit it.
703
+ */
704
+ async function dispatchEditDiff(server, cx, acpSid, zcodeSid, callId, differ, chunkMsgId) {
705
+ const messages = await fetchMessages(server, zcodeSid);
706
+ for (const m of messages) {
707
+ for (const p of m.parts ?? []) {
708
+ if (!p || typeof p !== "object")
709
+ continue;
710
+ const part = p;
711
+ const partCallId = String(part["callID"] ?? part["callId"] ?? "");
712
+ if (partCallId !== callId)
713
+ continue;
714
+ const state = part["state"] ?? {};
715
+ const display = state["metadata"]?.["display"];
716
+ const diffContent = buildDiffContent(display);
717
+ const locations = extractLocations(String(part["tool"] ?? ""), state["input"], display);
718
+ const ev = {
719
+ kind: "ToolCallUpdate",
720
+ callId,
721
+ tool: String(part["tool"] ?? ""),
722
+ status: "completed",
723
+ diffContent: diffContent.length > 0 ? diffContent : undefined,
724
+ locations: locations.length > 0 ? locations : undefined,
725
+ };
726
+ if (diffContent.length > 0 || locations.length > 0) {
727
+ await dispatchEvent(server, cx, acpSid, ev, chunkMsgId);
728
+ }
729
+ differ.markToolSeen(callId);
730
+ return;
731
+ }
732
+ }
733
+ differ.markToolSeen(callId);
734
+ }
735
+ function sleep(ms) {
736
+ return new Promise((r) => setTimeout(r, ms));
737
+ }
738
+ //# sourceMappingURL=session.js.map