@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.
Files changed (65) hide show
  1. package/dist/cc-orchestrator/cwd-normalize.d.ts +2 -0
  2. package/dist/cc-orchestrator/cwd-normalize.js +19 -0
  3. package/dist/cc-orchestrator/external-agent-bindings.d.ts +27 -0
  4. package/dist/cc-orchestrator/external-agent-bindings.js +150 -0
  5. package/dist/cc-orchestrator/external-agent-session-store.d.ts +23 -0
  6. package/dist/cc-orchestrator/external-agent-session-store.js +144 -0
  7. package/dist/context/manager.d.ts +3 -1
  8. package/dist/context/manager.js +24 -15
  9. package/dist/credentials/types.d.ts +2 -2
  10. package/dist/engine/engine.d.ts +5 -1
  11. package/dist/engine/engine.js +92 -51
  12. package/dist/engine/turn-loop.js +1 -1
  13. package/dist/git/worktree.d.ts +49 -6
  14. package/dist/git/worktree.js +265 -31
  15. package/dist/index.d.ts +4 -3
  16. package/dist/index.js +3 -2
  17. package/dist/logging/logger.js +6 -6
  18. package/dist/plugins/installer/installFromSource.js +9 -1
  19. package/dist/plugins/installer/sourcePath.d.ts +9 -0
  20. package/dist/plugins/installer/sourcePath.js +50 -0
  21. package/dist/plugins/pluginInstaller.js +24 -22
  22. package/dist/protocol/chat-session-manager.d.ts +1 -0
  23. package/dist/protocol/chat-session-manager.js +13 -0
  24. package/dist/protocol/server.d.ts +8 -0
  25. package/dist/protocol/server.js +45 -8
  26. package/dist/protocol/types.d.ts +4 -0
  27. package/dist/protocol/types.js +4 -0
  28. package/dist/run/FileRunStore.js +10 -1
  29. package/dist/run/Heartbeat.js +12 -0
  30. package/dist/run/RunApprovalBackend.d.ts +3 -0
  31. package/dist/run/RunApprovalBackend.js +41 -6
  32. package/dist/run/RunLock.js +2 -0
  33. package/dist/run/RunManager.d.ts +2 -0
  34. package/dist/run/RunManager.js +64 -24
  35. package/dist/run/ids.d.ts +2 -0
  36. package/dist/run/ids.js +23 -0
  37. package/dist/session/session-manager.d.ts +35 -1
  38. package/dist/session/session-manager.js +189 -2
  39. package/dist/settings/manager.d.ts +1 -0
  40. package/dist/settings/manager.js +45 -26
  41. package/dist/settings/schema-export.d.ts +2 -3
  42. package/dist/settings/schema-export.js +2 -3
  43. package/dist/tool-system/builtin/background-jobs.d.ts +8 -1
  44. package/dist/tool-system/builtin/background-jobs.js +8 -1
  45. package/dist/tool-system/builtin/config.d.ts +2 -1
  46. package/dist/tool-system/builtin/config.js +16 -11
  47. package/dist/tool-system/builtin/drive-claude-code.d.ts +15 -2
  48. package/dist/tool-system/builtin/drive-claude-code.js +174 -39
  49. package/dist/tool-system/builtin/edit.js +5 -2
  50. package/dist/tool-system/builtin/generate-video.d.ts +1 -0
  51. package/dist/tool-system/builtin/generate-video.js +13 -4
  52. package/dist/tool-system/builtin/index.js +5 -1
  53. package/dist/tool-system/builtin/lsp.d.ts +2 -1
  54. package/dist/tool-system/builtin/lsp.js +6 -3
  55. package/dist/tool-system/builtin/notebook-edit.js +5 -2
  56. package/dist/tool-system/builtin/read.js +5 -2
  57. package/dist/tool-system/builtin/worktree.d.ts +2 -4
  58. package/dist/tool-system/builtin/worktree.js +250 -75
  59. package/dist/tool-system/builtin/write.js +5 -3
  60. package/dist/tool-system/context.d.ts +12 -0
  61. package/dist/tool-system/mcp-manager.d.ts +8 -0
  62. package/dist/tool-system/mcp-manager.js +32 -11
  63. package/dist/types.d.ts +12 -0
  64. package/dist/utils/toolDisplay.js +1 -1
  65. package/package.json +1 -1
@@ -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 ? (result.answer ?? "") : (result.reason ?? "(user declined to answer)"));
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
  /**
@@ -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;
@@ -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 ────────────────────────────────────────────────────────
@@ -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 + ".tmp";
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) {
@@ -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
- if (!this.hooks) {
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
- // Notify RunManager that approval is needed
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
  /**
@@ -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
  }
@@ -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
  }
@@ -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
- // Persist the approval decision
143
- const approval = await this.store.getApproval(runId, approvalId);
144
- if (approval && approval.status === "pending") {
145
- approval.status = approved ? "approved" : "rejected";
146
- approval.resolvedAt = Date.now();
147
- await this.store.saveApproval(approval);
148
- await this.emitRunEvent(runId, "approval_resolved", {
149
- approvalId,
150
- approved,
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?.userInput) {
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.store.getApproval(runId, approvalId);
181
- if (approval && approval.status === "pending") {
182
- approval.status = approved ? "approved" : "rejected";
183
- approval.resolvedAt = Date.now();
184
- await this.store.saveApproval(approval);
185
- await this.emitRunEvent(runId, "approval_resolved", {
186
- approvalId,
187
- approved,
188
- reason,
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}`);
@@ -0,0 +1,2 @@
1
+ export declare function assertSafeRunId(runId: unknown): asserts runId is string;
2
+ export declare function assertSafeRunFileId(value: unknown, label: string): asserts value is string;
@@ -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