@cjhyy/code-shell-core 0.6.0-rc.12 → 0.6.0-rc.13
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/dist/cc-orchestrator/cwd-normalize.d.ts +2 -0
- package/dist/cc-orchestrator/cwd-normalize.js +19 -0
- package/dist/cc-orchestrator/external-agent-bindings.d.ts +27 -0
- package/dist/cc-orchestrator/external-agent-bindings.js +150 -0
- package/dist/cc-orchestrator/external-agent-session-store.d.ts +23 -0
- package/dist/cc-orchestrator/external-agent-session-store.js +144 -0
- package/dist/context/manager.d.ts +3 -1
- package/dist/context/manager.js +24 -15
- package/dist/credentials/types.d.ts +2 -2
- package/dist/engine/engine.d.ts +5 -1
- package/dist/engine/engine.js +92 -51
- package/dist/engine/turn-loop.js +1 -1
- package/dist/git/worktree.d.ts +49 -6
- package/dist/git/worktree.js +265 -31
- package/dist/index.d.ts +4 -3
- package/dist/index.js +3 -2
- package/dist/logging/logger.js +6 -6
- package/dist/plugins/installer/installFromSource.js +9 -1
- package/dist/plugins/installer/sourcePath.d.ts +9 -0
- package/dist/plugins/installer/sourcePath.js +50 -0
- package/dist/plugins/pluginInstaller.js +24 -22
- package/dist/protocol/chat-session-manager.d.ts +1 -0
- package/dist/protocol/chat-session-manager.js +13 -0
- package/dist/protocol/server.d.ts +8 -0
- package/dist/protocol/server.js +45 -8
- package/dist/protocol/types.d.ts +4 -0
- package/dist/protocol/types.js +4 -0
- package/dist/run/FileRunStore.js +10 -1
- package/dist/run/Heartbeat.js +12 -0
- package/dist/run/RunApprovalBackend.d.ts +3 -0
- package/dist/run/RunApprovalBackend.js +41 -6
- package/dist/run/RunLock.js +2 -0
- package/dist/run/RunManager.d.ts +2 -0
- package/dist/run/RunManager.js +64 -24
- package/dist/run/ids.d.ts +2 -0
- package/dist/run/ids.js +23 -0
- package/dist/session/session-manager.d.ts +35 -1
- package/dist/session/session-manager.js +189 -2
- package/dist/settings/manager.d.ts +1 -0
- package/dist/settings/manager.js +45 -26
- package/dist/settings/schema-export.d.ts +2 -3
- package/dist/settings/schema-export.js +2 -3
- package/dist/tool-system/builtin/background-jobs.d.ts +8 -1
- package/dist/tool-system/builtin/background-jobs.js +8 -1
- package/dist/tool-system/builtin/config.d.ts +2 -1
- package/dist/tool-system/builtin/config.js +16 -11
- package/dist/tool-system/builtin/drive-claude-code.d.ts +15 -2
- package/dist/tool-system/builtin/drive-claude-code.js +174 -39
- package/dist/tool-system/builtin/edit.js +5 -2
- package/dist/tool-system/builtin/generate-video.d.ts +1 -0
- package/dist/tool-system/builtin/generate-video.js +13 -4
- package/dist/tool-system/builtin/index.js +5 -1
- package/dist/tool-system/builtin/lsp.d.ts +2 -1
- package/dist/tool-system/builtin/lsp.js +6 -3
- package/dist/tool-system/builtin/notebook-edit.js +5 -2
- package/dist/tool-system/builtin/read.js +5 -2
- package/dist/tool-system/builtin/worktree.d.ts +2 -4
- package/dist/tool-system/builtin/worktree.js +250 -75
- package/dist/tool-system/builtin/write.js +5 -3
- package/dist/tool-system/context.d.ts +12 -0
- package/dist/tool-system/mcp-manager.d.ts +8 -0
- package/dist/tool-system/mcp-manager.js +32 -11
- package/dist/types.d.ts +12 -0
- package/dist/utils/toolDisplay.js +1 -1
- package/package.json +1 -1
package/dist/protocol/server.js
CHANGED
|
@@ -73,6 +73,14 @@ export class AgentServer {
|
|
|
73
73
|
approvalTimers = new Map();
|
|
74
74
|
/** Default approval timeout: 5 minutes */
|
|
75
75
|
static APPROVAL_TIMEOUT_MS = 5 * 60 * 1000;
|
|
76
|
+
/**
|
|
77
|
+
* Wall-clock timeout for AskUserQuestion during a GOAL run (10 minutes).
|
|
78
|
+
* Plain interactive asks stay untimed; only a self-driving goal run arms this
|
|
79
|
+
* so a mid-goal question can't suspend the loop forever with nobody watching.
|
|
80
|
+
* Longer than the tool-approval timeout — a user who IS present deserves more
|
|
81
|
+
* time to compose a real answer before we assume-and-continue.
|
|
82
|
+
*/
|
|
83
|
+
static GOAL_ASKUSER_TIMEOUT_MS = 10 * 60 * 1000;
|
|
76
84
|
/**
|
|
77
85
|
* Unsubscribe handle for the process-local `agentNotificationBus`
|
|
78
86
|
* subscription set up in the constructor. Called from `close()` so a
|
|
@@ -467,9 +475,7 @@ export class AgentServer {
|
|
|
467
475
|
// the user already knows they pressed stop; clients should
|
|
468
476
|
// just clear busy.
|
|
469
477
|
const errName = err.name;
|
|
470
|
-
const aborted = runController.signal.aborted ||
|
|
471
|
-
errName === "AbortError" ||
|
|
472
|
-
errName === "APIUserAbortError";
|
|
478
|
+
const aborted = runController.signal.aborted || errName === "AbortError" || errName === "APIUserAbortError";
|
|
473
479
|
if (aborted) {
|
|
474
480
|
const cancelledResult = {
|
|
475
481
|
text: "",
|
|
@@ -1239,8 +1245,7 @@ export class AgentServer {
|
|
|
1239
1245
|
const key = params.key;
|
|
1240
1246
|
if (!key)
|
|
1241
1247
|
throw new Error("provider_refresh: key required");
|
|
1242
|
-
const providers = engine.readSetting("providers") ??
|
|
1243
|
-
[];
|
|
1248
|
+
const providers = engine.readSetting("providers") ?? [];
|
|
1244
1249
|
const p = providers.find((x) => x.key === key);
|
|
1245
1250
|
if (!p)
|
|
1246
1251
|
throw new Error(`provider not found: ${key}`);
|
|
@@ -1276,8 +1281,7 @@ export class AgentServer {
|
|
|
1276
1281
|
if (refs.length > 0) {
|
|
1277
1282
|
throw new Error(`provider ${key} referenced by models: ${refs.join(", ")}`);
|
|
1278
1283
|
}
|
|
1279
|
-
const providers = engine.readSetting("providers") ??
|
|
1280
|
-
[];
|
|
1284
|
+
const providers = engine.readSetting("providers") ?? [];
|
|
1281
1285
|
const next = providers.filter((p) => p.key !== key);
|
|
1282
1286
|
engine.updateConfig("providers", next);
|
|
1283
1287
|
this.transport.send(createResponse(req.id, { type: "provider_delete", data: { ok: true } }));
|
|
@@ -1461,7 +1465,9 @@ export class AgentServer {
|
|
|
1461
1465
|
this.clearApprovalTimer(requestId);
|
|
1462
1466
|
const result = decision;
|
|
1463
1467
|
if (result && typeof result === "object" && "approved" in result) {
|
|
1464
|
-
resolve(result.approved
|
|
1468
|
+
resolve(result.approved
|
|
1469
|
+
? (result.answer ?? "")
|
|
1470
|
+
: (result.reason ?? "(user declined to answer)"));
|
|
1465
1471
|
}
|
|
1466
1472
|
else {
|
|
1467
1473
|
// chatManager approve handler resolves with the raw decision value.
|
|
@@ -1487,6 +1493,37 @@ export class AgentServer {
|
|
|
1487
1493
|
riskLevel: "low",
|
|
1488
1494
|
},
|
|
1489
1495
|
});
|
|
1496
|
+
// Goal mode is an unattended, self-driving run: nobody may be watching to
|
|
1497
|
+
// answer, and this ask channel otherwise has NO wall-clock timeout — so a
|
|
1498
|
+
// model that calls AskUserQuestion mid-goal would suspend the turn loop
|
|
1499
|
+
// forever (only a manual Stop could unblock it). When a goal is active,
|
|
1500
|
+
// arm the shared 10-minute approval timeout: on expiry, drain the pending
|
|
1501
|
+
// ask and resolve with a nudge so the loop keeps making progress instead
|
|
1502
|
+
// of hanging. Plain interactive (no-goal) asks keep their intentional
|
|
1503
|
+
// no-timeout behavior — a human can take as long as they like.
|
|
1504
|
+
let goalActive = false;
|
|
1505
|
+
try {
|
|
1506
|
+
goalActive = !!session.getGoal();
|
|
1507
|
+
}
|
|
1508
|
+
catch {
|
|
1509
|
+
goalActive = false;
|
|
1510
|
+
}
|
|
1511
|
+
if (goalActive) {
|
|
1512
|
+
const timer = setTimeout(() => {
|
|
1513
|
+
const resolveFn = session.pendingApprovals.get(requestId);
|
|
1514
|
+
if (resolveFn) {
|
|
1515
|
+
session.pendingApprovals.delete(requestId);
|
|
1516
|
+
this.approvalTimers.delete(requestId);
|
|
1517
|
+
resolveFn("(用户未在限定时间内回答;目标运行中请自行做出合理假设并继续推进。)");
|
|
1518
|
+
// Tell every client the ask is resolved (nobody answered) so the
|
|
1519
|
+
// stale AskUserQuestion card is dismissed instead of lingering as
|
|
1520
|
+
// if still waiting. Reuses the same envelope shape the desktop main
|
|
1521
|
+
// already broadcasts / the renderer's onApprovalResolved consumes.
|
|
1522
|
+
this.notify(Methods.ApprovalResolved, { sessionId, requestId });
|
|
1523
|
+
}
|
|
1524
|
+
}, AgentServer.GOAL_ASKUSER_TIMEOUT_MS);
|
|
1525
|
+
this.approvalTimers.set(requestId, timer);
|
|
1526
|
+
}
|
|
1490
1527
|
});
|
|
1491
1528
|
}
|
|
1492
1529
|
/**
|
package/dist/protocol/types.d.ts
CHANGED
|
@@ -293,6 +293,10 @@ export declare const Methods: {
|
|
|
293
293
|
readonly BackgroundWork: "agent/backgroundWork";
|
|
294
294
|
readonly StreamEvent: "agent/streamEvent";
|
|
295
295
|
readonly ApprovalRequest: "agent/approvalRequest";
|
|
296
|
+
/** Server-initiated resolution of a pending approval/ask (e.g. a goal-mode
|
|
297
|
+
* AskUserQuestion that timed out) so every client can dismiss its stale card
|
|
298
|
+
* without a user decision. Same envelope the desktop main broadcasts. */
|
|
299
|
+
readonly ApprovalResolved: "agent/approvalResolved";
|
|
296
300
|
readonly Status: "agent/status";
|
|
297
301
|
};
|
|
298
302
|
export declare function createRequest(method: string, params?: Record<string, unknown>): RpcRequest;
|
package/dist/protocol/types.js
CHANGED
|
@@ -56,6 +56,10 @@ export const Methods = {
|
|
|
56
56
|
// Server → Client (notifications, no id)
|
|
57
57
|
StreamEvent: "agent/streamEvent",
|
|
58
58
|
ApprovalRequest: "agent/approvalRequest",
|
|
59
|
+
/** Server-initiated resolution of a pending approval/ask (e.g. a goal-mode
|
|
60
|
+
* AskUserQuestion that timed out) so every client can dismiss its stale card
|
|
61
|
+
* without a user decision. Same envelope the desktop main broadcasts. */
|
|
62
|
+
ApprovalResolved: "agent/approvalResolved",
|
|
59
63
|
Status: "agent/status",
|
|
60
64
|
};
|
|
61
65
|
// ─── Helpers ────────────────────────────────────────────────────────
|
package/dist/run/FileRunStore.js
CHANGED
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
import { mkdirSync, existsSync, readFileSync, writeFileSync, appendFileSync, readdirSync, renameSync, rmSync, } from "node:fs";
|
|
13
13
|
import { join } from "node:path";
|
|
14
14
|
import { homedir } from "node:os";
|
|
15
|
+
import { randomUUID } from "node:crypto";
|
|
16
|
+
import { assertSafeRunFileId, assertSafeRunId } from "./ids.js";
|
|
15
17
|
export class FileRunStore {
|
|
16
18
|
runsDir;
|
|
17
19
|
constructor(storageDir) {
|
|
@@ -20,6 +22,7 @@ export class FileRunStore {
|
|
|
20
22
|
}
|
|
21
23
|
// ─── Helpers ───────────────────────────────────────────────────
|
|
22
24
|
runDir(runId) {
|
|
25
|
+
assertSafeRunId(runId);
|
|
23
26
|
return join(this.runsDir, runId);
|
|
24
27
|
}
|
|
25
28
|
ensureRunDir(runId) {
|
|
@@ -31,7 +34,7 @@ export class FileRunStore {
|
|
|
31
34
|
return dir;
|
|
32
35
|
}
|
|
33
36
|
writeJson(filePath, data) {
|
|
34
|
-
const tmp = filePath
|
|
37
|
+
const tmp = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
|
|
35
38
|
try {
|
|
36
39
|
writeFileSync(tmp, JSON.stringify(data, null, 2), "utf-8");
|
|
37
40
|
// Atomic rename — prevents partial writes on crash
|
|
@@ -153,6 +156,8 @@ export class FileRunStore {
|
|
|
153
156
|
}
|
|
154
157
|
// ─── Checkpoints ───────────────────────────────────────────────
|
|
155
158
|
async saveCheckpoint(cp) {
|
|
159
|
+
assertSafeRunId(cp.runId);
|
|
160
|
+
assertSafeRunFileId(cp.checkpointId, "checkpoint id");
|
|
156
161
|
const dir = this.runDir(cp.runId);
|
|
157
162
|
this.writeJson(join(dir, "checkpoints", `${cp.checkpointId}.json`), cp);
|
|
158
163
|
}
|
|
@@ -175,10 +180,14 @@ export class FileRunStore {
|
|
|
175
180
|
}
|
|
176
181
|
// ─── Approvals ─────────────────────────────────────────────────
|
|
177
182
|
async saveApproval(approval) {
|
|
183
|
+
assertSafeRunId(approval.runId);
|
|
184
|
+
assertSafeRunFileId(approval.approvalId, "approval id");
|
|
178
185
|
const dir = this.runDir(approval.runId);
|
|
179
186
|
this.writeJson(join(dir, "approvals", `${approval.approvalId}.json`), approval);
|
|
180
187
|
}
|
|
181
188
|
async getApproval(runId, approvalId) {
|
|
189
|
+
assertSafeRunId(runId);
|
|
190
|
+
assertSafeRunFileId(approvalId, "approval id");
|
|
182
191
|
return this.readJson(join(this.runDir(runId), "approvals", `${approvalId}.json`));
|
|
183
192
|
}
|
|
184
193
|
async getPendingApproval(runId) {
|
package/dist/run/Heartbeat.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import { existsSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
|
|
12
12
|
import { join } from "node:path";
|
|
13
13
|
import { homedir } from "node:os";
|
|
14
|
+
import { assertSafeRunId } from "./ids.js";
|
|
14
15
|
export class Heartbeat {
|
|
15
16
|
runsDir;
|
|
16
17
|
intervalMs;
|
|
@@ -23,6 +24,12 @@ export class Heartbeat {
|
|
|
23
24
|
* Start heartbeat for a run. Writes immediately, then repeats on interval.
|
|
24
25
|
*/
|
|
25
26
|
start(runId) {
|
|
27
|
+
assertSafeRunId(runId);
|
|
28
|
+
const existing = this.timers.get(runId);
|
|
29
|
+
if (existing) {
|
|
30
|
+
clearInterval(existing);
|
|
31
|
+
this.timers.delete(runId);
|
|
32
|
+
}
|
|
26
33
|
// Write first heartbeat immediately
|
|
27
34
|
this.write(runId);
|
|
28
35
|
// Schedule periodic heartbeats
|
|
@@ -37,6 +44,7 @@ export class Heartbeat {
|
|
|
37
44
|
* Stop heartbeat for a run and remove the heartbeat file.
|
|
38
45
|
*/
|
|
39
46
|
stop(runId) {
|
|
47
|
+
assertSafeRunId(runId);
|
|
40
48
|
const timer = this.timers.get(runId);
|
|
41
49
|
if (timer) {
|
|
42
50
|
clearInterval(timer);
|
|
@@ -56,6 +64,7 @@ export class Heartbeat {
|
|
|
56
64
|
* Read the last heartbeat for a run. Returns null if no heartbeat exists.
|
|
57
65
|
*/
|
|
58
66
|
read(runId) {
|
|
67
|
+
assertSafeRunId(runId);
|
|
59
68
|
const filePath = this.filePath(runId);
|
|
60
69
|
if (!existsSync(filePath))
|
|
61
70
|
return null;
|
|
@@ -71,6 +80,7 @@ export class Heartbeat {
|
|
|
71
80
|
* Returns true if stale or missing, false if recent.
|
|
72
81
|
*/
|
|
73
82
|
isStale(runId, thresholdMs) {
|
|
83
|
+
assertSafeRunId(runId);
|
|
74
84
|
const threshold = thresholdMs ?? this.intervalMs * 3;
|
|
75
85
|
const data = this.read(runId);
|
|
76
86
|
if (!data)
|
|
@@ -81,6 +91,7 @@ export class Heartbeat {
|
|
|
81
91
|
* Check if the process that wrote the heartbeat is still alive.
|
|
82
92
|
*/
|
|
83
93
|
isProcessAlive(runId) {
|
|
94
|
+
assertSafeRunId(runId);
|
|
84
95
|
const data = this.read(runId);
|
|
85
96
|
if (!data)
|
|
86
97
|
return false;
|
|
@@ -118,6 +129,7 @@ export class Heartbeat {
|
|
|
118
129
|
}
|
|
119
130
|
}
|
|
120
131
|
filePath(runId) {
|
|
132
|
+
assertSafeRunId(runId);
|
|
121
133
|
return join(this.runsDir, runId, "heartbeat");
|
|
122
134
|
}
|
|
123
135
|
}
|
|
@@ -16,6 +16,7 @@ import type { AskUserFn } from "../tool-system/builtin/ask-user.js";
|
|
|
16
16
|
export interface PendingApproval {
|
|
17
17
|
resolve: (result: ApprovalResult) => void;
|
|
18
18
|
request: ApprovalRequest;
|
|
19
|
+
timer: ReturnType<typeof setTimeout>;
|
|
19
20
|
}
|
|
20
21
|
export interface PendingInput {
|
|
21
22
|
resolve: (answer: string) => void;
|
|
@@ -32,6 +33,7 @@ export interface RunLifecycleHooks {
|
|
|
32
33
|
export declare class RunApprovalBackend implements ApprovalBackend {
|
|
33
34
|
private pendingApproval;
|
|
34
35
|
private hooks;
|
|
36
|
+
private approvalRequestSeq;
|
|
35
37
|
setHooks(hooks: RunLifecycleHooks): void;
|
|
36
38
|
/** Max time (ms) to wait for approval before auto-rejecting. Default: 24h */
|
|
37
39
|
private timeoutMs;
|
|
@@ -42,6 +44,7 @@ export declare class RunApprovalBackend implements ApprovalBackend {
|
|
|
42
44
|
*/
|
|
43
45
|
resolveApproval(result: ApprovalResult): boolean;
|
|
44
46
|
hasPendingApproval(): boolean;
|
|
47
|
+
private supersedePendingApproval;
|
|
45
48
|
}
|
|
46
49
|
/**
|
|
47
50
|
* Creates an AskUserFn that suspends the run when user input is needed.
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
export class RunApprovalBackend {
|
|
15
15
|
pendingApproval = null;
|
|
16
16
|
hooks = null;
|
|
17
|
+
approvalRequestSeq = 0;
|
|
17
18
|
setHooks(hooks) {
|
|
18
19
|
this.hooks = hooks;
|
|
19
20
|
}
|
|
@@ -23,7 +24,8 @@ export class RunApprovalBackend {
|
|
|
23
24
|
this.timeoutMs = ms;
|
|
24
25
|
}
|
|
25
26
|
async requestApproval(request) {
|
|
26
|
-
|
|
27
|
+
const hooks = this.hooks;
|
|
28
|
+
if (!hooks) {
|
|
27
29
|
// No hooks wired — fail CLOSED. This backend is the engine's approval
|
|
28
30
|
// path for an interactive run; if a host forgot to setHooks() there is
|
|
29
31
|
// no UI to ask, so auto-approving every tool (the old behavior) silently
|
|
@@ -34,11 +36,8 @@ export class RunApprovalBackend {
|
|
|
34
36
|
reason: "run approval backend has no lifecycle hooks wired (denied fail-closed)",
|
|
35
37
|
};
|
|
36
38
|
}
|
|
37
|
-
|
|
38
|
-
const { approvalId } = await this.hooks.onApprovalNeeded(request);
|
|
39
|
-
// Suspend execution until resolved (with timeout safety net)
|
|
39
|
+
const requestSeq = ++this.approvalRequestSeq;
|
|
40
40
|
return new Promise((resolve) => {
|
|
41
|
-
this.pendingApproval = { resolve, request };
|
|
42
41
|
const timer = setTimeout(() => {
|
|
43
42
|
if (this.pendingApproval?.resolve === resolve) {
|
|
44
43
|
this.pendingApproval = null;
|
|
@@ -48,6 +47,30 @@ export class RunApprovalBackend {
|
|
|
48
47
|
if (typeof timer === "object" && "unref" in timer) {
|
|
49
48
|
timer.unref();
|
|
50
49
|
}
|
|
50
|
+
this.supersedePendingApproval();
|
|
51
|
+
this.pendingApproval = { resolve, request, timer };
|
|
52
|
+
void Promise.resolve()
|
|
53
|
+
.then(() => hooks.onApprovalNeeded(request))
|
|
54
|
+
.then(() => {
|
|
55
|
+
if (requestSeq !== this.approvalRequestSeq &&
|
|
56
|
+
this.pendingApproval?.resolve === resolve) {
|
|
57
|
+
this.pendingApproval = null;
|
|
58
|
+
clearTimeout(timer);
|
|
59
|
+
resolve({
|
|
60
|
+
approved: false,
|
|
61
|
+
reason: "superseded: a newer approval request replaced this one before it was answered",
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
}, (err) => {
|
|
65
|
+
if (this.pendingApproval?.resolve !== resolve)
|
|
66
|
+
return;
|
|
67
|
+
this.pendingApproval = null;
|
|
68
|
+
clearTimeout(timer);
|
|
69
|
+
resolve({
|
|
70
|
+
approved: false,
|
|
71
|
+
reason: `approval request failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
72
|
+
});
|
|
73
|
+
});
|
|
51
74
|
});
|
|
52
75
|
}
|
|
53
76
|
/**
|
|
@@ -56,14 +79,26 @@ export class RunApprovalBackend {
|
|
|
56
79
|
resolveApproval(result) {
|
|
57
80
|
if (!this.pendingApproval)
|
|
58
81
|
return false;
|
|
59
|
-
const { resolve } = this.pendingApproval;
|
|
82
|
+
const { resolve, timer } = this.pendingApproval;
|
|
60
83
|
this.pendingApproval = null;
|
|
84
|
+
clearTimeout(timer);
|
|
61
85
|
resolve(result);
|
|
62
86
|
return true;
|
|
63
87
|
}
|
|
64
88
|
hasPendingApproval() {
|
|
65
89
|
return this.pendingApproval !== null;
|
|
66
90
|
}
|
|
91
|
+
supersedePendingApproval() {
|
|
92
|
+
if (!this.pendingApproval)
|
|
93
|
+
return;
|
|
94
|
+
const { resolve, timer } = this.pendingApproval;
|
|
95
|
+
this.pendingApproval = null;
|
|
96
|
+
clearTimeout(timer);
|
|
97
|
+
resolve({
|
|
98
|
+
approved: false,
|
|
99
|
+
reason: "superseded: a newer approval request replaced this one before it was answered",
|
|
100
|
+
});
|
|
101
|
+
}
|
|
67
102
|
}
|
|
68
103
|
// ─── RunAskUserAdapter ───────────────────────────────────────────
|
|
69
104
|
/**
|
package/dist/run/RunLock.js
CHANGED
|
@@ -10,6 +10,7 @@ import { join } from "node:path";
|
|
|
10
10
|
import { homedir } from "node:os";
|
|
11
11
|
import { lock, check, unlock } from "../utils/lockfile.js";
|
|
12
12
|
import { logger } from "../logging/logger.js";
|
|
13
|
+
import { assertSafeRunId } from "./ids.js";
|
|
13
14
|
export class RunLock {
|
|
14
15
|
runsDir;
|
|
15
16
|
staleMs;
|
|
@@ -135,6 +136,7 @@ export class RunLock {
|
|
|
135
136
|
}
|
|
136
137
|
}
|
|
137
138
|
lockTarget(runId) {
|
|
139
|
+
assertSafeRunId(runId);
|
|
138
140
|
return join(this.runsDir, runId, "run.json");
|
|
139
141
|
}
|
|
140
142
|
}
|
package/dist/run/RunManager.d.ts
CHANGED
|
@@ -66,6 +66,7 @@ export declare class RunManager {
|
|
|
66
66
|
resume(runId: string, input?: ResumeRunInput): Promise<void>;
|
|
67
67
|
private resumeInner;
|
|
68
68
|
cancel(runId: string, reason?: string): Promise<void>;
|
|
69
|
+
private cancelInner;
|
|
69
70
|
get(runId: string): Promise<RunSnapshot | null>;
|
|
70
71
|
list(query?: ListRunsQuery): Promise<RunSnapshot[]>;
|
|
71
72
|
getEvents(runId: string): Promise<RunEvent[]>;
|
|
@@ -90,5 +91,6 @@ export declare class RunManager {
|
|
|
90
91
|
private transition;
|
|
91
92
|
private emitRunEvent;
|
|
92
93
|
private notifySubscribers;
|
|
94
|
+
private getPendingApprovalForResume;
|
|
93
95
|
private getOrThrow;
|
|
94
96
|
}
|
package/dist/run/RunManager.js
CHANGED
|
@@ -19,7 +19,13 @@ import { ArtifactTracker } from "./ArtifactTracker.js";
|
|
|
19
19
|
import { RunLock } from "./RunLock.js";
|
|
20
20
|
import { Heartbeat } from "./Heartbeat.js";
|
|
21
21
|
import { NoopEvaluator } from "./Evaluator.js";
|
|
22
|
+
import { assertSafeRunFileId, assertSafeRunId } from "./ids.js";
|
|
22
23
|
import { VALID_TRANSITIONS } from "./types.js";
|
|
24
|
+
function hasOwnString(value, key) {
|
|
25
|
+
return (value !== undefined &&
|
|
26
|
+
Object.prototype.hasOwnProperty.call(value, key) &&
|
|
27
|
+
typeof value[key] === "string");
|
|
28
|
+
}
|
|
23
29
|
export class RunManager {
|
|
24
30
|
store;
|
|
25
31
|
queue;
|
|
@@ -69,6 +75,9 @@ export class RunManager {
|
|
|
69
75
|
async submit(input) {
|
|
70
76
|
const now = Date.now();
|
|
71
77
|
const runId = nanoid(16);
|
|
78
|
+
if (input.parentRunId !== undefined && input.parentRunId !== null) {
|
|
79
|
+
assertSafeRunId(input.parentRunId);
|
|
80
|
+
}
|
|
72
81
|
const snapshot = {
|
|
73
82
|
runId,
|
|
74
83
|
objective: input.objective,
|
|
@@ -110,6 +119,7 @@ export class RunManager {
|
|
|
110
119
|
}
|
|
111
120
|
// ─── Resume ────────────────────────────────────────────────────
|
|
112
121
|
async resume(runId, input) {
|
|
122
|
+
assertSafeRunId(runId);
|
|
113
123
|
// Serialize resume/cancel decisions per run. Without this, two concurrent
|
|
114
124
|
// resumes (or resume racing cancel) both pass the status check below —
|
|
115
125
|
// status stays `waiting_*` until a later `await transition()` — and both
|
|
@@ -139,18 +149,15 @@ export class RunManager {
|
|
|
139
149
|
if (handle) {
|
|
140
150
|
if (run.status === "waiting_approval" && input?.approvalDecision) {
|
|
141
151
|
const { approvalId, approved, reason } = input.approvalDecision;
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
reason,
|
|
152
|
-
});
|
|
153
|
-
}
|
|
152
|
+
const approval = await this.getPendingApprovalForResume(run, approvalId);
|
|
153
|
+
approval.status = approved ? "approved" : "rejected";
|
|
154
|
+
approval.resolvedAt = Date.now();
|
|
155
|
+
await this.store.saveApproval(approval);
|
|
156
|
+
await this.emitRunEvent(runId, "approval_resolved", {
|
|
157
|
+
approvalId,
|
|
158
|
+
approved,
|
|
159
|
+
reason,
|
|
160
|
+
});
|
|
154
161
|
// Transition back to running and resolve the suspended Engine
|
|
155
162
|
await this.transition(run, "queued");
|
|
156
163
|
await this.transition(run, "running");
|
|
@@ -158,7 +165,7 @@ export class RunManager {
|
|
|
158
165
|
handle.resolveApproval(approved, reason);
|
|
159
166
|
return;
|
|
160
167
|
}
|
|
161
|
-
if (run.status === "waiting_input" && input
|
|
168
|
+
if (run.status === "waiting_input" && hasOwnString(input, "userInput")) {
|
|
162
169
|
await this.transition(run, "queued");
|
|
163
170
|
await this.transition(run, "running");
|
|
164
171
|
await this.emitRunEvent(runId, "run_resumed", { userInput: input.userInput });
|
|
@@ -177,17 +184,15 @@ export class RunManager {
|
|
|
177
184
|
// Re-queue the run for a fresh execution attempt
|
|
178
185
|
if (input?.approvalDecision) {
|
|
179
186
|
const { approvalId, approved, reason } = input.approvalDecision;
|
|
180
|
-
const approval = await this.
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
});
|
|
190
|
-
}
|
|
187
|
+
const approval = await this.getPendingApprovalForResume(run, approvalId);
|
|
188
|
+
approval.status = approved ? "approved" : "rejected";
|
|
189
|
+
approval.resolvedAt = Date.now();
|
|
190
|
+
await this.store.saveApproval(approval);
|
|
191
|
+
await this.emitRunEvent(runId, "approval_resolved", {
|
|
192
|
+
approvalId,
|
|
193
|
+
approved,
|
|
194
|
+
reason,
|
|
195
|
+
});
|
|
191
196
|
}
|
|
192
197
|
await this.transition(run, "queued");
|
|
193
198
|
await this.emitRunEvent(runId, "run_resumed", {
|
|
@@ -197,6 +202,19 @@ export class RunManager {
|
|
|
197
202
|
}
|
|
198
203
|
// ─── Cancel ────────────────────────────────────────────────────
|
|
199
204
|
async cancel(runId, reason) {
|
|
205
|
+
assertSafeRunId(runId);
|
|
206
|
+
if (this.resolvingRuns.has(runId)) {
|
|
207
|
+
throw new Error(`Cannot cancel run ${runId}: another resume/cancel is already in progress`);
|
|
208
|
+
}
|
|
209
|
+
this.resolvingRuns.add(runId);
|
|
210
|
+
try {
|
|
211
|
+
await this.cancelInner(runId, reason);
|
|
212
|
+
}
|
|
213
|
+
finally {
|
|
214
|
+
this.resolvingRuns.delete(runId);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
async cancelInner(runId, reason) {
|
|
200
218
|
const run = await this.getOrThrow(runId);
|
|
201
219
|
if (run.status === "completed" || run.status === "failed" || run.status === "cancelled") {
|
|
202
220
|
throw new Error(`Cannot cancel run ${runId}: already in terminal state ${run.status}`);
|
|
@@ -225,16 +243,19 @@ export class RunManager {
|
|
|
225
243
|
}
|
|
226
244
|
// ─── Query ─────────────────────────────────────────────────────
|
|
227
245
|
async get(runId) {
|
|
246
|
+
assertSafeRunId(runId);
|
|
228
247
|
return this.store.get(runId);
|
|
229
248
|
}
|
|
230
249
|
async list(query) {
|
|
231
250
|
return this.store.list(query);
|
|
232
251
|
}
|
|
233
252
|
async getEvents(runId) {
|
|
253
|
+
assertSafeRunId(runId);
|
|
234
254
|
return this.store.listEvents(runId);
|
|
235
255
|
}
|
|
236
256
|
// ─── Attach (live stream) ──────────────────────────────────────
|
|
237
257
|
attach(runId, cb) {
|
|
258
|
+
assertSafeRunId(runId);
|
|
238
259
|
if (!this.subscribers.has(runId)) {
|
|
239
260
|
this.subscribers.set(runId, new Set());
|
|
240
261
|
}
|
|
@@ -326,6 +347,7 @@ export class RunManager {
|
|
|
326
347
|
}
|
|
327
348
|
// ─── Internal: Execute a run ───────────────────────────────────
|
|
328
349
|
async executeRun(runId) {
|
|
350
|
+
assertSafeRunId(runId);
|
|
329
351
|
const run = await this.getOrThrow(runId);
|
|
330
352
|
// Acquire run lock — prevents concurrent execution by multiple workers
|
|
331
353
|
const lockResult = await this.lock.acquire(runId);
|
|
@@ -657,7 +679,25 @@ export class RunManager {
|
|
|
657
679
|
}
|
|
658
680
|
}
|
|
659
681
|
// ─── Helpers ───────────────────────────────────────────────────
|
|
682
|
+
async getPendingApprovalForResume(run, approvalId) {
|
|
683
|
+
assertSafeRunFileId(approvalId, "approval id");
|
|
684
|
+
if (run.latestApprovalId !== approvalId) {
|
|
685
|
+
throw new Error(`Cannot resume run ${run.runId}: approval ${approvalId} is not the latest pending approval`);
|
|
686
|
+
}
|
|
687
|
+
const approval = await this.store.getApproval(run.runId, approvalId);
|
|
688
|
+
if (!approval) {
|
|
689
|
+
throw new Error(`Cannot resume run ${run.runId}: approval ${approvalId} was not found`);
|
|
690
|
+
}
|
|
691
|
+
if (approval.runId !== run.runId || approval.approvalId !== approvalId) {
|
|
692
|
+
throw new Error(`Cannot resume run ${run.runId}: approval ${approvalId} does not match this run`);
|
|
693
|
+
}
|
|
694
|
+
if (approval.status !== "pending") {
|
|
695
|
+
throw new Error(`Cannot resume run ${run.runId}: approval ${approvalId} is ${approval.status}, not pending`);
|
|
696
|
+
}
|
|
697
|
+
return approval;
|
|
698
|
+
}
|
|
660
699
|
async getOrThrow(runId) {
|
|
700
|
+
assertSafeRunId(runId);
|
|
661
701
|
const run = await this.store.get(runId);
|
|
662
702
|
if (!run)
|
|
663
703
|
throw new Error(`Run not found: ${runId}`);
|
package/dist/run/ids.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
function assertSafeRunSegment(value, label) {
|
|
2
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
3
|
+
throw new Error(`invalid ${label}: must be a non-empty string`);
|
|
4
|
+
}
|
|
5
|
+
if (value.includes("/") || value.includes("\\")) {
|
|
6
|
+
throw new Error(`invalid ${label}: contains path separator: ${value}`);
|
|
7
|
+
}
|
|
8
|
+
if (value === "." || value === ".." || value.includes("..")) {
|
|
9
|
+
throw new Error(`invalid ${label}: contains parent-dir token: ${value}`);
|
|
10
|
+
}
|
|
11
|
+
if (!/^[A-Za-z0-9_.-]+$/.test(value)) {
|
|
12
|
+
throw new Error(`invalid ${label}: unexpected characters: ${value}`);
|
|
13
|
+
}
|
|
14
|
+
if (value.length > 128) {
|
|
15
|
+
throw new Error(`invalid ${label}: too long (max 128 chars)`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export function assertSafeRunId(runId) {
|
|
19
|
+
assertSafeRunSegment(runId, "run id");
|
|
20
|
+
}
|
|
21
|
+
export function assertSafeRunFileId(value, label) {
|
|
22
|
+
assertSafeRunSegment(value, label);
|
|
23
|
+
}
|
|
@@ -1,12 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Session lifecycle manager.
|
|
3
3
|
*/
|
|
4
|
-
import type { SessionState } from "../types.js";
|
|
4
|
+
import type { SessionState, SessionWorkspace } from "../types.js";
|
|
5
5
|
import { Transcript } from "./transcript.js";
|
|
6
6
|
export interface SessionBundle {
|
|
7
7
|
state: SessionState;
|
|
8
8
|
transcript: Transcript;
|
|
9
9
|
}
|
|
10
|
+
export type SessionWorkspaceResumeResolution = {
|
|
11
|
+
ok: true;
|
|
12
|
+
cwd: string;
|
|
13
|
+
workspace: SessionWorkspace;
|
|
14
|
+
message?: string;
|
|
15
|
+
reason?: "main" | "worktree" | "legacy" | "worktree_missing_branch_gone";
|
|
16
|
+
} | {
|
|
17
|
+
ok: false;
|
|
18
|
+
cwd: string;
|
|
19
|
+
workspace: SessionWorkspace;
|
|
20
|
+
message: string;
|
|
21
|
+
reason: "worktree_missing_branch_exists";
|
|
22
|
+
};
|
|
10
23
|
/**
|
|
11
24
|
* Validate a session ID before it is joined into a filesystem path.
|
|
12
25
|
*
|
|
@@ -57,6 +70,27 @@ export declare class SessionManager {
|
|
|
57
70
|
* caller treats "no recoverable cwd" the same as "not given".
|
|
58
71
|
*/
|
|
59
72
|
readCwd(sessionId: string): string | undefined;
|
|
73
|
+
/**
|
|
74
|
+
* Disk-only workspace pointer reader. Legacy sessions written before
|
|
75
|
+
* `workspace` existed are treated as main-workspace sessions rooted at
|
|
76
|
+
* `state.cwd`; the read is intentionally non-mutating.
|
|
77
|
+
*/
|
|
78
|
+
getSessionWorkspace(sessionId: string): SessionWorkspace | undefined;
|
|
79
|
+
/**
|
|
80
|
+
* Persist the current session workspace pointer without changing legacy
|
|
81
|
+
* `cwd`. P1 will teach ToolContext to resolve cwd from this field; for P0 it
|
|
82
|
+
* is a safety pointer and resume breadcrumb.
|
|
83
|
+
*/
|
|
84
|
+
setSessionWorkspace(sessionId: string, workspace: SessionWorkspace): void;
|
|
85
|
+
recordWorkspaceHandoff(sessionId: string, from: SessionWorkspace | undefined, to: SessionWorkspace): void;
|
|
86
|
+
/**
|
|
87
|
+
* Resolve the cwd a resumed session must run in from its persisted workspace
|
|
88
|
+
* pointer. A missing worktree directory is never silently treated as the main
|
|
89
|
+
* repo: if the branch still exists callers get a blocking recreate message;
|
|
90
|
+
* if the branch is gone the workspace pointer is reset to main and a warning
|
|
91
|
+
* message is returned for the host to surface before continuing.
|
|
92
|
+
*/
|
|
93
|
+
resolveSessionWorkspaceForResume(sessionId: string): SessionWorkspaceResumeResolution;
|
|
60
94
|
/**
|
|
61
95
|
* Cheap "does this session have a persisted goal?" probe — reads only
|
|
62
96
|
* state.json, NOT the transcript (like readCwd). A persistent goal lives ONLY
|