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.
- package/README.md +109 -9
- package/README.zh-CN.md +91 -9
- package/dist/backend/index.d.ts +1 -0
- package/dist/backend/index.d.ts.map +1 -1
- package/dist/backend/index.js +1 -0
- package/dist/backend/index.js.map +1 -1
- package/dist/backend/sandbox.d.ts +160 -0
- package/dist/backend/sandbox.d.ts.map +1 -0
- package/dist/backend/sandbox.js +425 -0
- package/dist/backend/sandbox.js.map +1 -0
- package/dist/backend/types.d.ts +4 -0
- package/dist/backend/types.d.ts.map +1 -1
- package/dist/cli.d.ts +2 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +10 -1
- package/dist/cli.js.map +1 -1
- package/dist/config/auto-compact.d.ts.map +1 -1
- package/dist/config/auto-compact.js +6 -4
- package/dist/config/auto-compact.js.map +1 -1
- package/dist/config/mcp-discovery.d.ts.map +1 -1
- package/dist/config/mcp-discovery.js +7 -5
- package/dist/config/mcp-discovery.js.map +1 -1
- package/dist/handlers/background-tasks.d.ts.map +1 -1
- package/dist/handlers/background-tasks.js +2 -2
- package/dist/handlers/background-tasks.js.map +1 -1
- package/dist/handlers/dispatch.d.ts.map +1 -1
- package/dist/handlers/dispatch.js +4 -4
- package/dist/handlers/dispatch.js.map +1 -1
- package/dist/handlers/replay.d.ts.map +1 -1
- package/dist/handlers/replay.js +11 -5
- package/dist/handlers/replay.js.map +1 -1
- package/dist/handlers/sandbox-allow.d.ts +71 -0
- package/dist/handlers/sandbox-allow.d.ts.map +1 -0
- package/dist/handlers/sandbox-allow.js +299 -0
- package/dist/handlers/sandbox-allow.js.map +1 -0
- package/dist/handlers/server-requests.d.ts.map +1 -1
- package/dist/handlers/server-requests.js +7 -5
- package/dist/handlers/server-requests.js.map +1 -1
- package/dist/handlers/session.d.ts +14 -1
- package/dist/handlers/session.d.ts.map +1 -1
- package/dist/handlers/session.js +245 -60
- package/dist/handlers/session.js.map +1 -1
- package/dist/handlers/slash.d.ts.map +1 -1
- package/dist/handlers/slash.js +15 -13
- package/dist/handlers/slash.js.map +1 -1
- package/dist/i18n.d.ts +111 -0
- package/dist/i18n.d.ts.map +1 -0
- package/dist/i18n.js +222 -0
- package/dist/i18n.js.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +114 -15
- package/dist/index.js.map +1 -1
- package/dist/interaction/adapter.d.ts.map +1 -1
- package/dist/interaction/adapter.js +10 -7
- package/dist/interaction/adapter.js.map +1 -1
- package/dist/remote/config.d.ts +7 -0
- package/dist/remote/config.d.ts.map +1 -1
- package/dist/remote/config.js +2 -0
- package/dist/remote/config.js.map +1 -1
- package/dist/remote/endpoint.d.ts.map +1 -1
- package/dist/remote/endpoint.js +66 -11
- package/dist/remote/endpoint.js.map +1 -1
- package/dist/remote/hub-server.d.ts +15 -0
- package/dist/remote/hub-server.d.ts.map +1 -1
- package/dist/remote/hub-server.js +205 -1
- package/dist/remote/hub-server.js.map +1 -1
- package/dist/repl/App.d.ts.map +1 -1
- package/dist/repl/App.js +3 -2
- package/dist/repl/App.js.map +1 -1
- package/dist/repl/model.d.ts +2 -0
- package/dist/repl/model.d.ts.map +1 -1
- package/dist/repl/model.js.map +1 -1
- package/dist/repl/run.d.ts.map +1 -1
- package/dist/repl/run.js +5 -0
- package/dist/repl/run.js.map +1 -1
- package/dist/server.d.ts +78 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +114 -3
- package/dist/server.js.map +1 -1
- package/dist/tasks-index.d.ts +27 -0
- package/dist/tasks-index.d.ts.map +1 -1
- package/dist/tasks-index.js +72 -3
- package/dist/tasks-index.js.map +1 -1
- package/docs/ARCHITECTURE.md +19 -10
- package/docs/REMOTE-CLIENTS.md +43 -0
- package/docs/TROUBLESHOOTING.md +9 -3
- package/package.json +13 -12
- package/dist/remote/session-liveness.d.ts +0 -19
- package/dist/remote/session-liveness.d.ts.map +0 -1
- package/dist/remote/session-liveness.js +0 -70
- package/dist/remote/session-liveness.js.map +0 -1
- package/dist/repl/mouse.d.ts +0 -49
- package/dist/repl/mouse.d.ts.map +0 -1
- package/dist/repl/mouse.js +0 -165
- package/dist/repl/mouse.js.map +0 -1
package/dist/handlers/session.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 (
|
|
176
|
-
server.sessionCwds.set(acpSid,
|
|
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
|
-
|
|
252
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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,
|
|
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
|
|
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,
|
|
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,
|
|
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(
|
|
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
|
|
1420
|
-
if (
|
|
1421
|
-
//
|
|
1422
|
-
//
|
|
1423
|
-
//
|
|
1424
|
-
//
|
|
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]
|
|
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
|
-
|
|
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,
|
|
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
|