zcode-acp-server 0.14.3 → 0.17.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +24 -1
- package/dist/handlers/session.d.ts.map +1 -1
- package/dist/handlers/session.js +231 -24
- 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 +113 -0
- package/dist/i18n.d.ts.map +1 -0
- package/dist/i18n.js +224 -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 +22 -5
- package/dist/repl/App.js.map +1 -1
- package/dist/repl/model.d.ts +21 -0
- package/dist/repl/model.d.ts.map +1 -1
- package/dist/repl/model.js +34 -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/RELEASING.md +29 -0
- 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,56 @@ 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
|
+
try {
|
|
570
|
+
return await runPrompt(server, { sessionId: params.sessionId, prompt: [{ type: "text", text: continuation }] }, cx, `sandbox-cont-${randomUUID()}`, true);
|
|
571
|
+
}
|
|
572
|
+
catch (e) {
|
|
573
|
+
// The chained round runs right after a sandbox-allow respawn, inside
|
|
574
|
+
// the fresh backend's warm-up window. A failure here used to propagate
|
|
575
|
+
// as an RPC error and leave the session dead-silent with the
|
|
576
|
+
// continuation lost — the user had to blindly resend to recover
|
|
577
|
+
// (observed in production logs: "历史任务使用的模型已不可用" rejected
|
|
578
|
+
// the automatic send, a manual resend a minute later went through).
|
|
579
|
+
// Tell the user what happened and how to resume instead.
|
|
580
|
+
const err = e instanceof Error ? e.message : String(e);
|
|
581
|
+
warn(`sandbox: continuation prompt failed: ${err}`);
|
|
582
|
+
await sendTextChunk(cx, params.sessionId, messages().sandboxContinuationFailed(err), randomUUID()).catch(() => undefined);
|
|
583
|
+
return { stopReason: "cancelled" };
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
return result;
|
|
587
|
+
}
|
|
588
|
+
/** One prompt round: subscribe-before-send, run the event-driven turn loop. */
|
|
589
|
+
async function runPrompt(server, params, cx, requestId, continuationRound = false) {
|
|
590
|
+
// Project-level sandbox flip (ADR-0011): a .zcode/acp/sandbox.json created
|
|
591
|
+
// after the backend spawned unsandboxed must arm on THIS prompt, not on the
|
|
592
|
+
// next bridge restart — kill the old process; the ensureBackend() below
|
|
593
|
+
// respawns under the profile and the subscribe-recovery path reloads the
|
|
594
|
+
// session. No-op unless the config appeared mid-run.
|
|
595
|
+
await server.applySandboxFlip();
|
|
493
596
|
const backend = server.ensureBackend();
|
|
494
597
|
// Extract prompt text + image attachments from ACP ContentBlock[].
|
|
495
598
|
const text = extractPromptText(params.prompt);
|
|
@@ -659,7 +762,7 @@ export async function prompt(server, params, cx, requestId) {
|
|
|
659
762
|
return { stopReason: "cancelled" };
|
|
660
763
|
}
|
|
661
764
|
differ.markSeen(await fetchMessages(server, zcodeSid));
|
|
662
|
-
await sendTextChunk(cx, params.sessionId,
|
|
765
|
+
await sendTextChunk(cx, params.sessionId, messages().networkRetry(attempt - 1, MAX_TURN_ATTEMPTS - 1), randomUUID());
|
|
663
766
|
log(` [retry] transient turn failed, re-sending (attempt ${attempt}/${MAX_TURN_ATTEMPTS})`);
|
|
664
767
|
await sleep(backoffMs(attempt - 1));
|
|
665
768
|
}
|
|
@@ -691,6 +794,9 @@ export async function prompt(server, params, cx, requestId) {
|
|
|
691
794
|
// in-flight one) and the stop-recovery window after a manual cancel.
|
|
692
795
|
const SEND_RETRY_INTERVAL_MS = 500;
|
|
693
796
|
const SEND_RETRY_TIMEOUT_MS = 30_000;
|
|
797
|
+
// Cold-start rejections recover in seconds (observed); a longer budget
|
|
798
|
+
// would spin pointlessly when the model is PERMANENTLY unavailable.
|
|
799
|
+
const WARMUP_RETRY_TIMEOUT_MS = 10_000;
|
|
694
800
|
const sendParams = attachments.length > 0
|
|
695
801
|
? { sessionId: zcodeSid, content: sendText, attachments }
|
|
696
802
|
: { sessionId: zcodeSid, content: sendText };
|
|
@@ -728,14 +834,27 @@ export async function prompt(server, params, cx, requestId) {
|
|
|
728
834
|
const isBusy = sendErrCode === 1308 ||
|
|
729
835
|
sendErrMsg.includes("prompt is running") ||
|
|
730
836
|
sendErrMsg.includes("already running");
|
|
731
|
-
|
|
732
|
-
|
|
837
|
+
const isWarming = isTransientSendError(sendResp.error.message ?? "");
|
|
838
|
+
if (!isBusy && !isWarming) {
|
|
839
|
+
// Permanent error (auth, malformed, a truly removed model, …) —
|
|
840
|
+
// don't retry, surface it. The stale-history-model case is
|
|
841
|
+
// repaired before the send (repairUnavailableModel on every
|
|
842
|
+
// resume path); the cold-start form is transient and retries below.
|
|
733
843
|
throw new Error(`zcode send failed: ${sendResp.error.message ?? ""}`);
|
|
734
844
|
}
|
|
735
|
-
|
|
736
|
-
|
|
845
|
+
const budget = isBusy ? SEND_RETRY_TIMEOUT_MS : WARMUP_RETRY_TIMEOUT_MS;
|
|
846
|
+
if (Date.now() - sendT0 > budget) {
|
|
847
|
+
throw new Error(`zcode send failed: ${isBusy ? "backend still busy" : "send keeps being rejected"} after ${Math.round(budget / 1000)}s (${sendResp.error.message ?? ""})`);
|
|
737
848
|
}
|
|
738
|
-
log(` [send] backend busy (${sendResp.error.message ?? ""}), retrying in ${SEND_RETRY_INTERVAL_MS}ms`);
|
|
849
|
+
log(` [send] backend ${isBusy ? "busy" : "cold-start reject"} (${sendResp.error.message ?? ""}), retrying in ${SEND_RETRY_INTERVAL_MS}ms`);
|
|
850
|
+
}
|
|
851
|
+
// Continuation round status line: the respawn+reload window (~seconds)
|
|
852
|
+
// shows only the editor spinner before the model's first output — a
|
|
853
|
+
// bare thinking block with no context. Announce the resumed turn at
|
|
854
|
+
// send-accept so every phase of the allow→restart→continue flow is
|
|
855
|
+
// visibly accounted for (the allow-time hint covers the restart start).
|
|
856
|
+
if (continuationRound) {
|
|
857
|
+
await sendTextChunk(cx, params.sessionId, messages().sandboxResumedStatus, randomUUID());
|
|
739
858
|
}
|
|
740
859
|
try {
|
|
741
860
|
// Event-driven turn loop: translate events via EventTranslator + dispatch.
|
|
@@ -760,6 +879,9 @@ export async function prompt(server, params, cx, requestId) {
|
|
|
760
879
|
const { maybeAutoCompact } = await import("../config/auto-compact.js");
|
|
761
880
|
await maybeAutoCompact(server, cx, params.sessionId, zcodeSid);
|
|
762
881
|
}
|
|
882
|
+
// (Sandbox allow-restart continuation: handled by the prompt() wrapper
|
|
883
|
+
// — the chained round keeps the editor's original request pending so
|
|
884
|
+
// the restart window stays visibly "running".)
|
|
763
885
|
return result;
|
|
764
886
|
}
|
|
765
887
|
catch (e) {
|
|
@@ -783,7 +905,7 @@ export async function prompt(server, params, cx, requestId) {
|
|
|
783
905
|
// surfacing a hard error and stopping. Skip auto-compact here: compaction
|
|
784
906
|
// after a failed turn is more likely to confuse state than help.
|
|
785
907
|
const errMsg = formatTurnError(lastTurnError) || "turn failed after retries";
|
|
786
|
-
await sendTextChunk(cx, params.sessionId,
|
|
908
|
+
await sendTextChunk(cx, params.sessionId, messages().requestFailed(errMsg), randomUUID());
|
|
787
909
|
return { stopReason: "end_turn" };
|
|
788
910
|
}
|
|
789
911
|
finally {
|
|
@@ -1064,7 +1186,7 @@ export async function drainBackendAfterCancel(server, deps) {
|
|
|
1064
1186
|
}
|
|
1065
1187
|
if (!noticed) {
|
|
1066
1188
|
noticed = true;
|
|
1067
|
-
await sendTextChunk(cx, acpSid,
|
|
1189
|
+
await sendTextChunk(cx, acpSid, messages().promptQueuedBehindTurn, randomUUID());
|
|
1068
1190
|
}
|
|
1069
1191
|
await sleep(DRAIN_POLL_MS);
|
|
1070
1192
|
}
|
|
@@ -1299,12 +1421,22 @@ async function resumeBackendSession(server, zcParams) {
|
|
|
1299
1421
|
* fails). Marks the session backend-loaded on success.
|
|
1300
1422
|
*/
|
|
1301
1423
|
async function reloadBackendSession(server, acpSid, zcodeSid) {
|
|
1424
|
+
const cwd = server.sessionCwds.get(acpSid) ?? process.cwd();
|
|
1302
1425
|
const zcParams = {
|
|
1303
1426
|
sessionId: zcodeSid,
|
|
1304
|
-
workspace: workspaceFor(
|
|
1427
|
+
workspace: workspaceFor(cwd),
|
|
1305
1428
|
};
|
|
1429
|
+
// Same pre-resume steps as the ACP resume/load handlers: register the
|
|
1430
|
+
// provider registry (a resumed session's history references a model the
|
|
1431
|
+
// fresh backend can't process until its provider is registered — sends
|
|
1432
|
+
// then fail with the backend's stale-history-model error, persistent, not
|
|
1433
|
+
// transient), then repair a model that is no longer enabled. Skipping
|
|
1434
|
+
// these made every backend respawn (sandbox allow-restart, eviction
|
|
1435
|
+
// recovery) deaf-fail its first sends.
|
|
1436
|
+
await syncProviderRegistry(server, cwd);
|
|
1306
1437
|
await resumePreservingModel(server, zcParams);
|
|
1307
1438
|
server.markBackendLoaded(acpSid);
|
|
1439
|
+
await repairUnavailableModel(server, zcodeSid);
|
|
1308
1440
|
}
|
|
1309
1441
|
/**
|
|
1310
1442
|
* Resume WITHOUT pinning a model, so the session keeps its own selection (the
|
|
@@ -1358,6 +1490,19 @@ async function repairUnavailableModel(server, zcodeSid) {
|
|
|
1358
1490
|
warn(`model repair check failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
1359
1491
|
}
|
|
1360
1492
|
}
|
|
1493
|
+
/**
|
|
1494
|
+
* Cold-start send rejection worth retrying: right after a sandbox-allow
|
|
1495
|
+
* respawn, the reloaded session's recorded model briefly reads as
|
|
1496
|
+
* unavailable while the fresh backend finishes loading its model list — the
|
|
1497
|
+
* same send succeeds seconds later (verified in production: the automatic
|
|
1498
|
+
* continuation was rejected with "历史任务使用的模型已不可用", a manual
|
|
1499
|
+
* resend a minute later went through). Matches the backend's Chinese wording
|
|
1500
|
+
* plus a generic English form.
|
|
1501
|
+
*/
|
|
1502
|
+
export function isTransientSendError(message) {
|
|
1503
|
+
const m = message.toLowerCase();
|
|
1504
|
+
return m.includes("模型已不可用") || /model .*(unavailable|no longer available)/.test(m);
|
|
1505
|
+
}
|
|
1361
1506
|
/** Get or create the session-level ProjectionDiffer (persists across turns). */
|
|
1362
1507
|
function getOrCreateDiffer(server, zcodeSid) {
|
|
1363
1508
|
let d = server.differs.get(zcodeSid);
|
|
@@ -1524,7 +1669,7 @@ export async function runEventTurn(server, listener, monitor, differ, cx, acpSid
|
|
|
1524
1669
|
thinkingHintSent = true;
|
|
1525
1670
|
await sendSessionUpdate(cx, acpSid, {
|
|
1526
1671
|
sessionUpdate: "agent_thought_chunk",
|
|
1527
|
-
content: { type: "text", text:
|
|
1672
|
+
content: { type: "text", text: messages().thinkingPlaceholder },
|
|
1528
1673
|
messageId: `thinking_${chunkMsgId}`,
|
|
1529
1674
|
});
|
|
1530
1675
|
}
|
|
@@ -1601,7 +1746,7 @@ export async function runEventTurn(server, listener, monitor, differ, cx, acpSid
|
|
|
1601
1746
|
// with the message lost. turn.steerQueued is definitive proof of the
|
|
1602
1747
|
// swallow: report it at once so the user can resend immediately.
|
|
1603
1748
|
if (ev.type === "turn.steerQueued" && !translator.turnStarted && gateArmed) {
|
|
1604
|
-
await sendTextChunk(cx, acpSid,
|
|
1749
|
+
await sendTextChunk(cx, acpSid, messages().messageSwallowedByTurn, chunkMsgId);
|
|
1605
1750
|
return { stopReason: "max_turn_requests" };
|
|
1606
1751
|
}
|
|
1607
1752
|
// Turn-attribution gate: before this turn's own turn.started arrives, any
|
|
@@ -1654,6 +1799,68 @@ export async function runEventTurn(server, listener, monitor, differ, cx, acpSid
|
|
|
1654
1799
|
differ.setLastUsage(iev.used);
|
|
1655
1800
|
await dispatchEvent(server, cx, acpSid, iev, chunkMsgId);
|
|
1656
1801
|
}
|
|
1802
|
+
// Filesystem-permission failure surfacing (the tool output itself already
|
|
1803
|
+
// reached the editor above). Two scans, both only on tool.updated so
|
|
1804
|
+
// mid-stream deltas aren't scanned, and both skipping read-only tools —
|
|
1805
|
+
// their output merely ECHOES text, and acting on it would raise a phantom
|
|
1806
|
+
// ask for a path nothing tried to touch.
|
|
1807
|
+
if (ev.type === "tool.updated") {
|
|
1808
|
+
const outputText = JSON.stringify(ev.payload ?? {});
|
|
1809
|
+
const toolCallId = String(ev.payload?.toolCallId ?? "");
|
|
1810
|
+
const toolName = (translator.toolNames.get(toolCallId) ?? "").toLowerCase();
|
|
1811
|
+
// Case-insensitive: zsh prints redirects as "operation not permitted".
|
|
1812
|
+
const lower = outputText.toLowerCase();
|
|
1813
|
+
const scannable = !READ_ONLY_TOOLS.has(toolName);
|
|
1814
|
+
if (scannable && server.backendSandboxed && lower.includes("operation not permitted")) {
|
|
1815
|
+
// Sandbox write-denial (ADR-0011): EPERM outside the Seatbelt
|
|
1816
|
+
// whitelist → dynamic-allow flow (ask → persist → kill backend → the
|
|
1817
|
+
// turn unwinds as cancelled; prompt() fires the continuation). Gated
|
|
1818
|
+
// on the PROCESS fact backendSandboxed (not the config wish) — EPERM
|
|
1819
|
+
// can only come from a sandboxed process; unsandboxed EPERM is
|
|
1820
|
+
// ordinary filesystem permissions.
|
|
1821
|
+
const denial = extractSandboxDenial(outputText);
|
|
1822
|
+
if (denial) {
|
|
1823
|
+
await handleSandboxDenial(server, cx, acpSid, turn, denial, toolCallId);
|
|
1824
|
+
if (turn.cancelled)
|
|
1825
|
+
return { stopReason: "cancelled" };
|
|
1826
|
+
}
|
|
1827
|
+
else {
|
|
1828
|
+
// No path parsed — one generic hint per session, not one per retry.
|
|
1829
|
+
let asked = server.sandboxAskedPaths.get(acpSid);
|
|
1830
|
+
if (!asked) {
|
|
1831
|
+
asked = new Set();
|
|
1832
|
+
server.sandboxAskedPaths.set(acpSid, asked);
|
|
1833
|
+
}
|
|
1834
|
+
if (!asked.has(GENERIC_HINT_KEY)) {
|
|
1835
|
+
asked.add(GENERIC_HINT_KEY);
|
|
1836
|
+
await sendTextChunk(cx, acpSid, messages().sandboxGenericDenialHint, chunkMsgId);
|
|
1837
|
+
}
|
|
1838
|
+
}
|
|
1839
|
+
}
|
|
1840
|
+
else if (scannable && lower.includes("permission denied")) {
|
|
1841
|
+
// EACCES — ordinary filesystem permissions, sandbox or not. Nothing
|
|
1842
|
+
// the bridge can "allow" (no popup fixes chmod/ownership), so surface
|
|
1843
|
+
// it as a one-time hint per path per session; without it the model
|
|
1844
|
+
// silently swallows the failure and reroutes, and the user never
|
|
1845
|
+
// learns why the command died.
|
|
1846
|
+
const deniedPath = extractPermDeniedPath(outputText);
|
|
1847
|
+
if (deniedPath) {
|
|
1848
|
+
const cwd = server.sessionCwds.get(acpSid);
|
|
1849
|
+
const real = resolveReal(path.isAbsolute(deniedPath)
|
|
1850
|
+
? deniedPath
|
|
1851
|
+
: path.resolve(cwd ?? process.cwd(), deniedPath));
|
|
1852
|
+
let hinted = server.fsDeniedPaths.get(acpSid);
|
|
1853
|
+
if (!hinted) {
|
|
1854
|
+
hinted = new Set();
|
|
1855
|
+
server.fsDeniedPaths.set(acpSid, hinted);
|
|
1856
|
+
}
|
|
1857
|
+
if (!hinted.has(real)) {
|
|
1858
|
+
hinted.add(real);
|
|
1859
|
+
await sendTextChunk(cx, acpSid, messages().fsPermDeniedHint(real), chunkMsgId);
|
|
1860
|
+
}
|
|
1861
|
+
}
|
|
1862
|
+
}
|
|
1863
|
+
}
|
|
1657
1864
|
// Edit/Write diff eager dispatch: on tool.updated result, grab the
|
|
1658
1865
|
// structured patch from session/messages immediately (don't wait for turn
|
|
1659
1866
|
// completion — model rate-limiting could delay it indefinitely).
|