zcode-acp-server 0.11.9 → 0.14.2

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 (113) 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 +5 -4
  6. package/dist/backend/listener.d.ts.map +1 -1
  7. package/dist/backend/listener.js +11 -9
  8. package/dist/backend/listener.js.map +1 -1
  9. package/dist/backend/resolve.d.ts.map +1 -1
  10. package/dist/backend/resolve.js +35 -1
  11. package/dist/backend/resolve.js.map +1 -1
  12. package/dist/bin/hub.d.ts +3 -3
  13. package/dist/bin/hub.d.ts.map +1 -1
  14. package/dist/bin/hub.js +22 -12
  15. package/dist/bin/hub.js.map +1 -1
  16. package/dist/bin/quota.d.ts +14 -6
  17. package/dist/bin/quota.d.ts.map +1 -1
  18. package/dist/bin/quota.js +27 -19
  19. package/dist/bin/quota.js.map +1 -1
  20. package/dist/cli.d.ts +38 -0
  21. package/dist/cli.d.ts.map +1 -0
  22. package/dist/cli.js +129 -0
  23. package/dist/cli.js.map +1 -0
  24. package/dist/config/runtime-model.d.ts +10 -10
  25. package/dist/config/runtime-model.js +10 -10
  26. package/dist/handlers/account.d.ts +1 -1
  27. package/dist/handlers/account.js +1 -1
  28. package/dist/handlers/dispatch.d.ts.map +1 -1
  29. package/dist/handlers/dispatch.js +42 -36
  30. package/dist/handlers/dispatch.js.map +1 -1
  31. package/dist/handlers/io.d.ts.map +1 -1
  32. package/dist/handlers/io.js +19 -13
  33. package/dist/handlers/io.js.map +1 -1
  34. package/dist/handlers/session.d.ts +58 -4
  35. package/dist/handlers/session.d.ts.map +1 -1
  36. package/dist/handlers/session.js +417 -72
  37. package/dist/handlers/session.js.map +1 -1
  38. package/dist/index.d.ts +4 -1
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +27 -8
  41. package/dist/index.js.map +1 -1
  42. package/dist/interaction/adapter.d.ts +1 -0
  43. package/dist/interaction/adapter.d.ts.map +1 -1
  44. package/dist/interaction/adapter.js +5 -1
  45. package/dist/interaction/adapter.js.map +1 -1
  46. package/dist/quota/color.d.ts +4 -4
  47. package/dist/quota/color.d.ts.map +1 -1
  48. package/dist/quota/color.js +7 -5
  49. package/dist/quota/color.js.map +1 -1
  50. package/dist/quota/combined.d.ts +1 -1
  51. package/dist/quota/combined.js +4 -4
  52. package/dist/quota/combined.js.map +1 -1
  53. package/dist/quota/format.d.ts +1 -1
  54. package/dist/quota/opencode-go/format.d.ts.map +1 -1
  55. package/dist/quota/opencode-go/format.js +5 -1
  56. package/dist/quota/opencode-go/format.js.map +1 -1
  57. package/dist/quota/opencode-go/index.d.ts +1 -1
  58. package/dist/quota/opencode-go/index.js +1 -1
  59. package/dist/quota/opencode-go/types.d.ts +1 -1
  60. package/dist/quota/opencode-go/types.js +1 -1
  61. package/dist/quota/rounding.d.ts +8 -0
  62. package/dist/quota/rounding.d.ts.map +1 -0
  63. package/dist/quota/rounding.js +10 -0
  64. package/dist/quota/rounding.js.map +1 -0
  65. package/dist/remote/config.d.ts +1 -1
  66. package/dist/remote/config.d.ts.map +1 -1
  67. package/dist/remote/config.js +1 -1
  68. package/dist/remote/config.js.map +1 -1
  69. package/dist/remote/endpoint.d.ts +1 -1
  70. package/dist/remote/endpoint.js +1 -1
  71. package/dist/remote/hub-server.d.ts +1 -1
  72. package/dist/remote/hub-server.js +1 -1
  73. package/dist/repl/App.d.ts +108 -0
  74. package/dist/repl/App.d.ts.map +1 -0
  75. package/dist/repl/App.js +706 -0
  76. package/dist/repl/App.js.map +1 -0
  77. package/dist/repl/history.d.ts +25 -0
  78. package/dist/repl/history.d.ts.map +1 -0
  79. package/dist/repl/history.js +73 -0
  80. package/dist/repl/history.js.map +1 -0
  81. package/dist/repl/input-buffer.d.ts +77 -0
  82. package/dist/repl/input-buffer.d.ts.map +1 -0
  83. package/dist/repl/input-buffer.js +174 -0
  84. package/dist/repl/input-buffer.js.map +1 -0
  85. package/dist/repl/model.d.ts +275 -0
  86. package/dist/repl/model.d.ts.map +1 -0
  87. package/dist/repl/model.js +596 -0
  88. package/dist/repl/model.js.map +1 -0
  89. package/dist/repl/mouse.d.ts +49 -0
  90. package/dist/repl/mouse.d.ts.map +1 -0
  91. package/dist/repl/mouse.js +165 -0
  92. package/dist/repl/mouse.js.map +1 -0
  93. package/dist/repl/run.d.ts +12 -0
  94. package/dist/repl/run.d.ts.map +1 -0
  95. package/dist/repl/run.js +902 -0
  96. package/dist/repl/run.js.map +1 -0
  97. package/dist/server.d.ts +18 -0
  98. package/dist/server.d.ts.map +1 -1
  99. package/dist/server.js +31 -3
  100. package/dist/server.js.map +1 -1
  101. package/dist/translators/event-translator.d.ts +2 -0
  102. package/dist/translators/event-translator.d.ts.map +1 -1
  103. package/dist/translators/event-translator.js +47 -38
  104. package/dist/translators/event-translator.js.map +1 -1
  105. package/dist/translators/tool-helpers.d.ts.map +1 -1
  106. package/dist/translators/tool-helpers.js +11 -0
  107. package/dist/translators/tool-helpers.js.map +1 -1
  108. package/docs/ARCHITECTURE.md +13 -2
  109. package/docs/BACKLOG.md +40 -21
  110. package/docs/REMOTE-CLIENTS.md +2 -2
  111. package/docs/TROUBLESHOOTING.md +1 -1
  112. package/docs/adr/0007-unified-cli-entry-and-bin-pruning.md +40 -0
  113. 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);
@@ -572,11 +570,20 @@ export async function prompt(server, params, cx, requestId) {
572
570
  }
573
571
  // Out-of-band running indicator: clients that did not send this prompt
574
572
  // (re-attached mobile, second editor) learn the turn started here — the
575
- // 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
576
576
  // client must not fail the turn.
577
- const emitTurnState = (running) => cx
578
- .notify("$/zcode/turnState", { sessionId: params.sessionId, running })
579
- .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
+ };
580
587
  await emitTurnState(true);
581
588
  const listener = new EventStreamListener(backend, zcodeSid);
582
589
  const monitor = new TurnMonitor(backend, zcodeSid, () => server.nextId());
@@ -648,7 +655,7 @@ export async function prompt(server, params, cx, requestId) {
648
655
  // reconcile the differ baseline so the retried turn's new messages
649
656
  // aren't treated as already-seen, surface a retry hint, then back off.
650
657
  if (turn.cancelled) {
651
- stopBackendTurn(server, zcodeSid);
658
+ stopBackendTurn(server, zcodeSid, turn.foregroundExecutionId);
652
659
  return { stopReason: "cancelled" };
653
660
  }
654
661
  differ.markSeen(await fetchMessages(server, zcodeSid));
@@ -657,6 +664,23 @@ export async function prompt(server, params, cx, requestId) {
657
664
  await sleep(backoffMs(attempt - 1));
658
665
  }
659
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
+ }
660
684
  // Send the prompt, retrying while the backend reports it's still busy.
661
685
  // The backend's prompt lock is the single authoritative readiness signal:
662
686
  // a rejected send (code 1308 "prompt is running") means a previous turn
@@ -674,7 +698,7 @@ export async function prompt(server, params, cx, requestId) {
674
698
  let sendAttempt = 0;
675
699
  while (true) {
676
700
  if (turn.cancelled) {
677
- stopBackendTurn(server, zcodeSid);
701
+ stopBackendTurn(server, zcodeSid, turn.foregroundExecutionId);
678
702
  return { stopReason: "cancelled" };
679
703
  }
680
704
  sendAttempt++;
@@ -688,7 +712,7 @@ export async function prompt(server, params, cx, requestId) {
688
712
  if (expectBusy) {
689
713
  await sleep(SEND_RETRY_INTERVAL_MS);
690
714
  if (turn.cancelled) {
691
- stopBackendTurn(server, zcodeSid);
715
+ stopBackendTurn(server, zcodeSid, turn.foregroundExecutionId);
692
716
  return { stopReason: "cancelled" };
693
717
  }
694
718
  }
@@ -715,7 +739,13 @@ export async function prompt(server, params, cx, requestId) {
715
739
  }
716
740
  try {
717
741
  // Event-driven turn loop: translate events via EventTranslator + dispatch.
718
- const result = await runEventTurn(server, listener, monitor, differ, cx, params.sessionId, chunkMsgId, turn, preempted);
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);
719
749
  // (Session title: already set once at the FIRST prompt, before the
720
750
  // turn loop — nothing here may change it again.)
721
751
  // Auto-compact: if context usage exceeds the threshold, compact before
@@ -736,12 +766,14 @@ export async function prompt(server, params, cx, requestId) {
736
766
  // Only a transient TurnFailedError is retryable; everything else (send
737
767
  // failures, non-transient turn errors, exhausted retries, cancellation)
738
768
  // propagates to the caller.
739
- if (e instanceof TurnFailedError &&
740
- attempt < MAX_TURN_ATTEMPTS &&
741
- !turn.cancelled &&
742
- isTransientTurnError(e.turnError)) {
743
- lastTurnError = e.turnError;
744
- continue;
769
+ if (e instanceof TurnFailedError) {
770
+ const transient = isTransientTurnError(e.turnError);
771
+ if (attempt < MAX_TURN_ATTEMPTS && !turn.cancelled && transient) {
772
+ lastTurnError = e.turnError;
773
+ continue;
774
+ }
775
+ if (!transient)
776
+ throw turnFailureRequestError(e);
745
777
  }
746
778
  throw e;
747
779
  }
@@ -769,6 +801,13 @@ export async function prompt(server, params, cx, requestId) {
769
801
  await emitTurnState(stillBusy);
770
802
  }
771
803
  }
804
+ /** How long after a cancel a new prompt's attribution gate stays armed (the
805
+ * abandoned turn may still be streaming its finalisation into the backend). */
806
+ const CANCEL_RESIDUE_WINDOW_MS = 120_000;
807
+ /** How long after a cancel/preempt a new prompt still runs the drain gate
808
+ * (drainBackendAfterCancel) before sending — same bound as the drain wait
809
+ * itself, so the gate never waits twice its window. */
810
+ const DRAIN_WINDOW_MS = 90_000;
772
811
  /**
773
812
  * `session/set_config_option` → dispatch model/mode/thought and emit the
774
813
  * resulting config_option_update (+ current_mode_update for mode).
@@ -803,9 +842,11 @@ export async function setConfigOptionHandler(server, params, cx) {
803
842
  * on a session with no active turn, and on a turn already aborted), so firing
804
843
  * it eagerly is safe; the loop's `stopSent` guard prevents a second send.
805
844
  *
806
- * `turn.cancelled` is still set so the turn loop switches to its silent-drain
807
- * path (translate to detect turnDone, but discard every internal event no
808
- * text/tool/usage is pushed after the user stopped).
845
+ * `turn.cancelled` is still set so the turn loop returns at once (the backend
846
+ * ignores session/stop verified 0.16.5, the model stream runs to its natural
847
+ * end so waiting for a terminal event would hang the stop for the whole
848
+ * remaining generation). The loop's return resolves session/prompt with
849
+ * stopReason "cancelled" immediately.
809
850
  */
810
851
  export async function cancel(server, params) {
811
852
  const zcodeSid = server.resolveSid(params.sessionId);
@@ -815,13 +856,13 @@ export async function cancel(server, params) {
815
856
  // prior turn is still finalising, pendingTurns holds both it and any newer
816
857
  // prompt waiting on the backend's prompt lock; breaking on the first match
817
858
  // could leave the live one running. Each turn guards its own stopSent, so
818
- // multiple matching turns may each fire session/stop once — the backend
819
- // treats stop as idempotent, so the duplicate is harmless.
859
+ // multiple matching turns may each fire the stop pair once — the backend
860
+ // treats both as idempotent, so the duplicate is harmless.
820
861
  for (const [, turn] of server.pendingTurns) {
821
862
  if (turn.zcodeSid === zcodeSid) {
822
863
  turn.cancelled = true;
823
864
  if (!turn.stopSent) {
824
- stopBackendTurn(server, zcodeSid);
865
+ stopBackendTurn(server, zcodeSid, turn.foregroundExecutionId);
825
866
  turn.stopSent = true;
826
867
  }
827
868
  // Record cancel time so a prompt arriving in the backend's ~20s
@@ -845,6 +886,55 @@ class TurnFailedError extends Error {
845
886
  this.turnError = turnError;
846
887
  }
847
888
  }
889
+ function asRecord(value) {
890
+ return value !== null && typeof value === "object" && !Array.isArray(value)
891
+ ? value
892
+ : null;
893
+ }
894
+ function finiteNumber(value) {
895
+ if (typeof value === "number" && Number.isFinite(value))
896
+ return value;
897
+ if (typeof value !== "string" || value.trim() === "")
898
+ return undefined;
899
+ const parsed = Number(value);
900
+ return Number.isFinite(parsed) ? parsed : undefined;
901
+ }
902
+ /** Build a small, safe ACP error payload from a backend turn failure. */
903
+ function acpTurnFailureData(turnError) {
904
+ const cause = asRecord(turnError["cause"]) ?? turnError;
905
+ const context = asRecord(cause["context"]);
906
+ const summary = asRecord(context?.["responseBodySummary"]);
907
+ const headers = asRecord(summary?.["responseHeaders"]);
908
+ const retryAfterMs = finiteNumber(context?.["retryAfterMs"]) ??
909
+ (() => {
910
+ const seconds = finiteNumber(headers?.["retry-after"]);
911
+ return seconds === undefined ? undefined : seconds * 1000;
912
+ })();
913
+ const data = { type: "zcode_turn_failed" };
914
+ const code = cause["code"] ?? cause["type"];
915
+ if (typeof code === "string" && code.trim())
916
+ data.code = code;
917
+ const reason = context?.["reason"];
918
+ if (typeof reason === "string" && reason.trim())
919
+ data.reason = reason;
920
+ const statusCode = finiteNumber(context?.["statusCode"] ?? context?.["responseStatus"]);
921
+ if (statusCode !== undefined)
922
+ data.statusCode = statusCode;
923
+ const providerCode = context?.["providerCode"];
924
+ if (typeof providerCode === "string" && providerCode.trim())
925
+ data.providerCode = providerCode;
926
+ const retryable = context?.["retryable"];
927
+ if (typeof retryable === "boolean")
928
+ data.retryable = retryable;
929
+ if (retryAfterMs !== undefined)
930
+ data.retryAfterMs = retryAfterMs;
931
+ return data;
932
+ }
933
+ function turnFailureRequestError(error) {
934
+ const cause = asRecord(error.turnError["cause"]);
935
+ const detail = formatTurnError(cause ?? error.turnError) || error.message;
936
+ return new RequestError(-32603, `ZCode turn failed: ${detail}`, acpTurnFailureData(error.turnError));
937
+ }
848
938
  /**
849
939
  * Fire-and-forget `session/stop` to the backend. Mirrors Python's
850
940
  * `_cancel_backend_turn`: send stop with an id (some backends route by id
@@ -855,13 +945,131 @@ class TurnFailedError extends Error {
855
945
  * backend's prompt lock releases when ITS finalisation completes — that,
856
946
  * not any bridge-side signal, is what the next prompt's send-retry waits on.
857
947
  */
858
- function stopBackendTurn(server, zcodeSid) {
948
+ function stopBackendTurn(server, zcodeSid, foregroundExecutionId) {
859
949
  try {
860
950
  server.ensureBackend().send("session/stop", { sessionId: zcodeSid });
861
951
  }
862
952
  catch (e) {
863
953
  log(` [stop] session/stop send failed (ignored): ${e instanceof Error ? e.message : String(e)}`);
864
954
  }
955
+ // The official stop path (this is what the desktop app's stop button uses —
956
+ // found in the app bundle): a v4 command that asks the runtime to stop the
957
+ // active foreground execution. session/stop alone is a no-op on the Aug-28
958
+ // app-server (its abort controller is never registered; backend log shows
959
+ // `hadActivePrompt: false`), while this kills the generation instantly —
960
+ // verified: turn.completed arrives the same instant the command lands.
961
+ // expectedForegroundExecutionId is passed when known — it is captured from
962
+ // the turn's own turn.started, so it names the execution that is foreground
963
+ // at cancel time, letting the backend guard against stopping a newer one.
964
+ // Omitted when unknown, targeting whatever is currently foreground.
965
+ try {
966
+ server.ensureBackend().send("v4/command", {
967
+ commandId: randomUUID(),
968
+ clientId: "zcode-acp-server",
969
+ sessionId: zcodeSid,
970
+ type: "stop",
971
+ payload: foregroundExecutionId
972
+ ? { expectedForegroundExecutionId: foregroundExecutionId }
973
+ : {},
974
+ issuedAt: Date.now(),
975
+ });
976
+ log(` [stop] v4/command stop sent for ${zcodeSid}`);
977
+ }
978
+ catch (e) {
979
+ log(` [stop] v4/command stop send failed (ignored): ${e instanceof Error ? e.message : String(e)}`);
980
+ }
981
+ }
982
+ /**
983
+ * Last-resort stop: tear down the session's resident runtime, killing any
984
+ * generation that survived the stop pair (session/stop + v4/command stop).
985
+ *
986
+ * The primary path is stopBackendTurn's v4/command stop — the official one —
987
+ * which kills the generation instantly. This close is the escalation when
988
+ * both stops are ignored (drain gate, 5s grace): `session/close` closes the
989
+ * runtime itself, which kills the generation immediately; the conversation
990
+ * is persisted in the backend's session store, so `session/resume` restores
991
+ * it (verified live: resume succeeds and the partial reply is in the
992
+ * history). Callers reload the session on next use — prompt()'s subscribe
993
+ * recovery and the drain gate's reload both handle the closed window.
994
+ */
995
+ function closeBackendSession(server, zcodeSid) {
996
+ try {
997
+ server.ensureBackend().send("session/close", { sessionId: zcodeSid });
998
+ log(` [stop] session/close fired for ${zcodeSid} (backend ignores session/stop)`);
999
+ }
1000
+ catch (e) {
1001
+ log(` [stop] session/close send failed (ignored): ${e instanceof Error ? e.message : String(e)}`);
1002
+ }
1003
+ }
1004
+ /**
1005
+ * Drain gate: a recent cancel/preempt means the backend side needs settling
1006
+ * before the next send. Primary path: stopBackendTurn's v4/command stop kills
1007
+ * the generation at once, so the first probe here already sees idle.
1008
+ * Fallbacks: on a backend that honours session/stop we poll the projection
1009
+ * until idle (a send that lands mid-generation is accepted as a steer whose
1010
+ * input the backend silently DROPS when the old turn finishes); if the
1011
+ * generation is STILL running after a grace period — both stops ignored —
1012
+ * escalate to session/close, which tears down the runtime and kills it
1013
+ * outright (the probe then fails into the reload branch). A visible chunk
1014
+ * tells the user why the send waits. Bounded: on timeout send anyway — the
1015
+ * steer-drop risk returns (the turn.steerQueued guard in runEventTurn reports
1016
+ * it), but blocking the prompt forever is worse.
1017
+ *
1018
+ * Two post-drain repairs, both mirroring established patterns (prompt's
1019
+ * eviction recovery / transient-retry re-baseline):
1020
+ * - resubscribe: session/close killed the runtime this prompt subscribed to;
1021
+ * the reload revives the session but not the event push, so re-arm it —
1022
+ * without resubscribe the next turn runs deaf (no events at all, and stall
1023
+ * recovery can't engage because it needs turn.started).
1024
+ * - re-baseline: the abandoned turn committed messages to the session history
1025
+ * while we waited (and close persisted its partial output); without markSeen
1026
+ * the completion diff replays that residue as this turn's output.
1027
+ *
1028
+ * Returns "cancelled" when the turn was flagged cancelled during the drain
1029
+ * (stop pair fired; caller resolves session/prompt at once).
1030
+ */
1031
+ export async function drainBackendAfterCancel(server, deps) {
1032
+ const { acpSid, zcodeSid, turn, listener, monitor, differ, cx } = deps;
1033
+ const DRAIN_TIMEOUT_MS = 90_000;
1034
+ const DRAIN_POLL_MS = 1000;
1035
+ const escalateAfterMs = deps.escalateAfterMs ?? 5_000;
1036
+ const drainT0 = Date.now();
1037
+ let noticed = false;
1038
+ let escalated = false;
1039
+ while (Date.now() - drainT0 < DRAIN_TIMEOUT_MS) {
1040
+ if (turn.cancelled) {
1041
+ stopBackendTurn(server, zcodeSid, turn.foregroundExecutionId);
1042
+ return "cancelled";
1043
+ }
1044
+ const proj = await monitor.pollOnce();
1045
+ if (!proj) {
1046
+ // Probe failed — most likely the session was just closed by the
1047
+ // escalation (close tears down the runtime). Reload it so the send
1048
+ // below doesn't die on "session is not active", then re-arm the event
1049
+ // push (see docstring).
1050
+ try {
1051
+ await reloadBackendSession(server, acpSid, zcodeSid);
1052
+ await listener.resubscribe(() => server.nextId());
1053
+ }
1054
+ catch (e) {
1055
+ warn(`drain gate: reload after close failed: ${e instanceof Error ? e.message : String(e)}`);
1056
+ }
1057
+ break;
1058
+ }
1059
+ if (proj.status === "idle")
1060
+ break;
1061
+ if (proj.status === "running" && !escalated && Date.now() - drainT0 > escalateAfterMs) {
1062
+ escalated = true;
1063
+ closeBackendSession(server, zcodeSid);
1064
+ }
1065
+ if (!noticed) {
1066
+ noticed = true;
1067
+ await sendTextChunk(cx, acpSid, "[上一个回复仍在生成,等待结束后发送…]", randomUUID());
1068
+ }
1069
+ await sleep(DRAIN_POLL_MS);
1070
+ }
1071
+ differ.markSeen(await fetchMessages(server, zcodeSid));
1072
+ return "drained";
865
1073
  }
866
1074
  /**
867
1075
  * Serialize a per-session critical section. Each section awaits the previous
@@ -931,7 +1139,7 @@ export function preemptInFlightTurn(server, zcodeSid, selfRequestId) {
931
1139
  continue;
932
1140
  turn.cancelled = true; // signal the old turn to stop its retry loops
933
1141
  if (!turn.stopSent) {
934
- stopBackendTurn(server, zcodeSid);
1142
+ stopBackendTurn(server, zcodeSid, turn.foregroundExecutionId);
935
1143
  turn.stopSent = true;
936
1144
  }
937
1145
  // Record cancel time so the prompt()'s send-retry can use the recovery
@@ -1087,20 +1295,69 @@ async function resumeBackendSession(server, zcParams) {
1087
1295
  * Reload a session into the backend subprocess via `session/resume` — the
1088
1296
  * recovery path after the backend evicted the resident runtime (idle timeout
1089
1297
  * / LRU). Same param shape as session/load·resume (workspace from the
1090
- * recorded session cwd, runtimeModel overlay for stale history models).
1091
- * Marks the session backend-loaded on success.
1298
+ * recorded session cwd, default-model overlay only if a faithful resume
1299
+ * fails). Marks the session backend-loaded on success.
1092
1300
  */
1093
1301
  async function reloadBackendSession(server, acpSid, zcodeSid) {
1094
1302
  const zcParams = {
1095
1303
  sessionId: zcodeSid,
1096
1304
  workspace: workspaceFor(server.sessionCwds.get(acpSid) ?? process.cwd()),
1097
1305
  };
1098
- const runtimeModel = buildResumeRuntimeModel();
1099
- if (runtimeModel !== null)
1100
- zcParams.runtimeModel = runtimeModel;
1101
- await resumeBackendSession(server, zcParams);
1306
+ await resumePreservingModel(server, zcParams);
1102
1307
  server.markBackendLoaded(acpSid);
1103
1308
  }
1309
+ /**
1310
+ * Resume WITHOUT pinning a model, so the session keeps its own selection (the
1311
+ * backend persists it per session — sessions the user ran on GLM-5.3-Flash
1312
+ * used to be silently re-pinned to the first config model by an unconditional
1313
+ * runtimeModel overlay). The overlay is now a FALLBACK repair only: when the
1314
+ * faithful resume fails outright (history carrying a stale/revoked model),
1315
+ * retry once pinned to the first enabled provider's first model.
1316
+ */
1317
+ async function resumePreservingModel(server, zcParams) {
1318
+ try {
1319
+ return await resumeBackendSession(server, zcParams);
1320
+ }
1321
+ catch (err) {
1322
+ const overlay = buildResumeRuntimeModel();
1323
+ if (overlay === null)
1324
+ throw err;
1325
+ warn(`resume failed (${err instanceof Error ? err.message : String(err)}); retrying with default-model overlay`);
1326
+ return resumeBackendSession(server, { ...zcParams, runtimeModel: overlay });
1327
+ }
1328
+ }
1329
+ /**
1330
+ * After a faithful resume the session keeps its own last model; when that
1331
+ * model no longer belongs to an enabled provider in config.json (deleted or
1332
+ * revoked elsewhere), the first send would fail with the backend's
1333
+ * stale-history-model error. Repair proactively: switch to the default
1334
+ * (first enabled) model. Best-effort — a failed check leaves the model
1335
+ * untouched.
1336
+ */
1337
+ async function repairUnavailableModel(server, zcodeSid) {
1338
+ try {
1339
+ const backend = server.ensureBackend();
1340
+ const resp = await backend.request(server.nextId(), "session/read", { sessionId: zcodeSid }, 5000);
1341
+ if (resp.error)
1342
+ return;
1343
+ const settings = (resp.result ?? {}).settings;
1344
+ const cur = settings?.model?.current;
1345
+ if (!cur?.providerId || !cur.modelId)
1346
+ return;
1347
+ const available = loadAllModels();
1348
+ if (available.some((m) => m.providerId === cur.providerId && m.modelId === cur.modelId))
1349
+ return;
1350
+ const fallback = available[0];
1351
+ if (!fallback)
1352
+ return;
1353
+ warn(`session ${zcodeSid} model ${cur.providerId}/${cur.modelId} is no longer enabled; switching to ${fallback.providerId}/${fallback.modelId}`);
1354
+ const { applyModelSwitch } = await import("../config/runtime-model.js");
1355
+ await applyModelSwitch(server, zcodeSid, formatModelValue(fallback.providerId, fallback.modelId));
1356
+ }
1357
+ catch (e) {
1358
+ warn(`model repair check failed: ${e instanceof Error ? e.message : String(e)}`);
1359
+ }
1360
+ }
1104
1361
  /** Get or create the session-level ProjectionDiffer (persists across turns). */
1105
1362
  function getOrCreateDiffer(server, zcodeSid) {
1106
1363
  let d = server.differs.get(zcodeSid);
@@ -1121,12 +1378,13 @@ function getOrCreateDiffer(server, zcodeSid) {
1121
1378
  * handling (requestPermission / ExitPlanMode / AskUserQuestion) lands in
1122
1379
  * Commit 6 — for now they're polled to keep the inbox clear.
1123
1380
  */
1124
- async function runEventTurn(server, listener, monitor, differ, cx, acpSid, chunkMsgId, turn, preempted) {
1381
+ export async function runEventTurn(server, listener, monitor, differ, cx, acpSid, chunkMsgId, turn, gateArmed) {
1125
1382
  const backend = server.ensureBackend();
1126
1383
  const translator = new EventTranslator();
1127
1384
  differ.resetTurn();
1128
1385
  const NO_PROGRESS_MS = 120_000;
1129
- let lastProgress = Date.now();
1386
+ let lastProtocolProgressAt = Date.now();
1387
+ let nextNoProgressDecisionAt = lastProtocolProgressAt + NO_PROGRESS_MS;
1130
1388
  let lastStallCheck = Date.now();
1131
1389
  let emittedText = false;
1132
1390
  let emittedOutput = false;
@@ -1140,7 +1398,42 @@ async function runEventTurn(server, listener, monitor, differ, cx, acpSid, chunk
1140
1398
  let turnStartedAt = null;
1141
1399
  let thinkingHintSent = false;
1142
1400
  const THINKING_HINT_DELAY_MS = 1200;
1143
- while (Date.now() - lastProgress < NO_PROGRESS_MS) {
1401
+ const recordProtocolProgress = () => {
1402
+ lastProtocolProgressAt = Date.now();
1403
+ nextNoProgressDecisionAt = lastProtocolProgressAt + NO_PROGRESS_MS;
1404
+ };
1405
+ while (true) {
1406
+ if (Date.now() >= nextNoProgressDecisionAt) {
1407
+ if (listener.hasQueuedEvents()) {
1408
+ // An event that arrived exactly at the deadline is real protocol
1409
+ // progress. Consume it below before making any terminal decision.
1410
+ recordProtocolProgress();
1411
+ }
1412
+ else if (turn.cancelled) {
1413
+ // Preserve the pre-existing bounded cancel behaviour. A stuck prompt
1414
+ // lock after stop must not keep a user-cancelled turn alive forever.
1415
+ stopBackendTurn(server, turn.zcodeSid, turn.foregroundExecutionId);
1416
+ return { stopReason: "max_turn_requests" };
1417
+ }
1418
+ else {
1419
+ const lockState = await probePromptLock(server, turn.zcodeSid);
1420
+ if (lockState === "held") {
1421
+ // A prompt-lock failure is direct evidence that the backend still owns
1422
+ // an active turn. It is liveness, not protocol progress: leave
1423
+ // lastProtocolProgressAt untouched and schedule a later decision.
1424
+ // This protects legitimately long model/tool operations without
1425
+ // allowing a stale `projection.status=running` to refresh the clock.
1426
+ const activeTools = [...translator.seenToolIds].filter((toolId) => !translator.finalToolIds.has(toolId)).length;
1427
+ log(` [stall] prompt lock still held after ${Math.round((Date.now() - lastProtocolProgressAt) / 1000)}s silence (activeTools=${activeTools}); deferring terminal decision`);
1428
+ nextNoProgressDecisionAt = Date.now() + NO_PROGRESS_MS;
1429
+ }
1430
+ else {
1431
+ log(` [stall] no-progress deadline reached; prompt lock=${lockState}`);
1432
+ stopBackendTurn(server, turn.zcodeSid, turn.foregroundExecutionId);
1433
+ return { stopReason: "max_turn_requests" };
1434
+ }
1435
+ }
1436
+ }
1144
1437
  // Drain + handle server→client requests (interaction/*). Refreshes the
1145
1438
  // no-progress timer when any are handled. Pass `turn` so interaction
1146
1439
  // requests become turn-cancel aware (user stop aborts pending popups).
@@ -1154,21 +1447,25 @@ async function runEventTurn(server, listener, monitor, differ, cx, acpSid, chunk
1154
1447
  warn(`handleServerRequests threw: ${e instanceof Error ? e.message : String(e)}`);
1155
1448
  }
1156
1449
  if (handled) {
1157
- lastProgress = Date.now();
1450
+ recordProtocolProgress();
1158
1451
  }
1159
1452
  if (turn.cancelled) {
1160
- // Cancel requested: ensure stop was fired (cancel()/preempt normally do
1161
- // this, but guard anyway). We do NOT silence subsequent events here — if
1162
- // the backend ignored the stop and kept producing, that content is still
1163
- // valuable to the user and should be displayed (the backend is the single
1164
- // source of truth within a session). Cross-turn contamination is handled
1165
- // separately by the turn-attribution gate below, which discards this
1166
- // turn's leftover events from the *next* turn's queue. The loop exits
1167
- // normally on the terminal event (translator.turnDone below).
1453
+ // Cancel requested: fire the stop (cancel()/preempt normally already
1454
+ // did — this is a guard) and END THE TURN AT ONCE. The backend's
1455
+ // session/stop is fire-and-forget and, as verified against app-server
1456
+ // 0.16.5, does NOT abort the in-flight model stream waiting for the
1457
+ // backend's terminal event used to keep the turn streaming for the
1458
+ // full remaining generation (10s+ past the stop) while the user stared
1459
+ // at a live spinner. Returning here resolves session/prompt with
1460
+ // stopReason "cancelled" immediately; the finally below unregisters
1461
+ // the turn listener, so events the backend still pushes are delivered
1462
+ // to no turn listener, and the next turn's turn-attribution gate
1463
+ // discards any residue that slipped into the queue meanwhile.
1168
1464
  if (!turn.stopSent) {
1169
- stopBackendTurn(server, turn.zcodeSid);
1465
+ stopBackendTurn(server, turn.zcodeSid, turn.foregroundExecutionId);
1170
1466
  turn.stopSent = true;
1171
1467
  }
1468
+ return { stopReason: "cancelled" };
1172
1469
  }
1173
1470
  const ev = await listener.pollEvent(500);
1174
1471
  if (ev === null) {
@@ -1197,7 +1494,7 @@ async function runEventTurn(server, listener, monitor, differ, cx, acpSid, chunk
1197
1494
  // user stopped.
1198
1495
  if (!turn.cancelled &&
1199
1496
  translator.turnStarted &&
1200
- Date.now() - lastProgress > 15_000 &&
1497
+ Date.now() - lastProtocolProgressAt > 15_000 &&
1201
1498
  Date.now() - lastStallCheck > 15_000) {
1202
1499
  lastStallCheck = Date.now();
1203
1500
  const proj = await monitor.pollOnce();
@@ -1210,7 +1507,7 @@ async function runEventTurn(server, listener, monitor, differ, cx, acpSid, chunk
1210
1507
  // turn is alive (it stays queued for the next poll).
1211
1508
  await sleep(1500);
1212
1509
  if (listener.hasQueuedEvents()) {
1213
- lastProgress = Date.now();
1510
+ recordProtocolProgress();
1214
1511
  continue; // alive — events will be consumed by the next poll
1215
1512
  }
1216
1513
  const proj2 = await monitor.pollOnce();
@@ -1224,7 +1521,7 @@ async function runEventTurn(server, listener, monitor, differ, cx, acpSid, chunk
1224
1521
  }
1225
1522
  else if (!emittedOutput) {
1226
1523
  // No text and no output → suspected failure.
1227
- stopBackendTurn(server, turn.zcodeSid);
1524
+ stopBackendTurn(server, turn.zcodeSid, turn.foregroundExecutionId);
1228
1525
  throw new RequestError(-32603, "turn produced no output");
1229
1526
  }
1230
1527
  }
@@ -1237,7 +1534,6 @@ async function runEventTurn(server, listener, monitor, differ, cx, acpSid, chunk
1237
1534
  // Second probe says the backend is still working (or events arrived
1238
1535
  // mid-probe) — keep waiting; queued events are consumed by the next
1239
1536
  // poll iteration.
1240
- lastProgress = Date.now();
1241
1537
  if (proj2?.status === "running") {
1242
1538
  await listener.resubscribe(() => server.nextId());
1243
1539
  }
@@ -1248,13 +1544,23 @@ async function runEventTurn(server, listener, monitor, differ, cx, acpSid, chunk
1248
1544
  // The send-retry loop in prompt() already covers the recovery window
1249
1545
  // for the NEXT turn; for this in-flight turn we just resubscribe and
1250
1546
  // let the backend emit its terminal event when ready.
1251
- lastProgress = Date.now();
1252
1547
  await listener.resubscribe(() => server.nextId());
1253
1548
  }
1254
1549
  }
1255
1550
  continue;
1256
1551
  }
1257
- lastProgress = Date.now();
1552
+ recordProtocolProgress();
1553
+ // Steer-swallow guard: a send accepted while the previous turn is still
1554
+ // generating is queued as steer input, which the backend silently DROPS
1555
+ // when that turn ends — no new turn ever starts (no turn.started), and
1556
+ // the attribution gate below would discard the steerQueued event like
1557
+ // any other residue, leaving this prompt to hang until the 120s watchdog
1558
+ // with the message lost. turn.steerQueued is definitive proof of the
1559
+ // swallow: report it at once so the user can resend immediately.
1560
+ if (ev.type === "turn.steerQueued" && !translator.turnStarted && gateArmed) {
1561
+ await sendTextChunk(cx, acpSid, "[消息被并入仍在生成的回合,将被丢弃,请重新发送]", chunkMsgId);
1562
+ return { stopReason: "max_turn_requests" };
1563
+ }
1258
1564
  // Turn-attribution gate: before this turn's own turn.started arrives, any
1259
1565
  // event is leftover from a prior turn (cancelled/preempted but still
1260
1566
  // finalising) that landed in the queue while send was retrying on a busy
@@ -1266,15 +1572,26 @@ async function runEventTurn(server, listener, monitor, differ, cx, acpSid, chunk
1266
1572
  // terminal event here would flip them and make THIS turn exit prematurely
1267
1573
  // at the first check after its own turn.started passes the gate.
1268
1574
  //
1269
- // The gate is armed ONLY when this send preempted another prompt. Without
1270
- // preemption no prior-turn residue can exist: the queue can only contain
1271
- // events of a backend-owned turn that was already active at send time
1272
- // (e.g. the main-branch turn auto-resumed after a compaction) this send
1273
- // was steered into it and produces NO new turn.started, so dropping those
1274
- // events would silently swallow the entire turn's output in the UI.
1275
- if (shouldDropEventForTurnAttribution(ev, translator.turnStarted, preempted)) {
1575
+ // Armed when this send preempted another prompt, OR when a cancel is
1576
+ // recent: the backend ignores session/stop, so an abandoned turn keeps
1577
+ // streaming until its natural end while the turn loop has already
1578
+ // returned a prompt sent in that window reaches subscribe while the
1579
+ // residue is still arriving. Backend serialisation bounds the exposure:
1580
+ // a send is accepted only after the prior turn released the lock, so the
1581
+ // residue can only arrive BEFORE this turn's turn.started. (A send that
1582
+ // lands mid-generation instead is a steer whose input is dropped — the
1583
+ // turn.steerQueued guard above reports that at once.)
1584
+ if (shouldDropEventForTurnAttribution(ev, translator.turnStarted, gateArmed)) {
1276
1585
  continue;
1277
1586
  }
1587
+ if (ev.type === "turn.started") {
1588
+ // Remember the runtime's foreground execution id: the v4/command stop
1589
+ // (see stopBackendTurn) targets it if the user cancels mid-turn.
1590
+ const fge = ev.payload
1591
+ ?.foregroundExecutionId;
1592
+ if (fge)
1593
+ turn.foregroundExecutionId = fge;
1594
+ }
1278
1595
  const internalEvents = translator.translate(ev);
1279
1596
  // Capture the turn-start timestamp for the thinking-phase hint above.
1280
1597
  // Done after translate so the flag flip on the turn.started event is
@@ -1340,7 +1657,7 @@ async function runEventTurn(server, listener, monitor, differ, cx, acpSid, chunk
1340
1657
  }
1341
1658
  if (translator.turnFailed) {
1342
1659
  // Best-effort stop in case the failed turn left a residual lock.
1343
- stopBackendTurn(server, turn.zcodeSid);
1660
+ stopBackendTurn(server, turn.zcodeSid, turn.foregroundExecutionId);
1344
1661
  // Throw a TurnFailedError carrying the structured error so the caller
1345
1662
  // (prompt's retry loop) can classify transient vs fatal. The error
1346
1663
  // message is formatted for display when it ultimately reaches the user.
@@ -1390,9 +1707,37 @@ async function runEventTurn(server, listener, monitor, differ, cx, acpSid, chunk
1390
1707
  return { stopReason: "end_turn" };
1391
1708
  }
1392
1709
  }
1393
- // 120s no progress: abandon.
1394
- stopBackendTurn(server, turn.zcodeSid);
1395
- return { stopReason: "max_turn_requests" };
1710
+ }
1711
+ /**
1712
+ * Probe the backend's authoritative prompt lock without waiting for it to
1713
+ * change. `session/read` projection status is intentionally not considered:
1714
+ * that projection can remain stale at `running`, which is the condition this
1715
+ * probe is used to disambiguate.
1716
+ */
1717
+ async function probePromptLock(server, zcodeSid) {
1718
+ const backend = server.ensureBackend();
1719
+ if (backend.isDead)
1720
+ return "unknown";
1721
+ try {
1722
+ const resp = await backend.request(server.nextId(), "session/goal", { sessionId: zcodeSid, action: "show" }, 10_000);
1723
+ if (!resp.error)
1724
+ return "released";
1725
+ // Lock-busy must match by error CODE, not message text: backend message
1726
+ // wording drifts between releases (repo Gotcha), and a missed match kills
1727
+ // a live turn. 1308 is the prompt-lock-busy code (same one the send-retry
1728
+ // loop keys on); message matching kept as a legacy fallback.
1729
+ if (resp.error.code === 1308)
1730
+ return "held";
1731
+ const message = (resp.error.message ?? "").toLowerCase();
1732
+ if (message.includes("prompt is running") || message.includes("already running")) {
1733
+ return "held";
1734
+ }
1735
+ return "unknown";
1736
+ }
1737
+ catch (e) {
1738
+ log(` [stall] prompt-lock probe failed: ${e instanceof Error ? e.message : String(e)}`);
1739
+ return "unknown";
1740
+ }
1396
1741
  }
1397
1742
  /**
1398
1743
  * Turn-attribution gate decision (pure, exported for tests): whether an event