zcode-acp-server 0.31.1 → 0.32.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/dist/code-fingerprint.json +2 -2
- package/dist/goal-loop/driver.d.ts +112 -0
- package/dist/goal-loop/driver.d.ts.map +1 -0
- package/dist/goal-loop/driver.js +556 -0
- package/dist/goal-loop/driver.js.map +1 -0
- package/dist/goal-loop/state.d.ts +53 -0
- package/dist/goal-loop/state.d.ts.map +1 -0
- package/dist/goal-loop/state.js +80 -0
- package/dist/goal-loop/state.js.map +1 -0
- package/dist/goal-loop/templates.d.ts +79 -0
- package/dist/goal-loop/templates.d.ts.map +1 -0
- package/dist/goal-loop/templates.js +187 -0
- package/dist/goal-loop/templates.js.map +1 -0
- package/dist/handlers/sandbox-allow.d.ts +3 -0
- package/dist/handlers/sandbox-allow.d.ts.map +1 -1
- package/dist/handlers/sandbox-allow.js +21 -6
- package/dist/handlers/sandbox-allow.js.map +1 -1
- package/dist/handlers/session.d.ts +49 -0
- package/dist/handlers/session.d.ts.map +1 -1
- package/dist/handlers/session.js +225 -135
- package/dist/handlers/session.js.map +1 -1
- package/dist/handlers/slash.d.ts.map +1 -1
- package/dist/handlers/slash.js +58 -0
- package/dist/handlers/slash.js.map +1 -1
- package/dist/i18n.d.ts +18 -0
- package/dist/i18n.d.ts.map +1 -1
- package/dist/i18n.js +34 -0
- package/dist/i18n.js.map +1 -1
- package/dist/server.d.ts +25 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +12 -0
- package/dist/server.js.map +1 -1
- package/dist/utils.d.ts +6 -0
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +5 -0
- package/dist/utils.js.map +1 -1
- package/package.json +1 -1
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* notifications.
|
|
12
12
|
*/
|
|
13
13
|
import type * as acp from "@agentclientprotocol/sdk";
|
|
14
|
+
import type { ZcodeBackend } from "../backend/client.js";
|
|
14
15
|
import { EventStreamListener, TurnMonitor } from "../backend/listener.js";
|
|
15
16
|
import { ProjectionDiffer } from "../translators/index.js";
|
|
16
17
|
import type { PendingTurn, ZcodeAcpServer } from "../server.js";
|
|
@@ -103,6 +104,39 @@ export declare function loadSession(server: ZcodeAcpServer, params: acp.LoadSess
|
|
|
103
104
|
* the orphaned continuation bubble.
|
|
104
105
|
*/
|
|
105
106
|
export declare function prompt(server: ZcodeAcpServer, params: acp.PromptRequest, cx: acp.AgentContext, requestId: number | string, client?: acp.AgentContext): Promise<acp.PromptResponse>;
|
|
107
|
+
/**
|
|
108
|
+
* Options for {@link runOneTurn}: everything the round machinery needs that
|
|
109
|
+
* is not derivable from the server/session pair. `turn` MUST already be
|
|
110
|
+
* registered in `server.pendingTurns` under `requestId` by the caller (the
|
|
111
|
+
* prompt path does it inside withPreemptLock; the goal-loop driver likewise).
|
|
112
|
+
*/
|
|
113
|
+
export interface RunOneTurnOptions {
|
|
114
|
+
backend: ZcodeBackend;
|
|
115
|
+
cx: acp.AgentContext;
|
|
116
|
+
acpSid: string;
|
|
117
|
+
zcodeSid: string;
|
|
118
|
+
requestId: number | string;
|
|
119
|
+
turn: PendingTurn;
|
|
120
|
+
/** True when this send cancelled another in-flight prompt (gates attribution). */
|
|
121
|
+
preempted: boolean;
|
|
122
|
+
/** Wire text for the backend send (slash-neutralized on the prompt path). */
|
|
123
|
+
sendText: string;
|
|
124
|
+
attachments?: unknown[];
|
|
125
|
+
/** Sandbox allow-restart continuation round (emits the resumed status line). */
|
|
126
|
+
continuationRound?: boolean;
|
|
127
|
+
/** Run the env-gated maybeAutoCompact after an end_turn. Default true. */
|
|
128
|
+
autoCompact?: boolean;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* One backend round, shared by the prompt path and the goal-loop driver
|
|
132
|
+
* (ADR-0022): listener + differ baseline, subscribe with -32004 eviction
|
|
133
|
+
* recovery, send-busy retry, drain gate after a recent cancel, the
|
|
134
|
+
* event-driven turn loop with transient-failure retry, and the finally
|
|
135
|
+
* cleanup (pendingTurns deregistration, discovery/quota refresh, turnState).
|
|
136
|
+
* Every line here encodes an observed production failure — never reimplement
|
|
137
|
+
* a second send/subscribe/retry path.
|
|
138
|
+
*/
|
|
139
|
+
export declare function runOneTurn(server: ZcodeAcpServer, opts: RunOneTurnOptions): Promise<acp.PromptResponse>;
|
|
106
140
|
/**
|
|
107
141
|
* `session/set_config_option` → dispatch model/mode/thought and emit the
|
|
108
142
|
* resulting config_option_update (+ current_mode_update for mode).
|
|
@@ -171,6 +205,21 @@ interface DrainDeps {
|
|
|
171
205
|
* (stop pair fired; caller resolves session/prompt at once).
|
|
172
206
|
*/
|
|
173
207
|
export declare function drainBackendAfterCancel(server: ZcodeAcpServer, deps: DrainDeps): Promise<"cancelled" | "drained">;
|
|
208
|
+
/**
|
|
209
|
+
* Serialize a per-session critical section. Each section awaits the previous
|
|
210
|
+
* one's promise before running, so concurrent prompts for the same session
|
|
211
|
+
* execute register+preempt strictly one after another.
|
|
212
|
+
*
|
|
213
|
+
* Used by prompt() to wrap "register self in pendingTurns + preempt others":
|
|
214
|
+
* the registration must land before the section releases, so the next prompt
|
|
215
|
+
* entering its section sees this turn in its preempt scan. Without this lock,
|
|
216
|
+
* two near-simultaneous prompts could both scan before either registers.
|
|
217
|
+
*
|
|
218
|
+
* The body is async only to satisfy the lock chain (registration is
|
|
219
|
+
* synchronous; preempt no longer waits). The turn loop itself runs OUTSIDE
|
|
220
|
+
* this lock — only registration + preempt are serialized.
|
|
221
|
+
*/
|
|
222
|
+
export declare function withPreemptLock(server: ZcodeAcpServer, zcodeSid: string, body: () => Promise<void>): Promise<void>;
|
|
174
223
|
/**
|
|
175
224
|
* Cancel any other in-flight turn for this zcodeSid: fire `session/stop` and
|
|
176
225
|
* signal the old turn to stop retrying, then return immediately.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"session.d.ts","sourceRoot":"","sources":["../../src/handlers/session.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAMH,OAAO,KAAK,KAAK,GAAG,MAAM,0BAA0B,CAAC;AAGrD,OAAO,EAAE,mBAAmB,EAAE,WAAW,EAAE,MAAM,wBAAwB,CAAC;AAgC1E,OAAO,EAML,gBAAgB,EACjB,MAAM,yBAAyB,CAAC;AAGjC,OAAO,KAAK,EAAE,WAAW,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AAkHhE;;;;GAIG;AACH,wBAAgB,uBAAuB,IAAI,MAAM,GAAG,IAAI,CAIvD;AAqBD;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,mBAAmB,mBAAmB,CAAC;AAEpD;;;;;;;;;;;;;GAaG;AACH,wBAAsB,UAAU,CAC9B,MAAM,EAAE,cAAc,EACtB,MAAM,EAAE,GAAG,CAAC,iBAAiB,EAC7B,MAAM,CAAC,EAAE,GAAG,CAAC,YAAY,GACxB,OAAO,CAAC,GAAG,CAAC,kBAAkB,CAAC,CA2IjC;AAgDD;;;;;;GAMG;AACH,wBAAsB,iBAAiB,CAAC,MAAM,EAAE,cAAc,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CA2H/F;AAED,6CAA6C;AAC7C,wBAAsB,YAAY,CAChC,MAAM,EAAE,cAAc,EACtB,MAAM,EAAE,GAAG,CAAC,mBAAmB,GAC9B,OAAO,CAAC,GAAG,CAAC,oBAAoB,CAAC,CAuBnC;AAgKD,6EAA6E;AAC7E,wBAAsB,aAAa,CACjC,MAAM,EAAE,cAAc,EACtB,MAAM,EAAE,GAAG,CAAC,oBAAoB,EAChC,EAAE,EAAE,GAAG,CAAC,YAAY,GACnB,OAAO,CAAC,GAAG,CAAC,qBAAqB,CAAC,CA2FpC;AAED;;;;;;;;;;;;GAYG;AACH,wBAAsB,iBAAiB,CACrC,MAAM,EAAE,cAAc,EACtB,EAAE,EAAE,GAAG,CAAC,YAAY,EACpB,MAAM,EAAE,MAAM,EACd,WAAW,EAAE,MAAM,GAClB,OAAO,CAAC;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,MAAM,GAAG,SAAS,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC,CA8FjF;AAED;;;GAGG;AACH,wBAAsB,WAAW,CAC/B,MAAM,EAAE,cAAc,EACtB,MAAM,EAAE,GAAG,CAAC,kBAAkB,EAC9B,EAAE,EAAE,GAAG,CAAC,YAAY,EACpB,IAAI,GAAE;IAAE,aAAa,CAAC,EAAE,OAAO,CAAA;CAAO,GACrC,OAAO,CAAC,GAAG,CAAC,mBAAmB,CAAC,
|
|
1
|
+
{"version":3,"file":"session.d.ts","sourceRoot":"","sources":["../../src/handlers/session.ts"],"names":[],"mappings":"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"}
|
package/dist/handlers/session.js
CHANGED
|
@@ -891,6 +891,24 @@ export async function loadSession(server, params, cx, opts = {}) {
|
|
|
891
891
|
log(`session/load → ${zcodeSid}`);
|
|
892
892
|
server.ensureBackgroundListener(zcodeSid);
|
|
893
893
|
await adoptStoredTitle(server, acpSid, zcodeSid);
|
|
894
|
+
// Goal-loop restart recovery (ADR-0022 §6): surface a recovery hint at most
|
|
895
|
+
// once per bridge process per session — never auto-resume a spend-incurring
|
|
896
|
+
// loop the user may have meant to stop. A LIVE driver means a second client
|
|
897
|
+
// attached mid-loop: no hint at all. Only a persisted "running" status
|
|
898
|
+
// (running without a driver = the bridge died mid-loop) gets the
|
|
899
|
+
// interrupted wording; user-visible paused states get a neutral line.
|
|
900
|
+
{
|
|
901
|
+
const { GoalLoopDriver } = await import("../goal-loop/driver.js");
|
|
902
|
+
const { readGoalState } = await import("../goal-loop/state.js");
|
|
903
|
+
if (!GoalLoopDriver.live(server, zcodeSid) && !server.goalLoopLoadHints.has(zcodeSid)) {
|
|
904
|
+
const prior = readGoalState(cwd, zcodeSid);
|
|
905
|
+
const interrupted = prior?.status === "running";
|
|
906
|
+
if (prior && (interrupted || prior.status.startsWith("paused"))) {
|
|
907
|
+
server.goalLoopLoadHints.add(zcodeSid);
|
|
908
|
+
await sendTextChunk(cx, acpSid, interrupted ? messages().goalHintInterrupted(prior.objective) : messages().goalHintPaused, `goalhint_${randomUUID().slice(0, 8)}`).catch(() => undefined);
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
}
|
|
894
912
|
// Named `history` — a local `messages` would shadow the i18n `messages()`
|
|
895
913
|
// helper used in the error paths above (TDZ crash from the catch block).
|
|
896
914
|
const history = await fetchMessages(server, zcodeSid);
|
|
@@ -1016,120 +1034,25 @@ export async function prompt(server, params, cx, requestId, client) {
|
|
|
1016
1034
|
server.sandboxContinuations.delete(params.sessionId);
|
|
1017
1035
|
return result;
|
|
1018
1036
|
}
|
|
1019
|
-
/**
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
const
|
|
1030
|
-
const attachments =
|
|
1031
|
-
//
|
|
1032
|
-
//
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
// Boot-resume banner handshake (see BOOT_RESUME_TRIGGER): martty queues the
|
|
1036
|
-
// auto-submitted trigger until its boot bind, so it arrives as the FIRST
|
|
1037
|
-
// prompt OF THAT CONNECTION — ack with one chunk and end the turn without
|
|
1038
|
-
// touching the backend. Scoped to the arming connection's identity: a
|
|
1039
|
-
// phone app attached to the same bridge may prompt inside the boot window
|
|
1040
|
-
// and must neither spend nor disarm the handshake (observed live: its
|
|
1041
|
-
// prompt raced ahead and the trigger leaked to the model). The same
|
|
1042
|
-
// connection submitting anything else first means the auto-submit was
|
|
1043
|
-
// lost — disarm so the trigger text typed manually later stays a normal
|
|
1044
|
-
// prompt.
|
|
1045
|
-
if (server.bootResumeTriggerConnection !== null &&
|
|
1046
|
-
clientConnectionRoot(client) === server.bootResumeTriggerConnection) {
|
|
1047
|
-
server.bootResumeTriggerConnection = null;
|
|
1048
|
-
if (text === BOOT_RESUME_TRIGGER) {
|
|
1049
|
-
await sendTextChunk(cx, params.sessionId, messages().bootResumeAck, randomUUID());
|
|
1050
|
-
log("session/prompt: boot-resume banner handshake acknowledged");
|
|
1051
|
-
return { stopReason: "end_turn" };
|
|
1052
|
-
}
|
|
1053
|
-
}
|
|
1054
|
-
// Materialize a lazy session/new placeholder on first use. Placed after the
|
|
1055
|
-
// empty-prompt check so an invalid request doesn't create a backend session.
|
|
1056
|
-
const zcodeSid = await ensureRealSession(server, params.sessionId);
|
|
1057
|
-
// Slash-command interception: dispatches directly to ZCode methods and
|
|
1058
|
-
// returns end_turn without entering the turn loop. Known passthrough
|
|
1059
|
-
// commands and unknown /x both return null for the normal turn loop.
|
|
1060
|
-
const { handleSlashCommand, neutralizeSlashText } = await import("./slash.js");
|
|
1061
|
-
const intercepted = await handleSlashCommand(server, cx, params.sessionId, zcodeSid, text);
|
|
1062
|
-
if (intercepted)
|
|
1063
|
-
return intercepted;
|
|
1064
|
-
// Wire text for the backend: unknown `/x` prompts (not advertised commands)
|
|
1065
|
-
// are neutralized so the backend's command resolver never sees them — an
|
|
1066
|
-
// unresolvable name can hard-fail the turn. Known commands pass through
|
|
1067
|
-
// unchanged. The title/auto-compact paths below keep using the raw `text`.
|
|
1068
|
-
const sendText = neutralizeSlashText(text);
|
|
1069
|
-
// Register self + preempt others under a per-session lock. The lock
|
|
1070
|
-
// serializes the critical section so that two concurrent prompts (B, C) for
|
|
1071
|
-
// the same session can't both miss each other and register at once: C waits
|
|
1072
|
-
// for B's section, by which point B is in pendingTurns, so C's preempt finds
|
|
1073
|
-
// and cancels B. Registering INSIDE the lock is what makes the new turn
|
|
1074
|
-
// visible to the next prompt's preempt scan.
|
|
1075
|
-
const turn = {
|
|
1076
|
-
zcodeSid,
|
|
1077
|
-
cancelled: false,
|
|
1078
|
-
};
|
|
1079
|
-
// True when this send cancelled another in-flight prompt (preempt/stop).
|
|
1080
|
-
// Drives the turn-attribution gate: only a preempted prompt can see leftover
|
|
1081
|
-
// events from a prior turn in its listener queue; without preemption any
|
|
1082
|
-
// events before this turn's turn.started belong to a backend-owned turn
|
|
1083
|
-
// (e.g. auto-resumed after compaction) that this send was steered into.
|
|
1084
|
-
let preempted = false;
|
|
1085
|
-
await withPreemptLock(server, zcodeSid, async () => {
|
|
1086
|
-
server.pendingTurns.set(requestId, turn);
|
|
1087
|
-
preempted = preemptInFlightTurn(server, zcodeSid, requestId);
|
|
1088
|
-
});
|
|
1089
|
-
// Discovery: the session is live the moment its turn STARTS — mark it active
|
|
1090
|
-
// here instead of only at turn end, so a freshly created conversation shows
|
|
1091
|
-
// up in remote lists within one heartbeat even while its first (possibly
|
|
1092
|
-
// minutes-long) turn is still running. Until the backend's auto-title lands
|
|
1093
|
-
// at end_turn, seed a provisional title from the prompt text (auto-title
|
|
1094
|
-
// stays authoritative — its set-once gate is the separate sessionTitles).
|
|
1095
|
-
server.markSessionActive(params.sessionId);
|
|
1096
|
-
// Session title: set EXACTLY ONCE, here, from the first prompt of a
|
|
1097
|
-
// freshly created session — immediately, not at end_turn (a preempted
|
|
1098
|
-
// first turn ends "cancelled" and would never be titled; and the
|
|
1099
|
-
// completing prompt must not steal the title). After this, no automatic
|
|
1100
|
-
// path may change the title again: sessionTitles is set-once and a manual
|
|
1101
|
-
// rename is the only later modifier. Resumed/loaded sessions are not
|
|
1102
|
-
// title-eligible — their stored title was adopted on load, or left unset.
|
|
1103
|
-
if (server.titleEligibleSessions.has(params.sessionId) &&
|
|
1104
|
-
text &&
|
|
1105
|
-
!server.sessionTitles.has(params.sessionId)) {
|
|
1106
|
-
// Title = first non-empty line of the prompt, truncated to 80 chars.
|
|
1107
|
-
// Multi-line prompts must not leak newlines into the session title.
|
|
1108
|
-
// Split on any line break (\r\n, \n, \r) so all platforms are covered.
|
|
1109
|
-
const title = text
|
|
1110
|
-
.split(/\r\n|\r|\n/)
|
|
1111
|
-
.map((l) => l.trim())
|
|
1112
|
-
.find((l) => l.length > 0)
|
|
1113
|
-
?.slice(0, 80) ?? text.slice(0, 80);
|
|
1114
|
-
server.sessionTitles.set(params.sessionId, title);
|
|
1115
|
-
server.touchSessionSummary(params.sessionId, title);
|
|
1116
|
-
const { updateSessionTitle } = await import("../tasks-index.js");
|
|
1117
|
-
void updateSessionTitle(zcodeSid, title, text);
|
|
1118
|
-
void sendSessionUpdate(cx, params.sessionId, {
|
|
1119
|
-
sessionUpdate: "session_info_update",
|
|
1120
|
-
title,
|
|
1121
|
-
updatedAt: new Date().toISOString(),
|
|
1122
|
-
});
|
|
1123
|
-
}
|
|
1124
|
-
// Out-of-band running indicator: clients that did not send this prompt
|
|
1125
|
-
// (re-attached mobile, second editor) learn the turn started here — the
|
|
1126
|
-
// session/load replayMeta only snapshots attach time. Emitted per attached
|
|
1127
|
-
// alias (see server.sessionAliases) so a client holding this conversation
|
|
1128
|
-
// under a different ACP id opens its live turn too. Best-effort: a dead
|
|
1129
|
-
// client must not fail the turn.
|
|
1037
|
+
/**
|
|
1038
|
+
* One backend round, shared by the prompt path and the goal-loop driver
|
|
1039
|
+
* (ADR-0022): listener + differ baseline, subscribe with -32004 eviction
|
|
1040
|
+
* recovery, send-busy retry, drain gate after a recent cancel, the
|
|
1041
|
+
* event-driven turn loop with transient-failure retry, and the finally
|
|
1042
|
+
* cleanup (pendingTurns deregistration, discovery/quota refresh, turnState).
|
|
1043
|
+
* Every line here encodes an observed production failure — never reimplement
|
|
1044
|
+
* a second send/subscribe/retry path.
|
|
1045
|
+
*/
|
|
1046
|
+
export async function runOneTurn(server, opts) {
|
|
1047
|
+
const { backend, cx, acpSid, zcodeSid, requestId, turn, preempted, sendText } = opts;
|
|
1048
|
+
const attachments = opts.attachments ?? [];
|
|
1049
|
+
// Out-of-band running indicator: emitted per attached alias (see
|
|
1050
|
+
// server.sessionAliases) so a client holding this conversation under a
|
|
1051
|
+
// different ACP id opens its live turn too. Best-effort: a dead client must
|
|
1052
|
+
// not fail the turn.
|
|
1130
1053
|
const emitTurnState = async (running) => {
|
|
1131
1054
|
const results = await Promise.allSettled(server
|
|
1132
|
-
.sessionAliases(
|
|
1055
|
+
.sessionAliases(acpSid)
|
|
1133
1056
|
.map((sid) => cx.notify("$/zcode/turnState", { sessionId: sid, running })));
|
|
1134
1057
|
for (const r of results) {
|
|
1135
1058
|
if (r.status === "rejected") {
|
|
@@ -1137,7 +1060,6 @@ async function runPrompt(server, params, cx, requestId, continuationRound = fals
|
|
|
1137
1060
|
}
|
|
1138
1061
|
}
|
|
1139
1062
|
};
|
|
1140
|
-
await emitTurnState(true);
|
|
1141
1063
|
const listener = new EventStreamListener(backend, zcodeSid);
|
|
1142
1064
|
const monitor = new TurnMonitor(backend, zcodeSid, () => server.nextId());
|
|
1143
1065
|
// Per-session ProjectionDiffer (persists across turns). The baseline mark_seen
|
|
@@ -1148,8 +1070,7 @@ async function runPrompt(server, params, cx, requestId, continuationRound = fals
|
|
|
1148
1070
|
// Subscribe BEFORE send so we don't lose early turn.completed on short turns.
|
|
1149
1071
|
// subscribe() throws on failure, surfacing the backend's real error (reader
|
|
1150
1072
|
// dead, timeout, pipe broken, method-not-found on old CLI, session error) so
|
|
1151
|
-
// the cause is distinguishable. Clean up the pending turn before propagating
|
|
1152
|
-
// — this call site is outside the try/finally below.
|
|
1073
|
+
// the cause is distinguishable. Clean up the pending turn before propagating.
|
|
1153
1074
|
let snapshot;
|
|
1154
1075
|
try {
|
|
1155
1076
|
try {
|
|
@@ -1165,8 +1086,8 @@ async function runPrompt(server, params, cx, requestId, continuationRound = fals
|
|
|
1165
1086
|
const msg = e instanceof Error ? e.message : String(e);
|
|
1166
1087
|
if (!/session is not active/i.test(msg))
|
|
1167
1088
|
throw e;
|
|
1168
|
-
log(`
|
|
1169
|
-
await reloadBackendSession(server,
|
|
1089
|
+
log(`turn: session ${zcodeSid} no longer active in backend — reloading via session/resume`);
|
|
1090
|
+
await reloadBackendSession(server, acpSid, zcodeSid);
|
|
1170
1091
|
// The pre-subscribe fetchMessages ran against the evicted session and
|
|
1171
1092
|
// came back empty — re-baseline the differ so turn completion doesn't
|
|
1172
1093
|
// diff-replay the whole history as new output.
|
|
@@ -1175,7 +1096,7 @@ async function runPrompt(server, params, cx, requestId, continuationRound = fals
|
|
|
1175
1096
|
}
|
|
1176
1097
|
// A successful subscribe proves the resident runtime is live — refresh
|
|
1177
1098
|
// the verification so concurrent/later entry points skip a reload.
|
|
1178
|
-
server.markBackendLoaded(
|
|
1099
|
+
server.markBackendLoaded(acpSid);
|
|
1179
1100
|
}
|
|
1180
1101
|
catch (e) {
|
|
1181
1102
|
server.pendingTurns.delete(requestId);
|
|
@@ -1212,7 +1133,7 @@ async function runPrompt(server, params, cx, requestId, continuationRound = fals
|
|
|
1212
1133
|
return { stopReason: "cancelled" };
|
|
1213
1134
|
}
|
|
1214
1135
|
differ.markSeen(await fetchMessages(server, zcodeSid));
|
|
1215
|
-
await sendTextChunk(cx,
|
|
1136
|
+
await sendTextChunk(cx, acpSid, messages().networkRetry(attempt - 1, MAX_TURN_ATTEMPTS - 1), randomUUID());
|
|
1216
1137
|
log(` [retry] transient turn failed, re-sending (attempt ${attempt}/${MAX_TURN_ATTEMPTS})`);
|
|
1217
1138
|
await sleep(backoffMs(attempt - 1));
|
|
1218
1139
|
}
|
|
@@ -1223,7 +1144,7 @@ async function runPrompt(server, params, cx, requestId, continuationRound = fals
|
|
|
1223
1144
|
Date.now() - server.lastCancelledAt.get(zcodeSid) < DRAIN_WINDOW_MS;
|
|
1224
1145
|
if (cancelledRecently) {
|
|
1225
1146
|
const drained = await drainBackendAfterCancel(server, {
|
|
1226
|
-
acpSid
|
|
1147
|
+
acpSid,
|
|
1227
1148
|
zcodeSid,
|
|
1228
1149
|
turn,
|
|
1229
1150
|
listener,
|
|
@@ -1303,8 +1224,8 @@ async function runPrompt(server, params, cx, requestId, continuationRound = fals
|
|
|
1303
1224
|
// bare thinking block with no context. Announce the resumed turn at
|
|
1304
1225
|
// send-accept so every phase of the allow→restart→continue flow is
|
|
1305
1226
|
// visibly accounted for (the allow-time hint covers the restart start).
|
|
1306
|
-
if (continuationRound) {
|
|
1307
|
-
await sendTextChunk(cx,
|
|
1227
|
+
if (opts.continuationRound) {
|
|
1228
|
+
await sendTextChunk(cx, acpSid, messages().sandboxResumedStatus, randomUUID());
|
|
1308
1229
|
}
|
|
1309
1230
|
try {
|
|
1310
1231
|
// Event-driven turn loop: translate events via EventTranslator + dispatch.
|
|
@@ -1314,24 +1235,21 @@ async function runPrompt(server, params, cx, requestId, continuationRound = fals
|
|
|
1314
1235
|
// of this new prompt (see the gate comment in runEventTurn).
|
|
1315
1236
|
const gateArmed = preempted ||
|
|
1316
1237
|
Date.now() - (server.lastCancelledAt.get(zcodeSid) ?? 0) < CANCEL_RESIDUE_WINDOW_MS;
|
|
1317
|
-
const result = await runEventTurn(server, listener, monitor, differ, cx,
|
|
1318
|
-
// (Session title: already set once at the FIRST prompt, before the
|
|
1319
|
-
// turn loop — nothing here may change it again.)
|
|
1238
|
+
const result = await runEventTurn(server, listener, monitor, differ, cx, acpSid, chunkMsgId, turn, gateArmed);
|
|
1320
1239
|
// Auto-compact: if context usage exceeds the threshold, compact before
|
|
1321
1240
|
// returning so the next prompt has room. Configured via
|
|
1322
1241
|
// ZCODE_ACP_AUTO_COMPACT_THRESHOLD (absolute token count; 0/unset =
|
|
1323
1242
|
// disabled). Only on end_turn — cancelled/max_turn_requests skips
|
|
1324
1243
|
// compaction, as does a stall-recovered end_turn (the completion was
|
|
1325
1244
|
// inferred by the stall heuristic, not confirmed by turn.completed —
|
|
1326
|
-
// compressing an in-flight task's
|
|
1245
|
+
// compressing an in-flight task's would destroy the work).
|
|
1327
1246
|
// Best-effort: failures are logged inside maybeAutoCompact, never thrown.
|
|
1328
|
-
if (
|
|
1247
|
+
if (opts.autoCompact !== false &&
|
|
1248
|
+
result.stopReason === "end_turn" &&
|
|
1249
|
+
!turn.stallRecovered) {
|
|
1329
1250
|
const { maybeAutoCompact } = await import("../config/auto-compact.js");
|
|
1330
|
-
await maybeAutoCompact(server, cx,
|
|
1251
|
+
await maybeAutoCompact(server, cx, acpSid, zcodeSid);
|
|
1331
1252
|
}
|
|
1332
|
-
// (Sandbox allow-restart continuation: handled by the prompt() wrapper
|
|
1333
|
-
// — the chained round keeps the editor's original request pending so
|
|
1334
|
-
// the restart window stays visibly "running".)
|
|
1335
1253
|
return result;
|
|
1336
1254
|
}
|
|
1337
1255
|
catch (e) {
|
|
@@ -1355,7 +1273,7 @@ async function runPrompt(server, params, cx, requestId, continuationRound = fals
|
|
|
1355
1273
|
// surfacing a hard error and stopping. Skip auto-compact here: compaction
|
|
1356
1274
|
// after a failed turn is more likely to confuse state than help.
|
|
1357
1275
|
const errMsg = formatTurnError(lastTurnError) || "turn failed after retries";
|
|
1358
|
-
await sendTextChunk(cx,
|
|
1276
|
+
await sendTextChunk(cx, acpSid, messages().requestFailed(errMsg), randomUUID());
|
|
1359
1277
|
return { stopReason: "end_turn" };
|
|
1360
1278
|
}
|
|
1361
1279
|
finally {
|
|
@@ -1365,8 +1283,8 @@ async function runPrompt(server, params, cx, requestId, continuationRound = fals
|
|
|
1365
1283
|
// session discoverable regardless of outcome (end_turn, cancelled, retries
|
|
1366
1284
|
// exhausted). Also refresh the backend-loaded verification: the resident
|
|
1367
1285
|
// runtime was demonstrably live through this turn.
|
|
1368
|
-
server.markSessionActive(
|
|
1369
|
-
server.markBackendLoaded(
|
|
1286
|
+
server.markSessionActive(acpSid);
|
|
1287
|
+
server.markBackendLoaded(acpSid);
|
|
1370
1288
|
// Turn end = quota refresh point (ADR-0021): usage moved, the dock should
|
|
1371
1289
|
// catch up immediately instead of waiting for the 60s interval.
|
|
1372
1290
|
void forceRefreshQuota();
|
|
@@ -1376,6 +1294,158 @@ async function runPrompt(server, params, cx, requestId, continuationRound = fals
|
|
|
1376
1294
|
await emitTurnState(stillBusy);
|
|
1377
1295
|
}
|
|
1378
1296
|
}
|
|
1297
|
+
/** One prompt round: subscribe-before-send, run the event-driven turn loop. */
|
|
1298
|
+
async function runPrompt(server, params, cx, requestId, continuationRound = false, client) {
|
|
1299
|
+
// Project-level sandbox flip (ADR-0011): a .zcode/acp/sandbox.json created
|
|
1300
|
+
// after the backend spawned unsandboxed must arm on THIS prompt, not on the
|
|
1301
|
+
// next bridge restart — kill the old process; the ensureBackend() below
|
|
1302
|
+
// respawns under the profile and the subscribe-recovery path reloads the
|
|
1303
|
+
// session. No-op unless the config appeared mid-run.
|
|
1304
|
+
await server.applySandboxFlip();
|
|
1305
|
+
const backend = server.ensureBackend();
|
|
1306
|
+
// Extract prompt text + image attachments from ACP ContentBlock[].
|
|
1307
|
+
const text = extractPromptText(params.prompt);
|
|
1308
|
+
const attachments = extractAttachments(params.prompt);
|
|
1309
|
+
// A prompt is valid if it has text OR at least one image attachment (a user
|
|
1310
|
+
// may drag in an image with no accompanying text).
|
|
1311
|
+
if (!text && attachments.length === 0)
|
|
1312
|
+
throw new Error("empty prompt");
|
|
1313
|
+
// Boot-resume banner handshake (see BOOT_RESUME_TRIGGER): martty queues the
|
|
1314
|
+
// auto-submitted trigger until its boot bind, so it arrives as the FIRST
|
|
1315
|
+
// prompt OF THAT CONNECTION — ack with one chunk and end the turn without
|
|
1316
|
+
// touching the backend. Scoped to the arming connection's identity: a
|
|
1317
|
+
// phone app attached to the same bridge may prompt inside the boot window
|
|
1318
|
+
// and must neither spend nor disarm the handshake (observed live: its
|
|
1319
|
+
// prompt raced ahead and the trigger leaked to the model). The same
|
|
1320
|
+
// connection submitting anything else first means the auto-submit was
|
|
1321
|
+
// lost — disarm so the trigger text typed manually later stays a normal
|
|
1322
|
+
// prompt.
|
|
1323
|
+
if (server.bootResumeTriggerConnection !== null &&
|
|
1324
|
+
clientConnectionRoot(client) === server.bootResumeTriggerConnection) {
|
|
1325
|
+
server.bootResumeTriggerConnection = null;
|
|
1326
|
+
if (text === BOOT_RESUME_TRIGGER) {
|
|
1327
|
+
await sendTextChunk(cx, params.sessionId, messages().bootResumeAck, randomUUID());
|
|
1328
|
+
log("session/prompt: boot-resume banner handshake acknowledged");
|
|
1329
|
+
return { stopReason: "end_turn" };
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
// Materialize a lazy session/new placeholder on first use. Placed after the
|
|
1333
|
+
// empty-prompt check so an invalid request doesn't create a backend session.
|
|
1334
|
+
const zcodeSid = await ensureRealSession(server, params.sessionId);
|
|
1335
|
+
// Slash-command interception: dispatches directly to ZCode methods and
|
|
1336
|
+
// returns end_turn without entering the turn loop. Known passthrough
|
|
1337
|
+
// commands and unknown /x both return null for the normal turn loop.
|
|
1338
|
+
const { handleSlashCommand, neutralizeSlashText } = await import("./slash.js");
|
|
1339
|
+
const intercepted = await handleSlashCommand(server, cx, params.sessionId, zcodeSid, text);
|
|
1340
|
+
if (intercepted)
|
|
1341
|
+
return intercepted;
|
|
1342
|
+
// Wire text for the backend: unknown `/x` prompts (not advertised commands)
|
|
1343
|
+
// are neutralized so the backend's command resolver never sees them — an
|
|
1344
|
+
// unresolvable name can hard-fail the turn. Known commands pass through
|
|
1345
|
+
// unchanged. The title/auto-compact paths below keep using the raw `text`.
|
|
1346
|
+
const sendText = neutralizeSlashText(text);
|
|
1347
|
+
// Register self + preempt others under a per-session lock. The lock
|
|
1348
|
+
// serializes the critical section so that two concurrent prompts (B, C) for
|
|
1349
|
+
// the same session can't both miss each other and register at once: C waits
|
|
1350
|
+
// for B's section, by which point B is in pendingTurns, so C's preempt finds
|
|
1351
|
+
// and cancels B. Registering INSIDE the lock is what makes the new turn
|
|
1352
|
+
// visible to the next prompt's preempt scan.
|
|
1353
|
+
const turn = {
|
|
1354
|
+
zcodeSid,
|
|
1355
|
+
cancelled: false,
|
|
1356
|
+
};
|
|
1357
|
+
// True when this send cancelled another in-flight prompt (preempt/stop).
|
|
1358
|
+
// Drives the turn-attribution gate: only a preempted prompt can see leftover
|
|
1359
|
+
// events from a prior turn in its listener queue; without preemption any
|
|
1360
|
+
// events before this turn's turn.started belong to a backend-owned turn
|
|
1361
|
+
// (e.g. auto-resumed after compaction) that this send was steered into.
|
|
1362
|
+
let preempted = false;
|
|
1363
|
+
// Goal-loop parking (ADR-0022 §3): a live loop owns this session's turn
|
|
1364
|
+
// cadence. Park the prompt on the driver — its text merges into the next
|
|
1365
|
+
// round, and the parked request resolves when that round completes —
|
|
1366
|
+
// instead of preempting and cancelling the in-flight goal turn. Checked
|
|
1367
|
+
// INSIDE the preempt lock: the check and the registration are one critical
|
|
1368
|
+
// section, so an /auto start racing this prompt can't register its round
|
|
1369
|
+
// after the check yet before the park (the driver registers through the
|
|
1370
|
+
// same lock). Slash commands /auto pause|resume|stop|status were
|
|
1371
|
+
// intercepted above, so anything reaching here is conversational input.
|
|
1372
|
+
let parked;
|
|
1373
|
+
await withPreemptLock(server, zcodeSid, async () => {
|
|
1374
|
+
const goalLoop = server.goalLoops?.get(zcodeSid);
|
|
1375
|
+
if (goalLoop) {
|
|
1376
|
+
parked = goalLoop.parkPrompt(sendText);
|
|
1377
|
+
return;
|
|
1378
|
+
}
|
|
1379
|
+
server.pendingTurns.set(requestId, turn);
|
|
1380
|
+
preempted = preemptInFlightTurn(server, zcodeSid, requestId);
|
|
1381
|
+
});
|
|
1382
|
+
if (parked)
|
|
1383
|
+
return parked;
|
|
1384
|
+
// Discovery: the session is live the moment its turn STARTS — mark it active
|
|
1385
|
+
// here instead of only at turn end, so a freshly created conversation shows
|
|
1386
|
+
// up in remote lists within one heartbeat even while its first (possibly
|
|
1387
|
+
// minutes-long) turn is still running. Until the backend's auto-title lands
|
|
1388
|
+
// at end_turn, seed a provisional title from the prompt text (auto-title
|
|
1389
|
+
// stays authoritative — its set-once gate is the separate sessionTitles).
|
|
1390
|
+
server.markSessionActive(params.sessionId);
|
|
1391
|
+
// Session title: set EXACTLY ONCE, here, from the first prompt of a
|
|
1392
|
+
// freshly created session — immediately, not at end_turn (a preempted
|
|
1393
|
+
// first turn ends "cancelled" and would never be titled; and the
|
|
1394
|
+
// completing prompt must not steal the title). After this, no automatic
|
|
1395
|
+
// path may change the title again: sessionTitles is set-once and a manual
|
|
1396
|
+
// rename is the only later modifier. Resumed/loaded sessions are not
|
|
1397
|
+
// title-eligible — their stored title was adopted on load, or left unset.
|
|
1398
|
+
if (server.titleEligibleSessions.has(params.sessionId) &&
|
|
1399
|
+
text &&
|
|
1400
|
+
!server.sessionTitles.has(params.sessionId)) {
|
|
1401
|
+
// Title = first non-empty line of the prompt, truncated to 80 chars.
|
|
1402
|
+
// Multi-line prompts must not leak newlines into the session title.
|
|
1403
|
+
// Split on any line break (\r\n, \n, \r) so all platforms are covered.
|
|
1404
|
+
const title = text
|
|
1405
|
+
.split(/\r\n|\r|\n/)
|
|
1406
|
+
.map((l) => l.trim())
|
|
1407
|
+
.find((l) => l.length > 0)
|
|
1408
|
+
?.slice(0, 80) ?? text.slice(0, 80);
|
|
1409
|
+
server.sessionTitles.set(params.sessionId, title);
|
|
1410
|
+
server.touchSessionSummary(params.sessionId, title);
|
|
1411
|
+
const { updateSessionTitle } = await import("../tasks-index.js");
|
|
1412
|
+
void updateSessionTitle(zcodeSid, title, text);
|
|
1413
|
+
void sendSessionUpdate(cx, params.sessionId, {
|
|
1414
|
+
sessionUpdate: "session_info_update",
|
|
1415
|
+
title,
|
|
1416
|
+
updatedAt: new Date().toISOString(),
|
|
1417
|
+
});
|
|
1418
|
+
}
|
|
1419
|
+
// Out-of-band running indicator: clients that did not send this prompt
|
|
1420
|
+
// (re-attached mobile, second editor) learn the turn started here — the
|
|
1421
|
+
// session/load replayMeta only snapshots attach time. Emitted per attached
|
|
1422
|
+
// alias (see server.sessionAliases) so a client holding this conversation
|
|
1423
|
+
// under a different ACP id opens its live turn too. Best-effort: a dead
|
|
1424
|
+
// client must not fail the turn.
|
|
1425
|
+
const emitTurnState = async (running) => {
|
|
1426
|
+
const results = await Promise.allSettled(server
|
|
1427
|
+
.sessionAliases(params.sessionId)
|
|
1428
|
+
.map((sid) => cx.notify("$/zcode/turnState", { sessionId: sid, running })));
|
|
1429
|
+
for (const r of results) {
|
|
1430
|
+
if (r.status === "rejected") {
|
|
1431
|
+
log(`turnState notify failed: ${r.reason instanceof Error ? r.reason.message : String(r.reason)}`);
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
};
|
|
1435
|
+
await emitTurnState(true);
|
|
1436
|
+
return runOneTurn(server, {
|
|
1437
|
+
backend,
|
|
1438
|
+
cx,
|
|
1439
|
+
acpSid: params.sessionId,
|
|
1440
|
+
zcodeSid,
|
|
1441
|
+
requestId,
|
|
1442
|
+
turn,
|
|
1443
|
+
preempted,
|
|
1444
|
+
sendText,
|
|
1445
|
+
attachments,
|
|
1446
|
+
continuationRound,
|
|
1447
|
+
});
|
|
1448
|
+
}
|
|
1379
1449
|
/** How long after a cancel a new prompt's attribution gate stays armed (the
|
|
1380
1450
|
* abandoned turn may still be streaming its finalisation into the backend). */
|
|
1381
1451
|
const CANCEL_RESIDUE_WINDOW_MS = 120_000;
|
|
@@ -1440,8 +1510,10 @@ export async function cancel(server, params) {
|
|
|
1440
1510
|
// could leave the live one running. Each turn guards its own stopSent, so
|
|
1441
1511
|
// multiple matching turns may each fire the stop pair once — the backend
|
|
1442
1512
|
// treats both as idempotent, so the duplicate is harmless.
|
|
1513
|
+
let matched = false;
|
|
1443
1514
|
for (const [, turn] of server.pendingTurns) {
|
|
1444
1515
|
if (turn.zcodeSid === zcodeSid) {
|
|
1516
|
+
matched = true;
|
|
1445
1517
|
turn.cancelled = true;
|
|
1446
1518
|
if (!turn.stopSent) {
|
|
1447
1519
|
stopBackendTurn(server, zcodeSid, turn.foregroundExecutionId);
|
|
@@ -1452,6 +1524,18 @@ export async function cancel(server, params) {
|
|
|
1452
1524
|
server.lastCancelledAt.set(zcodeSid, Date.now());
|
|
1453
1525
|
}
|
|
1454
1526
|
}
|
|
1527
|
+
// ESC during a goal loop's quiet windows (compact()'s internal wait, the
|
|
1528
|
+
// judge/announce awaits) has no pendingTurns entry to flag — park the pause
|
|
1529
|
+
// on the driver instead; it takes effect at the next round boundary.
|
|
1530
|
+
// Best-effort: never break cancel for non-goal sessions.
|
|
1531
|
+
if (!matched) {
|
|
1532
|
+
try {
|
|
1533
|
+
server.goalLoops?.get(zcodeSid)?.requestPause();
|
|
1534
|
+
}
|
|
1535
|
+
catch (e) {
|
|
1536
|
+
log(`goal-loop pause request failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1455
1539
|
log(`session/cancel → ${zcodeSid}`);
|
|
1456
1540
|
}
|
|
1457
1541
|
/**
|
|
@@ -1667,7 +1751,7 @@ export async function drainBackendAfterCancel(server, deps) {
|
|
|
1667
1751
|
* synchronous; preempt no longer waits). The turn loop itself runs OUTSIDE
|
|
1668
1752
|
* this lock — only registration + preempt are serialized.
|
|
1669
1753
|
*/
|
|
1670
|
-
function withPreemptLock(server, zcodeSid, body) {
|
|
1754
|
+
export function withPreemptLock(server, zcodeSid, body) {
|
|
1671
1755
|
const prev = server.preemptLocks.get(zcodeSid) ?? Promise.resolve();
|
|
1672
1756
|
const next = prev.then(body, body); // run body regardless of prior rejection
|
|
1673
1757
|
server.preemptLocks.set(zcodeSid, next);
|
|
@@ -1719,6 +1803,12 @@ export function preemptInFlightTurn(server, zcodeSid, selfRequestId) {
|
|
|
1719
1803
|
for (const [reqId, turn] of server.pendingTurns) {
|
|
1720
1804
|
if (turn.zcodeSid !== zcodeSid || reqId === selfRequestId)
|
|
1721
1805
|
continue;
|
|
1806
|
+
// Goal-loop rounds (ADR-0022): user prompts PARK on the driver (see the
|
|
1807
|
+
// parking hook in runPrompt) instead of preempting — a goalLoop turn in
|
|
1808
|
+
// this scan means a path that bypassed the hook (defensive); never
|
|
1809
|
+
// cancel it here. ESC/cancel() still cancels it like any pending turn.
|
|
1810
|
+
if (turn.goalLoop)
|
|
1811
|
+
continue;
|
|
1722
1812
|
turn.cancelled = true; // signal the old turn to stop its retry loops
|
|
1723
1813
|
if (!turn.stopSent) {
|
|
1724
1814
|
stopBackendTurn(server, zcodeSid, turn.foregroundExecutionId);
|