zcode-acp-server 0.14.2 → 0.17.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 (96) hide show
  1. package/README.md +109 -9
  2. package/README.zh-CN.md +91 -9
  3. package/dist/backend/index.d.ts +1 -0
  4. package/dist/backend/index.d.ts.map +1 -1
  5. package/dist/backend/index.js +1 -0
  6. package/dist/backend/index.js.map +1 -1
  7. package/dist/backend/sandbox.d.ts +160 -0
  8. package/dist/backend/sandbox.d.ts.map +1 -0
  9. package/dist/backend/sandbox.js +425 -0
  10. package/dist/backend/sandbox.js.map +1 -0
  11. package/dist/backend/types.d.ts +4 -0
  12. package/dist/backend/types.d.ts.map +1 -1
  13. package/dist/cli.d.ts +2 -0
  14. package/dist/cli.d.ts.map +1 -1
  15. package/dist/cli.js +10 -1
  16. package/dist/cli.js.map +1 -1
  17. package/dist/config/auto-compact.d.ts.map +1 -1
  18. package/dist/config/auto-compact.js +6 -4
  19. package/dist/config/auto-compact.js.map +1 -1
  20. package/dist/config/mcp-discovery.d.ts.map +1 -1
  21. package/dist/config/mcp-discovery.js +7 -5
  22. package/dist/config/mcp-discovery.js.map +1 -1
  23. package/dist/handlers/background-tasks.d.ts.map +1 -1
  24. package/dist/handlers/background-tasks.js +2 -2
  25. package/dist/handlers/background-tasks.js.map +1 -1
  26. package/dist/handlers/dispatch.d.ts.map +1 -1
  27. package/dist/handlers/dispatch.js +4 -4
  28. package/dist/handlers/dispatch.js.map +1 -1
  29. package/dist/handlers/replay.d.ts.map +1 -1
  30. package/dist/handlers/replay.js +11 -5
  31. package/dist/handlers/replay.js.map +1 -1
  32. package/dist/handlers/sandbox-allow.d.ts +71 -0
  33. package/dist/handlers/sandbox-allow.d.ts.map +1 -0
  34. package/dist/handlers/sandbox-allow.js +299 -0
  35. package/dist/handlers/sandbox-allow.js.map +1 -0
  36. package/dist/handlers/server-requests.d.ts.map +1 -1
  37. package/dist/handlers/server-requests.js +7 -5
  38. package/dist/handlers/server-requests.js.map +1 -1
  39. package/dist/handlers/session.d.ts +14 -1
  40. package/dist/handlers/session.d.ts.map +1 -1
  41. package/dist/handlers/session.js +245 -60
  42. package/dist/handlers/session.js.map +1 -1
  43. package/dist/handlers/slash.d.ts.map +1 -1
  44. package/dist/handlers/slash.js +15 -13
  45. package/dist/handlers/slash.js.map +1 -1
  46. package/dist/i18n.d.ts +111 -0
  47. package/dist/i18n.d.ts.map +1 -0
  48. package/dist/i18n.js +222 -0
  49. package/dist/i18n.js.map +1 -0
  50. package/dist/index.d.ts +18 -0
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +114 -15
  53. package/dist/index.js.map +1 -1
  54. package/dist/interaction/adapter.d.ts.map +1 -1
  55. package/dist/interaction/adapter.js +10 -7
  56. package/dist/interaction/adapter.js.map +1 -1
  57. package/dist/remote/config.d.ts +7 -0
  58. package/dist/remote/config.d.ts.map +1 -1
  59. package/dist/remote/config.js +2 -0
  60. package/dist/remote/config.js.map +1 -1
  61. package/dist/remote/endpoint.d.ts.map +1 -1
  62. package/dist/remote/endpoint.js +66 -11
  63. package/dist/remote/endpoint.js.map +1 -1
  64. package/dist/remote/hub-server.d.ts +15 -0
  65. package/dist/remote/hub-server.d.ts.map +1 -1
  66. package/dist/remote/hub-server.js +205 -1
  67. package/dist/remote/hub-server.js.map +1 -1
  68. package/dist/repl/App.d.ts.map +1 -1
  69. package/dist/repl/App.js +3 -2
  70. package/dist/repl/App.js.map +1 -1
  71. package/dist/repl/model.d.ts +2 -0
  72. package/dist/repl/model.d.ts.map +1 -1
  73. package/dist/repl/model.js.map +1 -1
  74. package/dist/repl/run.d.ts.map +1 -1
  75. package/dist/repl/run.js +5 -0
  76. package/dist/repl/run.js.map +1 -1
  77. package/dist/server.d.ts +78 -1
  78. package/dist/server.d.ts.map +1 -1
  79. package/dist/server.js +114 -3
  80. package/dist/server.js.map +1 -1
  81. package/dist/tasks-index.d.ts +27 -0
  82. package/dist/tasks-index.d.ts.map +1 -1
  83. package/dist/tasks-index.js +72 -3
  84. package/dist/tasks-index.js.map +1 -1
  85. package/docs/ARCHITECTURE.md +19 -10
  86. package/docs/REMOTE-CLIENTS.md +43 -0
  87. package/docs/TROUBLESHOOTING.md +9 -3
  88. package/package.json +13 -12
  89. package/dist/remote/session-liveness.d.ts +0 -19
  90. package/dist/remote/session-liveness.d.ts.map +0 -1
  91. package/dist/remote/session-liveness.js +0 -70
  92. package/dist/remote/session-liveness.js.map +0 -1
  93. package/dist/repl/mouse.d.ts +0 -49
  94. package/dist/repl/mouse.d.ts.map +0 -1
  95. package/dist/repl/mouse.js +0 -165
  96. package/dist/repl/mouse.js.map +0 -1
@@ -11,19 +11,24 @@
11
11
  * notifications.
12
12
  */
13
13
  import process from "node:process";
14
+ import path from "node:path";
14
15
  import { randomUUID } from "node:crypto";
16
+ import { realpathSync } from "node:fs";
15
17
  import { RequestError } from "@agentclientprotocol/sdk";
16
18
  import { EventStreamListener, TurnMonitor } from "../backend/listener.js";
19
+ import { resolveReal } from "../backend/sandbox.js";
17
20
  import { buildModes, buildConfigOptions, formatModelValue, loadAllModels, } from "../config/options.js";
18
21
  import { emitInitialUsage } from "../config/model-cache.js";
19
22
  import { buildProviderRegistry } from "../config/provider-registry.js";
20
23
  import { buildResumeRuntimeModel } from "../config/runtime-model.js";
24
+ import { messages } from "../i18n.js";
21
25
  import { lookupLazySession, recordMaterializedSession, rememberLazySession, } from "../lazy-sessions.js";
22
26
  import { buildDiffContent, EventTranslator, extractLocations, formatTurnError, isTransientTurnError, ProjectionDiffer, } from "../translators/index.js";
23
27
  import { log, warn } from "../utils.js";
24
28
  import { dispatchEvent } from "./dispatch.js";
25
29
  import { sendSessionUpdate, sendTextChunk, withReplayBatch } from "./io.js";
26
30
  import { fetchMessages, fullSlice, readTailLimit, replayMessages, sliceTail } from "./replay.js";
31
+ import { extractPermDeniedPath, extractSandboxDenial, GENERIC_HINT_KEY, handleSandboxDenial, READ_ONLY_TOOLS, } from "./sandbox-allow.js";
27
32
  import { handleServerRequests } from "./server-requests.js";
28
33
  /** Workspace descriptor used in session create/resume calls. */
29
34
  function workspaceFor(cwd) {
@@ -39,6 +44,20 @@ function workspaceFor(cwd) {
39
44
  function sanitizeClientCwd(client) {
40
45
  return client && client !== "/" ? client : null;
41
46
  }
47
+ /**
48
+ * Same-directory check tolerant of spelling: a workspace can be recorded or
49
+ * reported under a symlinked / non-canonical spelling while the serve bridge
50
+ * holds the resolved process cwd. Compare realpaths; a vanished path falls
51
+ * back to raw equality (both sides unchanged → still equal).
52
+ */
53
+ function sameProjectDir(a, b) {
54
+ try {
55
+ return realpathSync(a) === realpathSync(b);
56
+ }
57
+ catch {
58
+ return a === b;
59
+ }
60
+ }
42
61
  /**
43
62
  * The authoritative Session Root for an EXISTING session: what the bridge
44
63
  * already recorded (set at creation, or refreshed from the backend's resume
@@ -100,7 +119,10 @@ function toIso(ms) {
100
119
  export async function newSession(server, params) {
101
120
  // Creation is the one moment a client's cwd is trusted (the editor
102
121
  // declaring its worktree); "/" is still rejected as a degenerate root.
103
- const cwd = sanitizeClientCwd(params.cwd) ?? process.cwd();
122
+ // Serve mode (ADR-0014) is the exception: a headless bridge exists for ONE
123
+ // hub-chosen project — the process cwd wins and client-supplied values are
124
+ // ignored, so the remote create-whitelist cannot be bypassed via session/new.
125
+ const cwd = server.serveMode ? process.cwd() : (sanitizeClientCwd(params.cwd) ?? process.cwd());
104
126
  // Placeholder id — the client addresses this session with it until the
105
127
  // backend session materializes; never shown in session/list.
106
128
  const acpSid = randomUUID();
@@ -165,15 +187,29 @@ export async function ensureRealSession(server, acpSid) {
165
187
  // (the backend session still exists — re-register the alias); one without
166
188
  // re-hydrates the pending entry so the create path below runs.
167
189
  const record = lookupLazySession(acpSid);
190
+ // Serve mode (ADR-0014) honors durable records for ITS OWN project only.
191
+ // Aliases are minted by editor bridges, which trust their local client's
192
+ // session/new cwd — a remote client can mint {sid → arbitrary cwd} there
193
+ // and then resume it here to drag this bridge into a foreign workspace.
194
+ // Records from another cwd read as unknown ids. The comparison tolerates
195
+ // spelling differences (symlinked record cwd vs the resolved process cwd)
196
+ // — same project under another spelling stays resumable.
197
+ if (server.serveMode && record && !sameProjectDir(record.cwd, process.cwd())) {
198
+ log(`ensureRealSession: serve mode ignores a foreign lazy record (${acpSid})`);
199
+ throw new Error(`session ${acpSid} not found`);
200
+ }
168
201
  if (record?.zcodeSid) {
169
202
  server.registerSession(acpSid, record.zcodeSid);
170
203
  return record.zcodeSid;
171
204
  }
172
205
  if (record) {
173
- pending = { cwd: record.cwd };
206
+ // Serve mode pins to its process cwd even here (belt and suspenders —
207
+ // the guard above already proved the record's cwd matches).
208
+ const cwd = server.serveMode ? process.cwd() : record.cwd;
209
+ pending = { cwd };
174
210
  server.pendingSessions.set(acpSid, pending);
175
- if (record.cwd !== "/")
176
- server.sessionCwds.set(acpSid, record.cwd);
211
+ if (cwd !== "/")
212
+ server.sessionCwds.set(acpSid, cwd);
177
213
  }
178
214
  }
179
215
  if (!pending)
@@ -248,8 +284,12 @@ export async function ensureRealSession(server, acpSid) {
248
284
  export async function listSessions(server, params) {
249
285
  const backend = server.ensureBackend();
250
286
  const zcParams = {};
251
- if (params.cwd) {
252
- zcParams.workspace = workspaceFor(params.cwd);
287
+ // Serve mode (ADR-0014) pins the workspace: a remote client must not use a
288
+ // client-supplied cwd to enumerate the machine's sessions in OTHER projects
289
+ // (the backend scopes the listing to the workspace it is given).
290
+ const listCwd = server.serveMode ? process.cwd() : params.cwd;
291
+ if (listCwd) {
292
+ zcParams.workspace = workspaceFor(listCwd);
253
293
  }
254
294
  const resp = await backend.request(server.nextId(), "session/list", zcParams, 15000);
255
295
  if (resp.error)
@@ -342,8 +382,10 @@ export async function resumeSession(server, params, cx) {
342
382
  // The Session Root never comes from the client (params.cwd is ignored):
343
383
  // start from what the bridge recorded, then let the backend's own resume
344
384
  // result correct it below — a remote client must not move a session's
345
- // file scope by sending its own cwd.
346
- let cwd = authoritativeSessionCwd(server, acpSid);
385
+ // file scope by sending its own cwd. Serve mode (ADR-0014) skips both
386
+ // sources: the bridge exists for ONE hub-chosen project, so the root (and
387
+ // the workspace sent to the backend's resume) stays the process cwd.
388
+ let cwd = server.serveMode ? process.cwd() : authoritativeSessionCwd(server, acpSid);
347
389
  // Lazy placeholders (session/new) resolve to their real backend session
348
390
  // here; alreadyLive targets skip the resume RPC because the session is live
349
391
  // in this backend subprocess.
@@ -375,9 +417,16 @@ export async function resumeSession(server, params, cx) {
375
417
  await repairUnavailableModel(server, zcodeSid);
376
418
  // The backend's session record is the root authority: adopt its
377
419
  // workspace as the session root (heals any stale/polluted entry).
420
+ // Serve mode keeps its pinned cwd (see above) — and a session that
421
+ // genuinely lives in ANOTHER workspace is refused outright: a raw
422
+ // backend id from session/list elsewhere must not be replayed through
423
+ // a serve bridge pinned to one project.
378
424
  const backendWs = workspaceFromResumeResult(resumeResult);
379
- if (backendWs)
425
+ if (backendWs && !server.serveMode)
380
426
  cwd = backendWs;
427
+ if (server.serveMode && backendWs && !sameProjectDir(backendWs, process.cwd())) {
428
+ throw new Error("session belongs to another workspace");
429
+ }
381
430
  }
382
431
  server.registerSession(acpSid, zcodeSid);
383
432
  // The session root for remote file access — backend-authoritative (see
@@ -407,8 +456,9 @@ export async function loadSession(server, params, cx) {
407
456
  // The Session Root never comes from the client (params.cwd is ignored):
408
457
  // start from what the bridge recorded, then let the backend's own resume
409
458
  // result correct it below — a remote client must not move a session's
410
- // file scope by sending its own cwd.
411
- let cwd = authoritativeSessionCwd(server, acpSid);
459
+ // file scope by sending its own cwd. Serve mode (ADR-0014) pins the root
460
+ // (and the workspace sent to resume) to the process cwd throughout.
461
+ let cwd = server.serveMode ? process.cwd() : authoritativeSessionCwd(server, acpSid);
412
462
  // Same placeholder resolution as resumeSession; alreadyLive targets skip the
413
463
  // backend resume RPC (the session is live in this subprocess).
414
464
  const { zcodeSid, alreadyLive } = await resolveResumeTarget(server, acpSid);
@@ -428,9 +478,14 @@ export async function loadSession(server, params, cx) {
428
478
  await repairUnavailableModel(server, zcodeSid);
429
479
  // The backend's session record is the root authority: adopt its
430
480
  // workspace as the session root (heals any stale/polluted entry).
481
+ // Serve mode keeps its pinned cwd (see above) — and refuses sessions
482
+ // from another workspace outright (same rule as resumeSession).
431
483
  const backendWs = workspaceFromResumeResult(resumeResult);
432
- if (backendWs)
484
+ if (backendWs && !server.serveMode)
433
485
  cwd = backendWs;
486
+ if (server.serveMode && backendWs && !sameProjectDir(backendWs, process.cwd())) {
487
+ throw new Error("session belongs to another workspace");
488
+ }
434
489
  }
435
490
  server.registerSession(acpSid, zcodeSid);
436
491
  // Same as resumeSession: backend-authoritative session root for file access.
@@ -488,8 +543,41 @@ export async function loadSession(server, params, cx) {
488
543
  };
489
544
  return result;
490
545
  }
491
- /** `session/prompt` → subscribe-before-send, run the event-driven turn loop. */
546
+ /**
547
+ * `session/prompt` → subscribe-before-send, run the event-driven turn loop.
548
+ *
549
+ * Sandbox allow-restart chaining (ADR-0011): when a turn unwinds as cancelled
550
+ * because the user approved a new writable root (the backend was killed to be
551
+ * respawned under the widened profile), the continuation prompt runs INSIDE
552
+ * this same request via runPrompt. The editor's spinner then spans the
553
+ * respawn+reload window and the resumed work renders as the same turn. A
554
+ * detached follow-up prompt (the previous design) has no pending editor
555
+ * request behind it, so the editor showed no running state for it at all —
556
+ * the window read as "it just stopped", any message typed there preempted
557
+ * the continuation for real, and only the session/load replay later surfaced
558
+ * the orphaned continuation bubble.
559
+ */
492
560
  export async function prompt(server, params, cx, requestId) {
561
+ const result = await runPrompt(server, params, cx, requestId);
562
+ const continuation = server.sandboxContinuations.get(params.sessionId);
563
+ if (continuation && result.stopReason === "cancelled") {
564
+ server.sandboxContinuations.delete(params.sessionId);
565
+ // Same request, fresh round: runPrompt re-enters the whole machinery
566
+ // (ensureBackend respawns; ensureRealSession/subscribe-recovery reloads
567
+ // the session into it). The user's preempt/ESC during the continuation
568
+ // still cancels it — the round registers itself in pendingTurns.
569
+ return runPrompt(server, { sessionId: params.sessionId, prompt: [{ type: "text", text: continuation }] }, cx, `sandbox-cont-${randomUUID()}`, true);
570
+ }
571
+ return result;
572
+ }
573
+ /** One prompt round: subscribe-before-send, run the event-driven turn loop. */
574
+ async function runPrompt(server, params, cx, requestId, continuationRound = false) {
575
+ // Project-level sandbox flip (ADR-0011): a .zcode/acp/sandbox.json created
576
+ // after the backend spawned unsandboxed must arm on THIS prompt, not on the
577
+ // next bridge restart — kill the old process; the ensureBackend() below
578
+ // respawns under the profile and the subscribe-recovery path reloads the
579
+ // session. No-op unless the config appeared mid-run.
580
+ await server.applySandboxFlip();
493
581
  const backend = server.ensureBackend();
494
582
  // Extract prompt text + image attachments from ACP ContentBlock[].
495
583
  const text = extractPromptText(params.prompt);
@@ -659,7 +747,7 @@ export async function prompt(server, params, cx, requestId) {
659
747
  return { stopReason: "cancelled" };
660
748
  }
661
749
  differ.markSeen(await fetchMessages(server, zcodeSid));
662
- await sendTextChunk(cx, params.sessionId, `[网络异常,正在重试 (${attempt - 1}/${MAX_TURN_ATTEMPTS - 1})…]`, randomUUID());
750
+ await sendTextChunk(cx, params.sessionId, messages().networkRetry(attempt - 1, MAX_TURN_ATTEMPTS - 1), randomUUID());
663
751
  log(` [retry] transient turn failed, re-sending (attempt ${attempt}/${MAX_TURN_ATTEMPTS})`);
664
752
  await sleep(backoffMs(attempt - 1));
665
753
  }
@@ -729,7 +817,9 @@ export async function prompt(server, params, cx, requestId) {
729
817
  sendErrMsg.includes("prompt is running") ||
730
818
  sendErrMsg.includes("already running");
731
819
  if (!isBusy) {
732
- // Non-busy error (auth, malformed, etc.) — don't retry, surface it.
820
+ // Non-busy error (auth, malformed, stale model, etc.) — don't
821
+ // retry, surface it. The stale-history-model case is repaired
822
+ // before the send (repairUnavailableModel on every resume path).
733
823
  throw new Error(`zcode send failed: ${sendResp.error.message ?? ""}`);
734
824
  }
735
825
  if (Date.now() - sendT0 > SEND_RETRY_TIMEOUT_MS) {
@@ -737,6 +827,14 @@ export async function prompt(server, params, cx, requestId) {
737
827
  }
738
828
  log(` [send] backend busy (${sendResp.error.message ?? ""}), retrying in ${SEND_RETRY_INTERVAL_MS}ms`);
739
829
  }
830
+ // Continuation round status line: the respawn+reload window (~seconds)
831
+ // shows only the editor spinner before the model's first output — a
832
+ // bare thinking block with no context. Announce the resumed turn at
833
+ // send-accept so every phase of the allow→restart→continue flow is
834
+ // visibly accounted for (the allow-time hint covers the restart start).
835
+ if (continuationRound) {
836
+ await sendTextChunk(cx, params.sessionId, messages().sandboxResumedStatus, randomUUID());
837
+ }
740
838
  try {
741
839
  // Event-driven turn loop: translate events via EventTranslator + dispatch.
742
840
  // Arm the attribution gate also on a recent cancel: the abandoned turn
@@ -760,6 +858,9 @@ export async function prompt(server, params, cx, requestId) {
760
858
  const { maybeAutoCompact } = await import("../config/auto-compact.js");
761
859
  await maybeAutoCompact(server, cx, params.sessionId, zcodeSid);
762
860
  }
861
+ // (Sandbox allow-restart continuation: handled by the prompt() wrapper
862
+ // — the chained round keeps the editor's original request pending so
863
+ // the restart window stays visibly "running".)
763
864
  return result;
764
865
  }
765
866
  catch (e) {
@@ -783,7 +884,7 @@ export async function prompt(server, params, cx, requestId) {
783
884
  // surfacing a hard error and stopping. Skip auto-compact here: compaction
784
885
  // after a failed turn is more likely to confuse state than help.
785
886
  const errMsg = formatTurnError(lastTurnError) || "turn failed after retries";
786
- await sendTextChunk(cx, params.sessionId, `[请求失败:${errMsg}。会话仍可用,请重新发送消息重试。]`, randomUUID());
887
+ await sendTextChunk(cx, params.sessionId, messages().requestFailed(errMsg), randomUUID());
787
888
  return { stopReason: "end_turn" };
788
889
  }
789
890
  finally {
@@ -1064,7 +1165,7 @@ export async function drainBackendAfterCancel(server, deps) {
1064
1165
  }
1065
1166
  if (!noticed) {
1066
1167
  noticed = true;
1067
- await sendTextChunk(cx, acpSid, "[上一个回复仍在生成,等待结束后发送…]", randomUUID());
1168
+ await sendTextChunk(cx, acpSid, messages().promptQueuedBehindTurn, randomUUID());
1068
1169
  }
1069
1170
  await sleep(DRAIN_POLL_MS);
1070
1171
  }
@@ -1299,12 +1400,22 @@ async function resumeBackendSession(server, zcParams) {
1299
1400
  * fails). Marks the session backend-loaded on success.
1300
1401
  */
1301
1402
  async function reloadBackendSession(server, acpSid, zcodeSid) {
1403
+ const cwd = server.sessionCwds.get(acpSid) ?? process.cwd();
1302
1404
  const zcParams = {
1303
1405
  sessionId: zcodeSid,
1304
- workspace: workspaceFor(server.sessionCwds.get(acpSid) ?? process.cwd()),
1406
+ workspace: workspaceFor(cwd),
1305
1407
  };
1408
+ // Same pre-resume steps as the ACP resume/load handlers: register the
1409
+ // provider registry (a resumed session's history references a model the
1410
+ // fresh backend can't process until its provider is registered — sends
1411
+ // then fail with the backend's stale-history-model error, persistent, not
1412
+ // transient), then repair a model that is no longer enabled. Skipping
1413
+ // these made every backend respawn (sandbox allow-restart, eviction
1414
+ // recovery) deaf-fail its first sends.
1415
+ await syncProviderRegistry(server, cwd);
1306
1416
  await resumePreservingModel(server, zcParams);
1307
1417
  server.markBackendLoaded(acpSid);
1418
+ await repairUnavailableModel(server, zcodeSid);
1308
1419
  }
1309
1420
  /**
1310
1421
  * Resume WITHOUT pinning a model, so the session keeps its own selection (the
@@ -1383,8 +1494,34 @@ export async function runEventTurn(server, listener, monitor, differ, cx, acpSid
1383
1494
  const translator = new EventTranslator();
1384
1495
  differ.resetTurn();
1385
1496
  const NO_PROGRESS_MS = 120_000;
1497
+ // Stall termination policy. Two candidate liveness signals were verified
1498
+ // against the Aug-28 app-server and both are unusable for kill decisions:
1499
+ // - `session/goal show` succeeds mid-turn (never reports the 1308 lock),
1500
+ // - a probe `session/send` is ACCEPTED while the turn runs (queued as
1501
+ // steer input) — the prompt lock is only held during finalisation.
1502
+ // So "lock released" proves nothing about turn liveness, and killing on it
1503
+ // murdered live sub-agent turns after 120s of stream silence. The honest
1504
+ // signal is the read-projection watermark: contextUsed / totalTokenCount /
1505
+ // turnCount / currentTurnId advance while the backend makes progress
1506
+ // (verified: a sub-agent turn advanced the watermark for 5+ minutes with
1507
+ // zero stream events). A live turn may still freeze the watermark for a
1508
+ // while (long CoT, quiet tools — observed 60s+ pauses), so a freeze alone
1509
+ // never kills: only a freeze sustained past STALE_FREEZE_MS ends the turn,
1510
+ // reply-fetch first, stop as the last resort.
1511
+ const STALE_FREEZE_MS = 600_000;
1386
1512
  let lastProtocolProgressAt = Date.now();
1387
1513
  let nextNoProgressDecisionAt = lastProtocolProgressAt + NO_PROGRESS_MS;
1514
+ let lastWatermarkAdvanceAt = Date.now();
1515
+ let watermark = "";
1516
+ const noteWatermark = (proj) => {
1517
+ if (!proj)
1518
+ return;
1519
+ const next = `${proj.contextUsed ?? 0}/${proj.totalTokenCount ?? 0}/${proj.turnCount ?? 0}/${proj.currentTurnId ?? ""}`;
1520
+ if (next !== watermark) {
1521
+ watermark = next;
1522
+ lastWatermarkAdvanceAt = Date.now();
1523
+ }
1524
+ };
1388
1525
  let lastStallCheck = Date.now();
1389
1526
  let emittedText = false;
1390
1527
  let emittedOutput = false;
@@ -1416,19 +1553,34 @@ export async function runEventTurn(server, listener, monitor, differ, cx, acpSid
1416
1553
  return { stopReason: "max_turn_requests" };
1417
1554
  }
1418
1555
  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.
1556
+ const frozenMs = Date.now() - lastWatermarkAdvanceAt;
1557
+ if (frozenMs < STALE_FREEZE_MS) {
1558
+ // The read watermark moved recently — direct evidence the backend is
1559
+ // still making progress (typically a sub-agent or slow tool working
1560
+ // behind a silent stream). Keep waiting; the 15s stall-reconcile
1561
+ // below keeps refreshing the watermark via session/read.
1426
1562
  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`);
1563
+ log(` [stall] watermark advanced within the last ${Math.round(frozenMs / 1000)}s (activeTools=${activeTools}); deferring terminal decision`);
1428
1564
  nextNoProgressDecisionAt = Date.now() + NO_PROGRESS_MS;
1429
1565
  }
1566
+ else if (emittedText || emittedOutput) {
1567
+ // Watermark frozen past the budget and something was already
1568
+ // delivered — treat as a completed-but-terminal-event-lost turn
1569
+ // (never compress its context; the completion is inferred).
1570
+ turn.stallRecovered = true;
1571
+ log(` [stall] watermark frozen ${Math.round(frozenMs / 1000)}s; ending turn after delivered output`);
1572
+ return { stopReason: "end_turn" };
1573
+ }
1430
1574
  else {
1431
- log(` [stall] no-progress deadline reached; prompt lock=${lockState}`);
1575
+ const reply = await fetchLastReply(server, turn.zcodeSid, differ);
1576
+ if (reply) {
1577
+ registerFetchedReply(translator, reply);
1578
+ await sendTextChunk(cx, acpSid, reply.text, chunkMsgId);
1579
+ turn.stallRecovered = true;
1580
+ log(` [stall] watermark frozen ${Math.round(frozenMs / 1000)}s; recovered reply via session/messages`);
1581
+ return { stopReason: "end_turn" };
1582
+ }
1583
+ log(` [stall] watermark frozen ${Math.round(frozenMs / 1000)}s with no output; stopping backend turn`);
1432
1584
  stopBackendTurn(server, turn.zcodeSid, turn.foregroundExecutionId);
1433
1585
  return { stopReason: "max_turn_requests" };
1434
1586
  }
@@ -1483,7 +1635,7 @@ export async function runEventTurn(server, listener, monitor, differ, cx, acpSid
1483
1635
  thinkingHintSent = true;
1484
1636
  await sendSessionUpdate(cx, acpSid, {
1485
1637
  sessionUpdate: "agent_thought_chunk",
1486
- content: { type: "text", text: "正在思考…" },
1638
+ content: { type: "text", text: messages().thinkingPlaceholder },
1487
1639
  messageId: `thinking_${chunkMsgId}`,
1488
1640
  });
1489
1641
  }
@@ -1498,6 +1650,7 @@ export async function runEventTurn(server, listener, monitor, differ, cx, acpSid
1498
1650
  Date.now() - lastStallCheck > 15_000) {
1499
1651
  lastStallCheck = Date.now();
1500
1652
  const proj = await monitor.pollOnce();
1653
+ noteWatermark(proj);
1501
1654
  if (proj?.status === "idle") {
1502
1655
  // A single idle probe can also fire mid-work: the backend is silent
1503
1656
  // during the model's thinking/connection phase and may report idle
@@ -1511,6 +1664,7 @@ export async function runEventTurn(server, listener, monitor, differ, cx, acpSid
1511
1664
  continue; // alive — events will be consumed by the next poll
1512
1665
  }
1513
1666
  const proj2 = await monitor.pollOnce();
1667
+ noteWatermark(proj2);
1514
1668
  if (proj2?.status === "idle" && !listener.hasQueuedEvents()) {
1515
1669
  // Turn completed but the event was lost (double-confirmed).
1516
1670
  if (!emittedText) {
@@ -1558,7 +1712,7 @@ export async function runEventTurn(server, listener, monitor, differ, cx, acpSid
1558
1712
  // with the message lost. turn.steerQueued is definitive proof of the
1559
1713
  // swallow: report it at once so the user can resend immediately.
1560
1714
  if (ev.type === "turn.steerQueued" && !translator.turnStarted && gateArmed) {
1561
- await sendTextChunk(cx, acpSid, "[消息被并入仍在生成的回合,将被丢弃,请重新发送]", chunkMsgId);
1715
+ await sendTextChunk(cx, acpSid, messages().messageSwallowedByTurn, chunkMsgId);
1562
1716
  return { stopReason: "max_turn_requests" };
1563
1717
  }
1564
1718
  // Turn-attribution gate: before this turn's own turn.started arrives, any
@@ -1611,6 +1765,68 @@ export async function runEventTurn(server, listener, monitor, differ, cx, acpSid
1611
1765
  differ.setLastUsage(iev.used);
1612
1766
  await dispatchEvent(server, cx, acpSid, iev, chunkMsgId);
1613
1767
  }
1768
+ // Filesystem-permission failure surfacing (the tool output itself already
1769
+ // reached the editor above). Two scans, both only on tool.updated so
1770
+ // mid-stream deltas aren't scanned, and both skipping read-only tools —
1771
+ // their output merely ECHOES text, and acting on it would raise a phantom
1772
+ // ask for a path nothing tried to touch.
1773
+ if (ev.type === "tool.updated") {
1774
+ const outputText = JSON.stringify(ev.payload ?? {});
1775
+ const toolCallId = String(ev.payload?.toolCallId ?? "");
1776
+ const toolName = (translator.toolNames.get(toolCallId) ?? "").toLowerCase();
1777
+ // Case-insensitive: zsh prints redirects as "operation not permitted".
1778
+ const lower = outputText.toLowerCase();
1779
+ const scannable = !READ_ONLY_TOOLS.has(toolName);
1780
+ if (scannable && server.backendSandboxed && lower.includes("operation not permitted")) {
1781
+ // Sandbox write-denial (ADR-0011): EPERM outside the Seatbelt
1782
+ // whitelist → dynamic-allow flow (ask → persist → kill backend → the
1783
+ // turn unwinds as cancelled; prompt() fires the continuation). Gated
1784
+ // on the PROCESS fact backendSandboxed (not the config wish) — EPERM
1785
+ // can only come from a sandboxed process; unsandboxed EPERM is
1786
+ // ordinary filesystem permissions.
1787
+ const denial = extractSandboxDenial(outputText);
1788
+ if (denial) {
1789
+ await handleSandboxDenial(server, cx, acpSid, turn, denial, toolCallId);
1790
+ if (turn.cancelled)
1791
+ return { stopReason: "cancelled" };
1792
+ }
1793
+ else {
1794
+ // No path parsed — one generic hint per session, not one per retry.
1795
+ let asked = server.sandboxAskedPaths.get(acpSid);
1796
+ if (!asked) {
1797
+ asked = new Set();
1798
+ server.sandboxAskedPaths.set(acpSid, asked);
1799
+ }
1800
+ if (!asked.has(GENERIC_HINT_KEY)) {
1801
+ asked.add(GENERIC_HINT_KEY);
1802
+ await sendTextChunk(cx, acpSid, messages().sandboxGenericDenialHint, chunkMsgId);
1803
+ }
1804
+ }
1805
+ }
1806
+ else if (scannable && lower.includes("permission denied")) {
1807
+ // EACCES — ordinary filesystem permissions, sandbox or not. Nothing
1808
+ // the bridge can "allow" (no popup fixes chmod/ownership), so surface
1809
+ // it as a one-time hint per path per session; without it the model
1810
+ // silently swallows the failure and reroutes, and the user never
1811
+ // learns why the command died.
1812
+ const deniedPath = extractPermDeniedPath(outputText);
1813
+ if (deniedPath) {
1814
+ const cwd = server.sessionCwds.get(acpSid);
1815
+ const real = resolveReal(path.isAbsolute(deniedPath)
1816
+ ? deniedPath
1817
+ : path.resolve(cwd ?? process.cwd(), deniedPath));
1818
+ let hinted = server.fsDeniedPaths.get(acpSid);
1819
+ if (!hinted) {
1820
+ hinted = new Set();
1821
+ server.fsDeniedPaths.set(acpSid, hinted);
1822
+ }
1823
+ if (!hinted.has(real)) {
1824
+ hinted.add(real);
1825
+ await sendTextChunk(cx, acpSid, messages().fsPermDeniedHint(real), chunkMsgId);
1826
+ }
1827
+ }
1828
+ }
1829
+ }
1614
1830
  // Edit/Write diff eager dispatch: on tool.updated result, grab the
1615
1831
  // structured patch from session/messages immediately (don't wait for turn
1616
1832
  // completion — model rate-limiting could delay it indefinitely).
@@ -1708,37 +1924,6 @@ export async function runEventTurn(server, listener, monitor, differ, cx, acpSid
1708
1924
  }
1709
1925
  }
1710
1926
  }
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
- }
1741
- }
1742
1927
  /**
1743
1928
  * Turn-attribution gate decision (pure, exported for tests): whether an event
1744
1929
  * observed before this turn's own `turn.started` should be dropped as leftover