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.
- package/LICENSE +201 -0
- package/README.md +167 -0
- package/README.zh-CN.md +163 -0
- package/dist/backend/client.d.ts +103 -0
- package/dist/backend/client.d.ts.map +1 -0
- package/dist/backend/client.js +344 -0
- package/dist/backend/client.js.map +1 -0
- package/dist/backend/credentials.d.ts +31 -0
- package/dist/backend/credentials.d.ts.map +1 -0
- package/dist/backend/credentials.js +93 -0
- package/dist/backend/credentials.js.map +1 -0
- package/dist/backend/index.d.ts +7 -0
- package/dist/backend/index.d.ts.map +1 -0
- package/dist/backend/index.js +6 -0
- package/dist/backend/index.js.map +1 -0
- package/dist/backend/listener.d.ts +63 -0
- package/dist/backend/listener.d.ts.map +1 -0
- package/dist/backend/listener.js +138 -0
- package/dist/backend/listener.js.map +1 -0
- package/dist/backend/resolve.d.ts +11 -0
- package/dist/backend/resolve.d.ts.map +1 -0
- package/dist/backend/resolve.js +116 -0
- package/dist/backend/resolve.js.map +1 -0
- package/dist/backend/types.d.ts +164 -0
- package/dist/backend/types.d.ts.map +1 -0
- package/dist/backend/types.js +15 -0
- package/dist/backend/types.js.map +1 -0
- package/dist/config/model-cache.d.ts +20 -0
- package/dist/config/model-cache.d.ts.map +1 -0
- package/dist/config/model-cache.js +62 -0
- package/dist/config/model-cache.js.map +1 -0
- package/dist/config/options.d.ts +36 -0
- package/dist/config/options.d.ts.map +1 -0
- package/dist/config/options.js +171 -0
- package/dist/config/options.js.map +1 -0
- package/dist/config/runtime-model.d.ts +33 -0
- package/dist/config/runtime-model.d.ts.map +1 -0
- package/dist/config/runtime-model.js +96 -0
- package/dist/config/runtime-model.js.map +1 -0
- package/dist/handlers/dispatch.d.ts +15 -0
- package/dist/handlers/dispatch.d.ts.map +1 -0
- package/dist/handlers/dispatch.js +183 -0
- package/dist/handlers/dispatch.js.map +1 -0
- package/dist/handlers/extensions.d.ts +43 -0
- package/dist/handlers/extensions.d.ts.map +1 -0
- package/dist/handlers/extensions.js +310 -0
- package/dist/handlers/extensions.js.map +1 -0
- package/dist/handlers/io.d.ts +40 -0
- package/dist/handlers/io.d.ts.map +1 -0
- package/dist/handlers/io.js +55 -0
- package/dist/handlers/io.js.map +1 -0
- package/dist/handlers/server-requests.d.ts +34 -0
- package/dist/handlers/server-requests.d.ts.map +1 -0
- package/dist/handlers/server-requests.js +357 -0
- package/dist/handlers/server-requests.js.map +1 -0
- package/dist/handlers/session.d.ts +46 -0
- package/dist/handlers/session.d.ts.map +1 -0
- package/dist/handlers/session.js +738 -0
- package/dist/handlers/session.js.map +1 -0
- package/dist/handlers/slash.d.ts +16 -0
- package/dist/handlers/slash.d.ts.map +1 -0
- package/dist/handlers/slash.js +107 -0
- package/dist/handlers/slash.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +93 -0
- package/dist/index.js.map +1 -0
- package/dist/interaction/adapter.d.ts +136 -0
- package/dist/interaction/adapter.d.ts.map +1 -0
- package/dist/interaction/adapter.js +353 -0
- package/dist/interaction/adapter.js.map +1 -0
- package/dist/server.d.ts +73 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +97 -0
- package/dist/server.js.map +1 -0
- package/dist/tasks-index.d.ts +39 -0
- package/dist/tasks-index.d.ts.map +1 -0
- package/dist/tasks-index.js +152 -0
- package/dist/tasks-index.js.map +1 -0
- package/dist/translators/event-translator.d.ts +40 -0
- package/dist/translators/event-translator.d.ts.map +1 -0
- package/dist/translators/event-translator.js +214 -0
- package/dist/translators/event-translator.js.map +1 -0
- package/dist/translators/index.d.ts +6 -0
- package/dist/translators/index.d.ts.map +1 -0
- package/dist/translators/index.js +5 -0
- package/dist/translators/index.js.map +1 -0
- package/dist/translators/projection-differ.d.ts +48 -0
- package/dist/translators/projection-differ.d.ts.map +1 -0
- package/dist/translators/projection-differ.js +239 -0
- package/dist/translators/projection-differ.js.map +1 -0
- package/dist/translators/tool-helpers.d.ts +60 -0
- package/dist/translators/tool-helpers.d.ts.map +1 -0
- package/dist/translators/tool-helpers.js +308 -0
- package/dist/translators/tool-helpers.js.map +1 -0
- package/dist/translators/types.d.ts +58 -0
- package/dist/translators/types.d.ts.map +1 -0
- package/dist/translators/types.js +27 -0
- package/dist/translators/types.js.map +1 -0
- package/dist/utils.d.ts +111 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +110 -0
- package/dist/utils.js.map +1 -0
- package/docs/ARCHITECTURE.md +299 -0
- package/docs/DEVELOPMENT.md +193 -0
- package/docs/PROTOCOL.md +649 -0
- package/docs/TROUBLESHOOTING.md +251 -0
- 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
|