zcode-acp-server 0.31.0 → 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.
Files changed (48) hide show
  1. package/dist/code-fingerprint.json +2 -2
  2. package/dist/config/options.d.ts +1 -7
  3. package/dist/config/options.d.ts.map +1 -1
  4. package/dist/config/options.js +44 -16
  5. package/dist/config/options.js.map +1 -1
  6. package/dist/goal-loop/driver.d.ts +112 -0
  7. package/dist/goal-loop/driver.d.ts.map +1 -0
  8. package/dist/goal-loop/driver.js +556 -0
  9. package/dist/goal-loop/driver.js.map +1 -0
  10. package/dist/goal-loop/state.d.ts +53 -0
  11. package/dist/goal-loop/state.d.ts.map +1 -0
  12. package/dist/goal-loop/state.js +80 -0
  13. package/dist/goal-loop/state.js.map +1 -0
  14. package/dist/goal-loop/templates.d.ts +79 -0
  15. package/dist/goal-loop/templates.d.ts.map +1 -0
  16. package/dist/goal-loop/templates.js +187 -0
  17. package/dist/goal-loop/templates.js.map +1 -0
  18. package/dist/handlers/sandbox-allow.d.ts +3 -0
  19. package/dist/handlers/sandbox-allow.d.ts.map +1 -1
  20. package/dist/handlers/sandbox-allow.js +21 -6
  21. package/dist/handlers/sandbox-allow.js.map +1 -1
  22. package/dist/handlers/session.d.ts +49 -0
  23. package/dist/handlers/session.d.ts.map +1 -1
  24. package/dist/handlers/session.js +225 -135
  25. package/dist/handlers/session.js.map +1 -1
  26. package/dist/handlers/slash.d.ts.map +1 -1
  27. package/dist/handlers/slash.js +58 -0
  28. package/dist/handlers/slash.js.map +1 -1
  29. package/dist/i18n.d.ts +18 -0
  30. package/dist/i18n.d.ts.map +1 -1
  31. package/dist/i18n.js +34 -0
  32. package/dist/i18n.js.map +1 -1
  33. package/dist/remote/hub-server.d.ts.map +1 -1
  34. package/dist/remote/hub-server.js +12 -4
  35. package/dist/remote/hub-server.js.map +1 -1
  36. package/dist/server.d.ts +25 -0
  37. package/dist/server.d.ts.map +1 -1
  38. package/dist/server.js +12 -0
  39. package/dist/server.js.map +1 -1
  40. package/dist/translators/event-translator.d.ts +11 -0
  41. package/dist/translators/event-translator.d.ts.map +1 -1
  42. package/dist/translators/event-translator.js +60 -14
  43. package/dist/translators/event-translator.js.map +1 -1
  44. package/dist/utils.d.ts +6 -0
  45. package/dist/utils.d.ts.map +1 -1
  46. package/dist/utils.js +5 -0
  47. package/dist/utils.js.map +1 -1
  48. package/package.json +1 -1
@@ -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
- /** One prompt round: subscribe-before-send, run the event-driven turn loop. */
1020
- async function runPrompt(server, params, cx, requestId, continuationRound = false, client) {
1021
- // Project-level sandbox flip (ADR-0011): a .zcode/acp/sandbox.json created
1022
- // after the backend spawned unsandboxed must arm on THIS prompt, not on the
1023
- // next bridge restart — kill the old process; the ensureBackend() below
1024
- // respawns under the profile and the subscribe-recovery path reloads the
1025
- // session. No-op unless the config appeared mid-run.
1026
- await server.applySandboxFlip();
1027
- const backend = server.ensureBackend();
1028
- // Extract prompt text + image attachments from ACP ContentBlock[].
1029
- const text = extractPromptText(params.prompt);
1030
- const attachments = extractAttachments(params.prompt);
1031
- // A prompt is valid if it has text OR at least one image attachment (a user
1032
- // may drag in an image with no accompanying text).
1033
- if (!text && attachments.length === 0)
1034
- throw new Error("empty prompt");
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(params.sessionId)
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(`prompt: session ${zcodeSid} no longer active in backend — reloading via session/resume`);
1169
- await reloadBackendSession(server, params.sessionId, zcodeSid);
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(params.sessionId);
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, params.sessionId, messages().networkRetry(attempt - 1, MAX_TURN_ATTEMPTS - 1), randomUUID());
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: params.sessionId,
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, params.sessionId, messages().sandboxResumedStatus, randomUUID());
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, params.sessionId, chunkMsgId, turn, gateArmed);
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 context would destroy the work).
1245
+ // compressing an in-flight task's would destroy the work).
1327
1246
  // Best-effort: failures are logged inside maybeAutoCompact, never thrown.
1328
- if (result.stopReason === "end_turn" && !turn.stallRecovered) {
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, params.sessionId, zcodeSid);
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, params.sessionId, messages().requestFailed(errMsg), randomUUID());
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(params.sessionId);
1369
- server.markBackendLoaded(params.sessionId);
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);