zcode-acp-server 0.14.3 → 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 +92 -23
- package/dist/backend/sandbox.d.ts.map +1 -1
- package/dist/backend/sandbox.js +265 -70
- package/dist/backend/sandbox.js.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 +193 -20
- 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/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
|
|
@@ -1524,7 +1635,7 @@ export async function runEventTurn(server, listener, monitor, differ, cx, acpSid
|
|
|
1524
1635
|
thinkingHintSent = true;
|
|
1525
1636
|
await sendSessionUpdate(cx, acpSid, {
|
|
1526
1637
|
sessionUpdate: "agent_thought_chunk",
|
|
1527
|
-
content: { type: "text", text:
|
|
1638
|
+
content: { type: "text", text: messages().thinkingPlaceholder },
|
|
1528
1639
|
messageId: `thinking_${chunkMsgId}`,
|
|
1529
1640
|
});
|
|
1530
1641
|
}
|
|
@@ -1601,7 +1712,7 @@ export async function runEventTurn(server, listener, monitor, differ, cx, acpSid
|
|
|
1601
1712
|
// with the message lost. turn.steerQueued is definitive proof of the
|
|
1602
1713
|
// swallow: report it at once so the user can resend immediately.
|
|
1603
1714
|
if (ev.type === "turn.steerQueued" && !translator.turnStarted && gateArmed) {
|
|
1604
|
-
await sendTextChunk(cx, acpSid,
|
|
1715
|
+
await sendTextChunk(cx, acpSid, messages().messageSwallowedByTurn, chunkMsgId);
|
|
1605
1716
|
return { stopReason: "max_turn_requests" };
|
|
1606
1717
|
}
|
|
1607
1718
|
// Turn-attribution gate: before this turn's own turn.started arrives, any
|
|
@@ -1654,6 +1765,68 @@ export async function runEventTurn(server, listener, monitor, differ, cx, acpSid
|
|
|
1654
1765
|
differ.setLastUsage(iev.used);
|
|
1655
1766
|
await dispatchEvent(server, cx, acpSid, iev, chunkMsgId);
|
|
1656
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
|
+
}
|
|
1657
1830
|
// Edit/Write diff eager dispatch: on tool.updated result, grab the
|
|
1658
1831
|
// structured patch from session/messages immediately (don't wait for turn
|
|
1659
1832
|
// completion — model rate-limiting could delay it indefinitely).
|