zcode-acp-server 0.11.8 → 0.14.1

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 (120) hide show
  1. package/README.md +144 -32
  2. package/dist/backend/client.d.ts.map +1 -1
  3. package/dist/backend/client.js +10 -0
  4. package/dist/backend/client.js.map +1 -1
  5. package/dist/backend/listener.d.ts.map +1 -1
  6. package/dist/backend/listener.js +6 -5
  7. package/dist/backend/listener.js.map +1 -1
  8. package/dist/backend/resolve.d.ts.map +1 -1
  9. package/dist/backend/resolve.js +35 -1
  10. package/dist/backend/resolve.js.map +1 -1
  11. package/dist/bin/hub.d.ts +3 -3
  12. package/dist/bin/hub.d.ts.map +1 -1
  13. package/dist/bin/hub.js +22 -12
  14. package/dist/bin/hub.js.map +1 -1
  15. package/dist/bin/quota.d.ts +14 -6
  16. package/dist/bin/quota.d.ts.map +1 -1
  17. package/dist/bin/quota.js +27 -19
  18. package/dist/bin/quota.js.map +1 -1
  19. package/dist/cli.d.ts +38 -0
  20. package/dist/cli.d.ts.map +1 -0
  21. package/dist/cli.js +129 -0
  22. package/dist/cli.js.map +1 -0
  23. package/dist/config/runtime-model.d.ts +10 -10
  24. package/dist/config/runtime-model.js +10 -10
  25. package/dist/handlers/account.d.ts +1 -1
  26. package/dist/handlers/account.js +1 -1
  27. package/dist/handlers/dispatch.d.ts.map +1 -1
  28. package/dist/handlers/dispatch.js +42 -36
  29. package/dist/handlers/dispatch.js.map +1 -1
  30. package/dist/handlers/io.d.ts.map +1 -1
  31. package/dist/handlers/io.js +19 -13
  32. package/dist/handlers/io.js.map +1 -1
  33. package/dist/handlers/session.d.ts +58 -4
  34. package/dist/handlers/session.d.ts.map +1 -1
  35. package/dist/handlers/session.js +317 -87
  36. package/dist/handlers/session.js.map +1 -1
  37. package/dist/index.d.ts +4 -1
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +27 -8
  40. package/dist/index.js.map +1 -1
  41. package/dist/interaction/adapter.d.ts +1 -0
  42. package/dist/interaction/adapter.d.ts.map +1 -1
  43. package/dist/interaction/adapter.js +5 -1
  44. package/dist/interaction/adapter.js.map +1 -1
  45. package/dist/quota/color.d.ts +4 -4
  46. package/dist/quota/color.d.ts.map +1 -1
  47. package/dist/quota/color.js +7 -5
  48. package/dist/quota/color.js.map +1 -1
  49. package/dist/quota/combined.d.ts +1 -1
  50. package/dist/quota/combined.js +4 -4
  51. package/dist/quota/combined.js.map +1 -1
  52. package/dist/quota/format.d.ts +1 -1
  53. package/dist/quota/opencode-go/format.d.ts.map +1 -1
  54. package/dist/quota/opencode-go/format.js +5 -1
  55. package/dist/quota/opencode-go/format.js.map +1 -1
  56. package/dist/quota/opencode-go/index.d.ts +1 -1
  57. package/dist/quota/opencode-go/index.js +1 -1
  58. package/dist/quota/opencode-go/types.d.ts +1 -1
  59. package/dist/quota/opencode-go/types.js +1 -1
  60. package/dist/quota/rounding.d.ts +8 -0
  61. package/dist/quota/rounding.d.ts.map +1 -0
  62. package/dist/quota/rounding.js +10 -0
  63. package/dist/quota/rounding.js.map +1 -0
  64. package/dist/remote/config.d.ts +1 -1
  65. package/dist/remote/config.d.ts.map +1 -1
  66. package/dist/remote/config.js +1 -1
  67. package/dist/remote/config.js.map +1 -1
  68. package/dist/remote/endpoint.d.ts +1 -1
  69. package/dist/remote/endpoint.d.ts.map +1 -1
  70. package/dist/remote/endpoint.js +6 -1
  71. package/dist/remote/endpoint.js.map +1 -1
  72. package/dist/remote/hub-server.d.ts +1 -1
  73. package/dist/remote/hub-server.d.ts.map +1 -1
  74. package/dist/remote/hub-server.js +13 -10
  75. package/dist/remote/hub-server.js.map +1 -1
  76. package/dist/remote/session-rename-endpoint.d.ts +20 -0
  77. package/dist/remote/session-rename-endpoint.d.ts.map +1 -0
  78. package/dist/remote/session-rename-endpoint.js +108 -0
  79. package/dist/remote/session-rename-endpoint.js.map +1 -0
  80. package/dist/repl/App.d.ts +108 -0
  81. package/dist/repl/App.d.ts.map +1 -0
  82. package/dist/repl/App.js +706 -0
  83. package/dist/repl/App.js.map +1 -0
  84. package/dist/repl/history.d.ts +25 -0
  85. package/dist/repl/history.d.ts.map +1 -0
  86. package/dist/repl/history.js +73 -0
  87. package/dist/repl/history.js.map +1 -0
  88. package/dist/repl/input-buffer.d.ts +77 -0
  89. package/dist/repl/input-buffer.d.ts.map +1 -0
  90. package/dist/repl/input-buffer.js +174 -0
  91. package/dist/repl/input-buffer.js.map +1 -0
  92. package/dist/repl/model.d.ts +275 -0
  93. package/dist/repl/model.d.ts.map +1 -0
  94. package/dist/repl/model.js +596 -0
  95. package/dist/repl/model.js.map +1 -0
  96. package/dist/repl/mouse.d.ts +49 -0
  97. package/dist/repl/mouse.d.ts.map +1 -0
  98. package/dist/repl/mouse.js +165 -0
  99. package/dist/repl/mouse.js.map +1 -0
  100. package/dist/repl/run.d.ts +12 -0
  101. package/dist/repl/run.d.ts.map +1 -0
  102. package/dist/repl/run.js +902 -0
  103. package/dist/repl/run.js.map +1 -0
  104. package/dist/server.d.ts +19 -1
  105. package/dist/server.d.ts.map +1 -1
  106. package/dist/server.js +32 -4
  107. package/dist/server.js.map +1 -1
  108. package/dist/tasks-index.d.ts +7 -0
  109. package/dist/tasks-index.d.ts.map +1 -1
  110. package/dist/tasks-index.js +50 -9
  111. package/dist/tasks-index.js.map +1 -1
  112. package/dist/translators/tool-helpers.d.ts.map +1 -1
  113. package/dist/translators/tool-helpers.js +3 -0
  114. package/dist/translators/tool-helpers.js.map +1 -1
  115. package/docs/ARCHITECTURE.md +1 -1
  116. package/docs/BACKLOG.md +40 -21
  117. package/docs/REMOTE-CLIENTS.md +37 -8
  118. package/docs/TROUBLESHOOTING.md +1 -1
  119. package/docs/adr/0007-unified-cli-entry-and-bin-pruning.md +40 -0
  120. package/package.json +9 -4
@@ -14,7 +14,7 @@ import process from "node:process";
14
14
  import { randomUUID } from "node:crypto";
15
15
  import { RequestError } from "@agentclientprotocol/sdk";
16
16
  import { EventStreamListener, TurnMonitor } from "../backend/listener.js";
17
- import { buildModes, buildConfigOptions } from "../config/options.js";
17
+ import { buildModes, buildConfigOptions, formatModelValue, loadAllModels, } from "../config/options.js";
18
18
  import { emitInitialUsage } from "../config/model-cache.js";
19
19
  import { buildProviderRegistry } from "../config/provider-registry.js";
20
20
  import { buildResumeRuntimeModel } from "../config/runtime-model.js";
@@ -349,11 +349,11 @@ export async function resumeSession(server, params, cx) {
349
349
  // in this backend subprocess.
350
350
  const { zcodeSid, alreadyLive } = await resolveResumeTarget(server, acpSid);
351
351
  if (!alreadyLive) {
352
- // runtimeModel overlay: a resumed session may carry a stale/revoked model in
353
- // its history send fails with "历史模型不可用". Overlaying the current
354
- // enabled provider redirects the session onto a working model. The overlay
355
- // deliberately carries NO apiKey (the backend's schema rejects it; it resolves
356
- // auth from its own config/OAuth store).
352
+ // No runtimeModel pinning here: the session keeps its own persisted
353
+ // model (see resumePreservingModel an overlay is a FALLBACK only, when
354
+ // the faithful resume fails outright). The params deliberately carry NO
355
+ // apiKey either (the backend's schema rejects it; it resolves auth from
356
+ // its own config/OAuth store).
357
357
  const zcParams = {
358
358
  sessionId: zcodeSid,
359
359
  workspace: workspaceFor(cwd),
@@ -364,16 +364,15 @@ export async function resumeSession(server, params, cx) {
364
364
  if (params.mcpServers && params.mcpServers.length > 0) {
365
365
  zcParams.mcpServers = params.mcpServers;
366
366
  }
367
- const runtimeModel = buildResumeRuntimeModel();
368
- if (runtimeModel !== null)
369
- zcParams.runtimeModel = runtimeModel;
370
367
  // Push the provider registry BEFORE resume: a resumed session may carry a
371
368
  // third-party model in its history, and the backend needs the provider
372
369
  // registered to even process the resume turn.
373
370
  await syncProviderRegistry(server, cwd);
374
- const resumeResult = await resumeBackendSession(server, zcParams);
371
+ const resumeResult = await resumePreservingModel(server, zcParams);
375
372
  // The resume RPC succeeded — the session is now loaded in this backend.
376
373
  server.markBackendLoaded(acpSid);
374
+ // The session kept its own model — repair it only if it's no longer enabled.
375
+ await repairUnavailableModel(server, zcodeSid);
377
376
  // The backend's session record is the root authority: adopt its
378
377
  // workspace as the session root (heals any stale/polluted entry).
379
378
  const backendWs = workspaceFromResumeResult(resumeResult);
@@ -418,16 +417,15 @@ export async function loadSession(server, params, cx) {
418
417
  sessionId: zcodeSid,
419
418
  workspace: workspaceFor(cwd),
420
419
  };
421
- const runtimeModel = buildResumeRuntimeModel();
422
- if (runtimeModel !== null)
423
- zcParams.runtimeModel = runtimeModel;
424
420
  // Push the provider registry BEFORE resume: a loaded session may carry a
425
421
  // third-party model in its history, and the backend needs the provider
426
422
  // registered to process it.
427
423
  await syncProviderRegistry(server, cwd);
428
- const resumeResult = await resumeBackendSession(server, zcParams);
424
+ const resumeResult = await resumePreservingModel(server, zcParams);
429
425
  // The resume RPC succeeded — the session is now loaded in this backend.
430
426
  server.markBackendLoaded(acpSid);
427
+ // The session kept its own model — repair it only if it's no longer enabled.
428
+ await repairUnavailableModel(server, zcodeSid);
431
429
  // The backend's session record is the root authority: adopt its
432
430
  // workspace as the session root (heals any stale/polluted entry).
433
431
  const backendWs = workspaceFromResumeResult(resumeResult);
@@ -542,19 +540,50 @@ export async function prompt(server, params, cx, requestId) {
542
540
  // at end_turn, seed a provisional title from the prompt text (auto-title
543
541
  // stays authoritative — its set-once gate is the separate sessionTitles).
544
542
  server.markSessionActive(params.sessionId);
545
- if (server.sessionSummaries.get(params.sessionId)?.title === undefined) {
546
- const firstLine = text.trim().split(/\r\n|\r|\n/)[0] ?? "";
547
- if (firstLine) {
548
- server.touchSessionSummary(params.sessionId, firstLine.length > 60 ? firstLine.slice(0, 57) + "…" : firstLine);
549
- }
543
+ // Session title: set EXACTLY ONCE, here, from the first prompt of a
544
+ // freshly created session — immediately, not at end_turn (a preempted
545
+ // first turn ends "cancelled" and would never be titled; and the
546
+ // completing prompt must not steal the title). After this, no automatic
547
+ // path may change the title again: sessionTitles is set-once and a manual
548
+ // rename is the only later modifier. Resumed/loaded sessions are not
549
+ // title-eligible — their stored title was adopted on load, or left unset.
550
+ if (server.titleEligibleSessions.has(params.sessionId) &&
551
+ text &&
552
+ !server.sessionTitles.has(params.sessionId)) {
553
+ // Title = first non-empty line of the prompt, truncated to 80 chars.
554
+ // Multi-line prompts must not leak newlines into the session title.
555
+ // Split on any line break (\r\n, \n, \r) so all platforms are covered.
556
+ const title = text
557
+ .split(/\r\n|\r|\n/)
558
+ .map((l) => l.trim())
559
+ .find((l) => l.length > 0)
560
+ ?.slice(0, 80) ?? text.slice(0, 80);
561
+ server.sessionTitles.set(params.sessionId, title);
562
+ server.touchSessionSummary(params.sessionId, title);
563
+ const { updateSessionTitle } = await import("../tasks-index.js");
564
+ void updateSessionTitle(zcodeSid, title, text);
565
+ void sendSessionUpdate(cx, params.sessionId, {
566
+ sessionUpdate: "session_info_update",
567
+ title,
568
+ updatedAt: new Date().toISOString(),
569
+ });
550
570
  }
551
571
  // Out-of-band running indicator: clients that did not send this prompt
552
572
  // (re-attached mobile, second editor) learn the turn started here — the
553
- // session/load replayMeta only snapshots attach time. Best-effort: a dead
573
+ // session/load replayMeta only snapshots attach time. Emitted per attached
574
+ // alias (see server.sessionAliases) so a client holding this conversation
575
+ // under a different ACP id opens its live turn too. Best-effort: a dead
554
576
  // client must not fail the turn.
555
- const emitTurnState = (running) => cx
556
- .notify("$/zcode/turnState", { sessionId: params.sessionId, running })
557
- .catch((e) => log(`turnState notify failed: ${e instanceof Error ? e.message : String(e)}`));
577
+ const emitTurnState = async (running) => {
578
+ const results = await Promise.allSettled(server
579
+ .sessionAliases(params.sessionId)
580
+ .map((sid) => cx.notify("$/zcode/turnState", { sessionId: sid, running })));
581
+ for (const r of results) {
582
+ if (r.status === "rejected") {
583
+ log(`turnState notify failed: ${r.reason instanceof Error ? r.reason.message : String(r.reason)}`);
584
+ }
585
+ }
586
+ };
558
587
  await emitTurnState(true);
559
588
  const listener = new EventStreamListener(backend, zcodeSid);
560
589
  const monitor = new TurnMonitor(backend, zcodeSid, () => server.nextId());
@@ -626,7 +655,7 @@ export async function prompt(server, params, cx, requestId) {
626
655
  // reconcile the differ baseline so the retried turn's new messages
627
656
  // aren't treated as already-seen, surface a retry hint, then back off.
628
657
  if (turn.cancelled) {
629
- stopBackendTurn(server, zcodeSid);
658
+ stopBackendTurn(server, zcodeSid, turn.foregroundExecutionId);
630
659
  return { stopReason: "cancelled" };
631
660
  }
632
661
  differ.markSeen(await fetchMessages(server, zcodeSid));
@@ -635,6 +664,23 @@ export async function prompt(server, params, cx, requestId) {
635
664
  await sleep(backoffMs(attempt - 1));
636
665
  }
637
666
  const chunkMsgId = randomUUID();
667
+ // Drain gate: a recent cancel/preempt means the backend side needs
668
+ // settling before the send — see drainBackendAfterCancel.
669
+ const cancelledRecently = server.lastCancelledAt.get(zcodeSid) !== undefined &&
670
+ Date.now() - server.lastCancelledAt.get(zcodeSid) < DRAIN_WINDOW_MS;
671
+ if (cancelledRecently) {
672
+ const drained = await drainBackendAfterCancel(server, {
673
+ acpSid: params.sessionId,
674
+ zcodeSid,
675
+ turn,
676
+ listener,
677
+ monitor: new TurnMonitor(backend, zcodeSid, () => server.nextId()),
678
+ differ,
679
+ cx,
680
+ });
681
+ if (drained === "cancelled")
682
+ return { stopReason: "cancelled" };
683
+ }
638
684
  // Send the prompt, retrying while the backend reports it's still busy.
639
685
  // The backend's prompt lock is the single authoritative readiness signal:
640
686
  // a rejected send (code 1308 "prompt is running") means a previous turn
@@ -652,7 +698,7 @@ export async function prompt(server, params, cx, requestId) {
652
698
  let sendAttempt = 0;
653
699
  while (true) {
654
700
  if (turn.cancelled) {
655
- stopBackendTurn(server, zcodeSid);
701
+ stopBackendTurn(server, zcodeSid, turn.foregroundExecutionId);
656
702
  return { stopReason: "cancelled" };
657
703
  }
658
704
  sendAttempt++;
@@ -666,7 +712,7 @@ export async function prompt(server, params, cx, requestId) {
666
712
  if (expectBusy) {
667
713
  await sleep(SEND_RETRY_INTERVAL_MS);
668
714
  if (turn.cancelled) {
669
- stopBackendTurn(server, zcodeSid);
715
+ stopBackendTurn(server, zcodeSid, turn.foregroundExecutionId);
670
716
  return { stopReason: "cancelled" };
671
717
  }
672
718
  }
@@ -693,33 +739,15 @@ export async function prompt(server, params, cx, requestId) {
693
739
  }
694
740
  try {
695
741
  // Event-driven turn loop: translate events via EventTranslator + dispatch.
696
- const result = await runEventTurn(server, listener, monitor, differ, cx, params.sessionId, chunkMsgId, turn, preempted);
697
- // Session title: set once on the first end_turn, but ONLY for freshly
698
- // created sessions. Resumed/loaded sessions already carry a title from
699
- // their history and must not be overwritten by the first post-load
700
- // message. sessionTitles enforces set-once within a session;
701
- // titleEligibleSessions gates which sessions are titled at all.
702
- if (result.stopReason === "end_turn" &&
703
- server.titleEligibleSessions.has(params.sessionId) &&
704
- !server.sessionTitles.has(params.sessionId)) {
705
- // Title = first non-empty line of the prompt, truncated to 80 chars.
706
- // Multi-line prompts must not leak newlines into the session title.
707
- // Split on any line break (\r\n, \n, \r) so all platforms are covered.
708
- const title = text
709
- .split(/\r\n|\r|\n/)
710
- .map((l) => l.trim())
711
- .find((l) => l.length > 0)
712
- ?.slice(0, 80) ?? text.slice(0, 80);
713
- server.sessionTitles.set(params.sessionId, title);
714
- server.touchSessionSummary(params.sessionId, title);
715
- const { updateSessionTitle } = await import("../tasks-index.js");
716
- void updateSessionTitle(zcodeSid, title, text);
717
- await sendSessionUpdate(cx, params.sessionId, {
718
- sessionUpdate: "session_info_update",
719
- title,
720
- updatedAt: new Date().toISOString(),
721
- });
722
- }
742
+ // Arm the attribution gate also on a recent cancel: the abandoned turn
743
+ // is still finalising in the backend (session/stop is not honored
744
+ // verified 0.16.5), and its leftover deltas stream past the subscribe
745
+ // of this new prompt (see the gate comment in runEventTurn).
746
+ const gateArmed = preempted ||
747
+ Date.now() - (server.lastCancelledAt.get(zcodeSid) ?? 0) < CANCEL_RESIDUE_WINDOW_MS;
748
+ const result = await runEventTurn(server, listener, monitor, differ, cx, params.sessionId, chunkMsgId, turn, gateArmed);
749
+ // (Session title: already set once at the FIRST prompt, before the
750
+ // turn loop — nothing here may change it again.)
723
751
  // Auto-compact: if context usage exceeds the threshold, compact before
724
752
  // returning so the next prompt has room. Configured via
725
753
  // ZCODE_ACP_AUTO_COMPACT_THRESHOLD (absolute token count; 0/unset =
@@ -771,6 +799,13 @@ export async function prompt(server, params, cx, requestId) {
771
799
  await emitTurnState(stillBusy);
772
800
  }
773
801
  }
802
+ /** How long after a cancel a new prompt's attribution gate stays armed (the
803
+ * abandoned turn may still be streaming its finalisation into the backend). */
804
+ const CANCEL_RESIDUE_WINDOW_MS = 120_000;
805
+ /** How long after a cancel/preempt a new prompt still runs the drain gate
806
+ * (drainBackendAfterCancel) before sending — same bound as the drain wait
807
+ * itself, so the gate never waits twice its window. */
808
+ const DRAIN_WINDOW_MS = 90_000;
774
809
  /**
775
810
  * `session/set_config_option` → dispatch model/mode/thought and emit the
776
811
  * resulting config_option_update (+ current_mode_update for mode).
@@ -805,9 +840,11 @@ export async function setConfigOptionHandler(server, params, cx) {
805
840
  * on a session with no active turn, and on a turn already aborted), so firing
806
841
  * it eagerly is safe; the loop's `stopSent` guard prevents a second send.
807
842
  *
808
- * `turn.cancelled` is still set so the turn loop switches to its silent-drain
809
- * path (translate to detect turnDone, but discard every internal event no
810
- * text/tool/usage is pushed after the user stopped).
843
+ * `turn.cancelled` is still set so the turn loop returns at once (the backend
844
+ * ignores session/stop verified 0.16.5, the model stream runs to its natural
845
+ * end so waiting for a terminal event would hang the stop for the whole
846
+ * remaining generation). The loop's return resolves session/prompt with
847
+ * stopReason "cancelled" immediately.
811
848
  */
812
849
  export async function cancel(server, params) {
813
850
  const zcodeSid = server.resolveSid(params.sessionId);
@@ -817,13 +854,13 @@ export async function cancel(server, params) {
817
854
  // prior turn is still finalising, pendingTurns holds both it and any newer
818
855
  // prompt waiting on the backend's prompt lock; breaking on the first match
819
856
  // could leave the live one running. Each turn guards its own stopSent, so
820
- // multiple matching turns may each fire session/stop once — the backend
821
- // treats stop as idempotent, so the duplicate is harmless.
857
+ // multiple matching turns may each fire the stop pair once — the backend
858
+ // treats both as idempotent, so the duplicate is harmless.
822
859
  for (const [, turn] of server.pendingTurns) {
823
860
  if (turn.zcodeSid === zcodeSid) {
824
861
  turn.cancelled = true;
825
862
  if (!turn.stopSent) {
826
- stopBackendTurn(server, zcodeSid);
863
+ stopBackendTurn(server, zcodeSid, turn.foregroundExecutionId);
827
864
  turn.stopSent = true;
828
865
  }
829
866
  // Record cancel time so a prompt arriving in the backend's ~20s
@@ -857,13 +894,131 @@ class TurnFailedError extends Error {
857
894
  * backend's prompt lock releases when ITS finalisation completes — that,
858
895
  * not any bridge-side signal, is what the next prompt's send-retry waits on.
859
896
  */
860
- function stopBackendTurn(server, zcodeSid) {
897
+ function stopBackendTurn(server, zcodeSid, foregroundExecutionId) {
861
898
  try {
862
899
  server.ensureBackend().send("session/stop", { sessionId: zcodeSid });
863
900
  }
864
901
  catch (e) {
865
902
  log(` [stop] session/stop send failed (ignored): ${e instanceof Error ? e.message : String(e)}`);
866
903
  }
904
+ // The official stop path (this is what the desktop app's stop button uses —
905
+ // found in the app bundle): a v4 command that asks the runtime to stop the
906
+ // active foreground execution. session/stop alone is a no-op on the Aug-28
907
+ // app-server (its abort controller is never registered; backend log shows
908
+ // `hadActivePrompt: false`), while this kills the generation instantly —
909
+ // verified: turn.completed arrives the same instant the command lands.
910
+ // expectedForegroundExecutionId is passed when known — it is captured from
911
+ // the turn's own turn.started, so it names the execution that is foreground
912
+ // at cancel time, letting the backend guard against stopping a newer one.
913
+ // Omitted when unknown, targeting whatever is currently foreground.
914
+ try {
915
+ server.ensureBackend().send("v4/command", {
916
+ commandId: randomUUID(),
917
+ clientId: "zcode-acp-server",
918
+ sessionId: zcodeSid,
919
+ type: "stop",
920
+ payload: foregroundExecutionId
921
+ ? { expectedForegroundExecutionId: foregroundExecutionId }
922
+ : {},
923
+ issuedAt: Date.now(),
924
+ });
925
+ log(` [stop] v4/command stop sent for ${zcodeSid}`);
926
+ }
927
+ catch (e) {
928
+ log(` [stop] v4/command stop send failed (ignored): ${e instanceof Error ? e.message : String(e)}`);
929
+ }
930
+ }
931
+ /**
932
+ * Last-resort stop: tear down the session's resident runtime, killing any
933
+ * generation that survived the stop pair (session/stop + v4/command stop).
934
+ *
935
+ * The primary path is stopBackendTurn's v4/command stop — the official one —
936
+ * which kills the generation instantly. This close is the escalation when
937
+ * both stops are ignored (drain gate, 5s grace): `session/close` closes the
938
+ * runtime itself, which kills the generation immediately; the conversation
939
+ * is persisted in the backend's session store, so `session/resume` restores
940
+ * it (verified live: resume succeeds and the partial reply is in the
941
+ * history). Callers reload the session on next use — prompt()'s subscribe
942
+ * recovery and the drain gate's reload both handle the closed window.
943
+ */
944
+ function closeBackendSession(server, zcodeSid) {
945
+ try {
946
+ server.ensureBackend().send("session/close", { sessionId: zcodeSid });
947
+ log(` [stop] session/close fired for ${zcodeSid} (backend ignores session/stop)`);
948
+ }
949
+ catch (e) {
950
+ log(` [stop] session/close send failed (ignored): ${e instanceof Error ? e.message : String(e)}`);
951
+ }
952
+ }
953
+ /**
954
+ * Drain gate: a recent cancel/preempt means the backend side needs settling
955
+ * before the next send. Primary path: stopBackendTurn's v4/command stop kills
956
+ * the generation at once, so the first probe here already sees idle.
957
+ * Fallbacks: on a backend that honours session/stop we poll the projection
958
+ * until idle (a send that lands mid-generation is accepted as a steer whose
959
+ * input the backend silently DROPS when the old turn finishes); if the
960
+ * generation is STILL running after a grace period — both stops ignored —
961
+ * escalate to session/close, which tears down the runtime and kills it
962
+ * outright (the probe then fails into the reload branch). A visible chunk
963
+ * tells the user why the send waits. Bounded: on timeout send anyway — the
964
+ * steer-drop risk returns (the turn.steerQueued guard in runEventTurn reports
965
+ * it), but blocking the prompt forever is worse.
966
+ *
967
+ * Two post-drain repairs, both mirroring established patterns (prompt's
968
+ * eviction recovery / transient-retry re-baseline):
969
+ * - resubscribe: session/close killed the runtime this prompt subscribed to;
970
+ * the reload revives the session but not the event push, so re-arm it —
971
+ * without resubscribe the next turn runs deaf (no events at all, and stall
972
+ * recovery can't engage because it needs turn.started).
973
+ * - re-baseline: the abandoned turn committed messages to the session history
974
+ * while we waited (and close persisted its partial output); without markSeen
975
+ * the completion diff replays that residue as this turn's output.
976
+ *
977
+ * Returns "cancelled" when the turn was flagged cancelled during the drain
978
+ * (stop pair fired; caller resolves session/prompt at once).
979
+ */
980
+ export async function drainBackendAfterCancel(server, deps) {
981
+ const { acpSid, zcodeSid, turn, listener, monitor, differ, cx } = deps;
982
+ const DRAIN_TIMEOUT_MS = 90_000;
983
+ const DRAIN_POLL_MS = 1000;
984
+ const escalateAfterMs = deps.escalateAfterMs ?? 5_000;
985
+ const drainT0 = Date.now();
986
+ let noticed = false;
987
+ let escalated = false;
988
+ while (Date.now() - drainT0 < DRAIN_TIMEOUT_MS) {
989
+ if (turn.cancelled) {
990
+ stopBackendTurn(server, zcodeSid, turn.foregroundExecutionId);
991
+ return "cancelled";
992
+ }
993
+ const proj = await monitor.pollOnce();
994
+ if (!proj) {
995
+ // Probe failed — most likely the session was just closed by the
996
+ // escalation (close tears down the runtime). Reload it so the send
997
+ // below doesn't die on "session is not active", then re-arm the event
998
+ // push (see docstring).
999
+ try {
1000
+ await reloadBackendSession(server, acpSid, zcodeSid);
1001
+ await listener.resubscribe(() => server.nextId());
1002
+ }
1003
+ catch (e) {
1004
+ warn(`drain gate: reload after close failed: ${e instanceof Error ? e.message : String(e)}`);
1005
+ }
1006
+ break;
1007
+ }
1008
+ if (proj.status === "idle")
1009
+ break;
1010
+ if (proj.status === "running" && !escalated && Date.now() - drainT0 > escalateAfterMs) {
1011
+ escalated = true;
1012
+ closeBackendSession(server, zcodeSid);
1013
+ }
1014
+ if (!noticed) {
1015
+ noticed = true;
1016
+ await sendTextChunk(cx, acpSid, "[上一个回复仍在生成,等待结束后发送…]", randomUUID());
1017
+ }
1018
+ await sleep(DRAIN_POLL_MS);
1019
+ }
1020
+ differ.markSeen(await fetchMessages(server, zcodeSid));
1021
+ return "drained";
867
1022
  }
868
1023
  /**
869
1024
  * Serialize a per-session critical section. Each section awaits the previous
@@ -933,7 +1088,7 @@ export function preemptInFlightTurn(server, zcodeSid, selfRequestId) {
933
1088
  continue;
934
1089
  turn.cancelled = true; // signal the old turn to stop its retry loops
935
1090
  if (!turn.stopSent) {
936
- stopBackendTurn(server, zcodeSid);
1091
+ stopBackendTurn(server, zcodeSid, turn.foregroundExecutionId);
937
1092
  turn.stopSent = true;
938
1093
  }
939
1094
  // Record cancel time so the prompt()'s send-retry can use the recovery
@@ -1089,20 +1244,69 @@ async function resumeBackendSession(server, zcParams) {
1089
1244
  * Reload a session into the backend subprocess via `session/resume` — the
1090
1245
  * recovery path after the backend evicted the resident runtime (idle timeout
1091
1246
  * / LRU). Same param shape as session/load·resume (workspace from the
1092
- * recorded session cwd, runtimeModel overlay for stale history models).
1093
- * Marks the session backend-loaded on success.
1247
+ * recorded session cwd, default-model overlay only if a faithful resume
1248
+ * fails). Marks the session backend-loaded on success.
1094
1249
  */
1095
1250
  async function reloadBackendSession(server, acpSid, zcodeSid) {
1096
1251
  const zcParams = {
1097
1252
  sessionId: zcodeSid,
1098
1253
  workspace: workspaceFor(server.sessionCwds.get(acpSid) ?? process.cwd()),
1099
1254
  };
1100
- const runtimeModel = buildResumeRuntimeModel();
1101
- if (runtimeModel !== null)
1102
- zcParams.runtimeModel = runtimeModel;
1103
- await resumeBackendSession(server, zcParams);
1255
+ await resumePreservingModel(server, zcParams);
1104
1256
  server.markBackendLoaded(acpSid);
1105
1257
  }
1258
+ /**
1259
+ * Resume WITHOUT pinning a model, so the session keeps its own selection (the
1260
+ * backend persists it per session — sessions the user ran on GLM-5.3-Flash
1261
+ * used to be silently re-pinned to the first config model by an unconditional
1262
+ * runtimeModel overlay). The overlay is now a FALLBACK repair only: when the
1263
+ * faithful resume fails outright (history carrying a stale/revoked model),
1264
+ * retry once pinned to the first enabled provider's first model.
1265
+ */
1266
+ async function resumePreservingModel(server, zcParams) {
1267
+ try {
1268
+ return await resumeBackendSession(server, zcParams);
1269
+ }
1270
+ catch (err) {
1271
+ const overlay = buildResumeRuntimeModel();
1272
+ if (overlay === null)
1273
+ throw err;
1274
+ warn(`resume failed (${err instanceof Error ? err.message : String(err)}); retrying with default-model overlay`);
1275
+ return resumeBackendSession(server, { ...zcParams, runtimeModel: overlay });
1276
+ }
1277
+ }
1278
+ /**
1279
+ * After a faithful resume the session keeps its own last model; when that
1280
+ * model no longer belongs to an enabled provider in config.json (deleted or
1281
+ * revoked elsewhere), the first send would fail with the backend's
1282
+ * stale-history-model error. Repair proactively: switch to the default
1283
+ * (first enabled) model. Best-effort — a failed check leaves the model
1284
+ * untouched.
1285
+ */
1286
+ async function repairUnavailableModel(server, zcodeSid) {
1287
+ try {
1288
+ const backend = server.ensureBackend();
1289
+ const resp = await backend.request(server.nextId(), "session/read", { sessionId: zcodeSid }, 5000);
1290
+ if (resp.error)
1291
+ return;
1292
+ const settings = (resp.result ?? {}).settings;
1293
+ const cur = settings?.model?.current;
1294
+ if (!cur?.providerId || !cur.modelId)
1295
+ return;
1296
+ const available = loadAllModels();
1297
+ if (available.some((m) => m.providerId === cur.providerId && m.modelId === cur.modelId))
1298
+ return;
1299
+ const fallback = available[0];
1300
+ if (!fallback)
1301
+ return;
1302
+ warn(`session ${zcodeSid} model ${cur.providerId}/${cur.modelId} is no longer enabled; switching to ${fallback.providerId}/${fallback.modelId}`);
1303
+ const { applyModelSwitch } = await import("../config/runtime-model.js");
1304
+ await applyModelSwitch(server, zcodeSid, formatModelValue(fallback.providerId, fallback.modelId));
1305
+ }
1306
+ catch (e) {
1307
+ warn(`model repair check failed: ${e instanceof Error ? e.message : String(e)}`);
1308
+ }
1309
+ }
1106
1310
  /** Get or create the session-level ProjectionDiffer (persists across turns). */
1107
1311
  function getOrCreateDiffer(server, zcodeSid) {
1108
1312
  let d = server.differs.get(zcodeSid);
@@ -1123,7 +1327,7 @@ function getOrCreateDiffer(server, zcodeSid) {
1123
1327
  * handling (requestPermission / ExitPlanMode / AskUserQuestion) lands in
1124
1328
  * Commit 6 — for now they're polled to keep the inbox clear.
1125
1329
  */
1126
- async function runEventTurn(server, listener, monitor, differ, cx, acpSid, chunkMsgId, turn, preempted) {
1330
+ export async function runEventTurn(server, listener, monitor, differ, cx, acpSid, chunkMsgId, turn, gateArmed) {
1127
1331
  const backend = server.ensureBackend();
1128
1332
  const translator = new EventTranslator();
1129
1333
  differ.resetTurn();
@@ -1159,18 +1363,22 @@ async function runEventTurn(server, listener, monitor, differ, cx, acpSid, chunk
1159
1363
  lastProgress = Date.now();
1160
1364
  }
1161
1365
  if (turn.cancelled) {
1162
- // Cancel requested: ensure stop was fired (cancel()/preempt normally do
1163
- // this, but guard anyway). We do NOT silence subsequent events here — if
1164
- // the backend ignored the stop and kept producing, that content is still
1165
- // valuable to the user and should be displayed (the backend is the single
1166
- // source of truth within a session). Cross-turn contamination is handled
1167
- // separately by the turn-attribution gate below, which discards this
1168
- // turn's leftover events from the *next* turn's queue. The loop exits
1169
- // normally on the terminal event (translator.turnDone below).
1366
+ // Cancel requested: fire the stop (cancel()/preempt normally already
1367
+ // did — this is a guard) and END THE TURN AT ONCE. The backend's
1368
+ // session/stop is fire-and-forget and, as verified against app-server
1369
+ // 0.16.5, does NOT abort the in-flight model stream waiting for the
1370
+ // backend's terminal event used to keep the turn streaming for the
1371
+ // full remaining generation (10s+ past the stop) while the user stared
1372
+ // at a live spinner. Returning here resolves session/prompt with
1373
+ // stopReason "cancelled" immediately; the finally below unregisters
1374
+ // the turn listener, so events the backend still pushes are delivered
1375
+ // to no turn listener, and the next turn's turn-attribution gate
1376
+ // discards any residue that slipped into the queue meanwhile.
1170
1377
  if (!turn.stopSent) {
1171
- stopBackendTurn(server, turn.zcodeSid);
1378
+ stopBackendTurn(server, turn.zcodeSid, turn.foregroundExecutionId);
1172
1379
  turn.stopSent = true;
1173
1380
  }
1381
+ return { stopReason: "cancelled" };
1174
1382
  }
1175
1383
  const ev = await listener.pollEvent(500);
1176
1384
  if (ev === null) {
@@ -1226,7 +1434,7 @@ async function runEventTurn(server, listener, monitor, differ, cx, acpSid, chunk
1226
1434
  }
1227
1435
  else if (!emittedOutput) {
1228
1436
  // No text and no output → suspected failure.
1229
- stopBackendTurn(server, turn.zcodeSid);
1437
+ stopBackendTurn(server, turn.zcodeSid, turn.foregroundExecutionId);
1230
1438
  throw new RequestError(-32603, "turn produced no output");
1231
1439
  }
1232
1440
  }
@@ -1257,6 +1465,17 @@ async function runEventTurn(server, listener, monitor, differ, cx, acpSid, chunk
1257
1465
  continue;
1258
1466
  }
1259
1467
  lastProgress = Date.now();
1468
+ // Steer-swallow guard: a send accepted while the previous turn is still
1469
+ // generating is queued as steer input, which the backend silently DROPS
1470
+ // when that turn ends — no new turn ever starts (no turn.started), and
1471
+ // the attribution gate below would discard the steerQueued event like
1472
+ // any other residue, leaving this prompt to hang until the 120s watchdog
1473
+ // with the message lost. turn.steerQueued is definitive proof of the
1474
+ // swallow: report it at once so the user can resend immediately.
1475
+ if (ev.type === "turn.steerQueued" && !translator.turnStarted && gateArmed) {
1476
+ await sendTextChunk(cx, acpSid, "[消息被并入仍在生成的回合,将被丢弃,请重新发送]", chunkMsgId);
1477
+ return { stopReason: "max_turn_requests" };
1478
+ }
1260
1479
  // Turn-attribution gate: before this turn's own turn.started arrives, any
1261
1480
  // event is leftover from a prior turn (cancelled/preempted but still
1262
1481
  // finalising) that landed in the queue while send was retrying on a busy
@@ -1268,15 +1487,26 @@ async function runEventTurn(server, listener, monitor, differ, cx, acpSid, chunk
1268
1487
  // terminal event here would flip them and make THIS turn exit prematurely
1269
1488
  // at the first check after its own turn.started passes the gate.
1270
1489
  //
1271
- // The gate is armed ONLY when this send preempted another prompt. Without
1272
- // preemption no prior-turn residue can exist: the queue can only contain
1273
- // events of a backend-owned turn that was already active at send time
1274
- // (e.g. the main-branch turn auto-resumed after a compaction) this send
1275
- // was steered into it and produces NO new turn.started, so dropping those
1276
- // events would silently swallow the entire turn's output in the UI.
1277
- if (shouldDropEventForTurnAttribution(ev, translator.turnStarted, preempted)) {
1490
+ // Armed when this send preempted another prompt, OR when a cancel is
1491
+ // recent: the backend ignores session/stop, so an abandoned turn keeps
1492
+ // streaming until its natural end while the turn loop has already
1493
+ // returned a prompt sent in that window reaches subscribe while the
1494
+ // residue is still arriving. Backend serialisation bounds the exposure:
1495
+ // a send is accepted only after the prior turn released the lock, so the
1496
+ // residue can only arrive BEFORE this turn's turn.started. (A send that
1497
+ // lands mid-generation instead is a steer whose input is dropped — the
1498
+ // turn.steerQueued guard above reports that at once.)
1499
+ if (shouldDropEventForTurnAttribution(ev, translator.turnStarted, gateArmed)) {
1278
1500
  continue;
1279
1501
  }
1502
+ if (ev.type === "turn.started") {
1503
+ // Remember the runtime's foreground execution id: the v4/command stop
1504
+ // (see stopBackendTurn) targets it if the user cancels mid-turn.
1505
+ const fge = ev.payload
1506
+ ?.foregroundExecutionId;
1507
+ if (fge)
1508
+ turn.foregroundExecutionId = fge;
1509
+ }
1280
1510
  const internalEvents = translator.translate(ev);
1281
1511
  // Capture the turn-start timestamp for the thinking-phase hint above.
1282
1512
  // Done after translate so the flag flip on the turn.started event is
@@ -1342,7 +1572,7 @@ async function runEventTurn(server, listener, monitor, differ, cx, acpSid, chunk
1342
1572
  }
1343
1573
  if (translator.turnFailed) {
1344
1574
  // Best-effort stop in case the failed turn left a residual lock.
1345
- stopBackendTurn(server, turn.zcodeSid);
1575
+ stopBackendTurn(server, turn.zcodeSid, turn.foregroundExecutionId);
1346
1576
  // Throw a TurnFailedError carrying the structured error so the caller
1347
1577
  // (prompt's retry loop) can classify transient vs fatal. The error
1348
1578
  // message is formatted for display when it ultimately reaches the user.
@@ -1393,7 +1623,7 @@ async function runEventTurn(server, listener, monitor, differ, cx, acpSid, chunk
1393
1623
  }
1394
1624
  }
1395
1625
  // 120s no progress: abandon.
1396
- stopBackendTurn(server, turn.zcodeSid);
1626
+ stopBackendTurn(server, turn.zcodeSid, turn.foregroundExecutionId);
1397
1627
  return { stopReason: "max_turn_requests" };
1398
1628
  }
1399
1629
  /**