@ferris1225/pi-subagents 4.3.7 → 4.3.9

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.
@@ -0,0 +1,168 @@
1
+ import { resolve } from "node:path";
2
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+ import { Type } from "typebox";
4
+ import { runCommand } from "../isolation/git-command.ts";
5
+
6
+ export type SubagentRiskCategory =
7
+ | "concurrency"
8
+ | "trust-boundary"
9
+ | "persistence-compatibility"
10
+ | "failure-cancellation";
11
+
12
+ export type GitRiskRunner = (
13
+ cwd: string,
14
+ args: readonly string[],
15
+ signal?: AbortSignal,
16
+ ) => Promise<string>;
17
+
18
+ export interface SubagentRiskAdvisory {
19
+ available: boolean;
20
+ changedPaths: string[];
21
+ categories: SubagentRiskCategory[];
22
+ matches: Partial<Record<SubagentRiskCategory, string[]>>;
23
+ recommendSentinel: boolean;
24
+ unavailableReason?: string;
25
+ }
26
+
27
+ const CATEGORY_RULES: ReadonlyArray<{
28
+ category: SubagentRiskCategory;
29
+ pattern: RegExp;
30
+ }> = [
31
+ {
32
+ category: "concurrency",
33
+ pattern: /(?:^|[/_.-])(?:concurr(?:ency|ent)?|parallel|queues?|workers?|threads?|locks?|mutex|semaphore|dispatch|background|races?|lane)(?:[/_.-]|$)/u,
34
+ },
35
+ {
36
+ category: "trust-boundary",
37
+ pattern: /(?:^|[/_.-])(?:auth|credentials?|permissions?|policy|privilege|secrets?|security|sandbox|trust|tokens?)(?:[/_.-]|$)/u,
38
+ },
39
+ {
40
+ category: "persistence-compatibility",
41
+ pattern: /(?:^|[/_.-])(?:compat(?:ibility)?|durable|manifests?|migrations?|persist(?:ence|ent)?|restore|schemas?|serializ(?:e|ation)|storage)(?:[/_.-]|$)/u,
42
+ },
43
+ {
44
+ category: "failure-cancellation",
45
+ pattern: /(?:^|[/_.-])(?:abort(?:ed|ion|s)?|cancel(?:ed|lation|led)?|errors?|fail(?:ed|ures?)?|recovery|retries|retry|stop|timeout)(?:[/_.-]|$)/u,
46
+ },
47
+ ];
48
+
49
+ function normalizedGitPath(path: string): string {
50
+ return path.replaceAll("\\", "/").replace(/^\.\//u, "");
51
+ }
52
+
53
+ export function classifyRiskPaths(paths: readonly string[]): Pick<
54
+ SubagentRiskAdvisory,
55
+ "categories" | "matches" | "recommendSentinel"
56
+ > {
57
+ const normalized = [...new Set(paths.map(normalizedGitPath).filter(Boolean))].sort();
58
+ const matches: Partial<Record<SubagentRiskCategory, string[]>> = {};
59
+ const categories: SubagentRiskCategory[] = [];
60
+ for (const rule of CATEGORY_RULES) {
61
+ const matching = normalized.filter((path) => rule.pattern.test(path.toLowerCase()));
62
+ if (matching.length === 0) continue;
63
+ categories.push(rule.category);
64
+ matches[rule.category] = matching;
65
+ }
66
+ return { categories, matches, recommendSentinel: categories.length > 0 };
67
+ }
68
+
69
+ const RISK_GIT_TIMEOUT_MS = 30_000;
70
+ const RISK_GIT_OUTPUT_MAX_BYTES = 4 * 1024 * 1024;
71
+
72
+ const defaultGitRunner: GitRiskRunner = async (cwd, args, signal) => {
73
+ const result = await runCommand("git", args, {
74
+ cwd,
75
+ signal,
76
+ timeoutMs: RISK_GIT_TIMEOUT_MS,
77
+ maxOutputBytes: RISK_GIT_OUTPUT_MAX_BYTES,
78
+ });
79
+ if (result.code !== 0) {
80
+ const detail = result.stderr.toString("utf8").trim();
81
+ throw new Error(detail || `git ${args[0] ?? "command"} exited with code ${result.code}`);
82
+ }
83
+ return result.stdout.toString("utf8");
84
+ };
85
+
86
+ function isCancellation(error: unknown, signal?: AbortSignal): boolean {
87
+ return signal?.aborted === true || (error instanceof Error && error.name === "AbortError");
88
+ }
89
+
90
+ function nulPaths(output: string): string[] {
91
+ return output.split("\0").map(normalizedGitPath).filter(Boolean);
92
+ }
93
+
94
+ /** Inspect repository-root-relative tracked and untracked changes without a model call.
95
+ * Non-cancellation Git failures are advisory-unavailable and never block work. */
96
+ export async function analyzeSubagentRisk(
97
+ cwd: string,
98
+ runGit: GitRiskRunner = defaultGitRunner,
99
+ signal?: AbortSignal,
100
+ ): Promise<SubagentRiskAdvisory> {
101
+ signal?.throwIfAborted();
102
+ const resolvedCwd = resolve(cwd);
103
+ try {
104
+ const topLevel = (await runGit(resolvedCwd, ["rev-parse", "--show-toplevel"], signal)).trim();
105
+ signal?.throwIfAborted();
106
+ if (!topLevel) throw new Error("Git returned an empty repository top-level path.");
107
+ const repositoryRoot = resolve(topLevel);
108
+ const [tracked, untracked] = await Promise.all([
109
+ runGit(repositoryRoot, ["diff", "--name-only", "-z", "HEAD"], signal),
110
+ runGit(repositoryRoot, ["ls-files", "--others", "--exclude-standard", "-z"], signal),
111
+ ]);
112
+ signal?.throwIfAborted();
113
+ const changedPaths = [...new Set([...nulPaths(tracked), ...nulPaths(untracked)])].sort();
114
+ const classification = classifyRiskPaths(changedPaths);
115
+ return { available: true, changedPaths, ...classification };
116
+ } catch (error) {
117
+ if (isCancellation(error, signal)) throw error;
118
+ return {
119
+ available: false,
120
+ changedPaths: [],
121
+ categories: [],
122
+ matches: {},
123
+ recommendSentinel: false,
124
+ unavailableReason: error instanceof Error ? error.message : String(error),
125
+ };
126
+ }
127
+ }
128
+
129
+ export function registerSubagentRiskTool(pi: ExtensionAPI): void {
130
+ pi.registerTool({
131
+ name: "subagent_risk",
132
+ label: "Subagent Risk",
133
+ description: "Advisory-only, no-model-call inspection of repository-root-relative tracked and untracked changes from HEAD, even when called from a nested cwd. Applies fixed path rules for concurrency, trust-boundary, persistence-compatibility, and failure-cancellation risk, and reports whether a fresh Sentinel review is suggested. It never dispatches a child or blocks work.",
134
+ parameters: Type.Object({
135
+ cwd: Type.Optional(Type.String({ description: "Repository working directory; defaults to the current caller cwd." })),
136
+ }),
137
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
138
+ const advisory = await analyzeSubagentRisk(
139
+ resolve(ctx.cwd, params.cwd ?? "."),
140
+ defaultGitRunner,
141
+ signal,
142
+ );
143
+ if (!advisory.available) {
144
+ return {
145
+ content: [{
146
+ type: "text",
147
+ text: `Sentinel risk advisory unavailable: ${advisory.unavailableReason ?? "Git could not inspect the working tree"}. Advisory only; no child was dispatched and work was not blocked.`,
148
+ }],
149
+ details: advisory,
150
+ };
151
+ }
152
+ const changed = advisory.changedPaths.length > 0
153
+ ? advisory.changedPaths.map((path) => `- ${path}`).join("\n")
154
+ : "- (none)";
155
+ const categories = advisory.categories.length > 0 ? advisory.categories.join(", ") : "none";
156
+ const recommendation = advisory.recommendSentinel
157
+ ? "Sentinel suggested by fixed path rules. Dispatch remains the main agent's decision."
158
+ : "Sentinel not suggested by fixed path rules.";
159
+ return {
160
+ content: [{
161
+ type: "text",
162
+ text: `Changed paths relative to HEAD:\n${changed}\nRisk categories: ${categories}\n${recommendation} Advisory only; no child was dispatched and work was not blocked.`,
163
+ }],
164
+ details: advisory,
165
+ };
166
+ },
167
+ });
168
+ }
@@ -19,6 +19,7 @@ import { mkdir, readFile, realpath, rename, rm, writeFile } from "node:fs/promis
19
19
  import { uptime } from "node:os";
20
20
  import { dirname, isAbsolute, join, relative, resolve } from "node:path";
21
21
  import type { UsageStats } from "../execution/rpc-control.ts";
22
+ import { normalizePhaseId, normalizePhaseScope, type PhaseScope } from "../delegation/phase-scope.ts";
22
23
  import type { SubagentThread } from "./runtime.ts";
23
24
  import { getResultOutput, isFailedResult, getProjectRoot, getSubagentsRoot, type SingleResult } from "../execution/spawn.ts";
24
25
  import { isManagedSessionDir, isManagedWorktreeLayout, samePath } from "../isolation/managed-paths.ts";
@@ -76,6 +77,9 @@ export interface ThreadRecord {
76
77
  generation: number;
77
78
  agentName: string;
78
79
  task: string;
80
+ phaseId?: string;
81
+ scope?: PhaseScope;
82
+ writeCapable?: boolean;
79
83
  cwd: string;
80
84
  executionCwd: string;
81
85
  /** Resolved (clamped) level of the last generation. */
@@ -166,6 +170,14 @@ function normalizeRecord(value: unknown): ThreadRecord | undefined {
166
170
  if (raw.state !== "parked" && raw.state !== "completed" && raw.state !== "failed") return undefined;
167
171
  const worktree = raw.worktree === undefined ? undefined : normalizeWorktreeSnapshot(raw.worktree);
168
172
  if (worktree === null) return undefined;
173
+ let phaseId: string | undefined;
174
+ let scope: PhaseScope | undefined;
175
+ try {
176
+ phaseId = normalizePhaseId(raw.phaseId as string | undefined);
177
+ scope = normalizePhaseScope(raw.scope as Parameters<typeof normalizePhaseScope>[0], raw.cwd);
178
+ } catch {
179
+ return undefined;
180
+ }
169
181
  return {
170
182
  runId: raw.runId,
171
183
  createdAt: raw.createdAt,
@@ -173,6 +185,9 @@ function normalizeRecord(value: unknown): ThreadRecord | undefined {
173
185
  generation: typeof raw.generation === "number" && Number.isInteger(raw.generation) && raw.generation >= 0 ? raw.generation : 0,
174
186
  agentName: raw.agentName,
175
187
  task: raw.task,
188
+ ...(phaseId ? { phaseId } : {}),
189
+ ...(scope ? { scope } : {}),
190
+ ...(typeof raw.writeCapable === "boolean" ? { writeCapable: raw.writeCapable } : {}),
176
191
  cwd: raw.cwd,
177
192
  executionCwd: typeof raw.executionCwd === "string" && raw.executionCwd ? raw.executionCwd : raw.cwd,
178
193
  ...(typeof raw.thinkingLevel === "string" && raw.thinkingLevel ? { thinkingLevel: raw.thinkingLevel } : {}),
@@ -375,6 +390,9 @@ export function threadRecordFromThread(
375
390
  generation: thread.generation,
376
391
  agentName: thread.agentName,
377
392
  task: thread.task,
393
+ ...(thread.phaseId ? { phaseId: thread.phaseId } : {}),
394
+ ...(thread.scope ? { scope: thread.scope } : {}),
395
+ ...(thread.writeCapable !== undefined ? { writeCapable: thread.writeCapable } : {}),
378
396
  cwd: thread.cwd,
379
397
  executionCwd: thread.executionCwd,
380
398
  ...(thread.thinkingLevel ? { thinkingLevel: thread.thinkingLevel } : {}),
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
12
+ import type { PhaseScope, PhaseScopeInput } from "../delegation/phase-scope.ts";
12
13
  import { rmSync } from "node:fs";
13
14
  import { resolveSubagentConcurrency, BackgroundTaskQueue } from "../execution/background.ts";
14
15
  import {
@@ -43,6 +44,12 @@ export interface SubagentThread {
43
44
  generation: number;
44
45
  agentName: string;
45
46
  task: string;
47
+ phaseId?: string;
48
+ scope?: PhaseScope;
49
+ /** Monotonic continuation scope visible while resume preflight is in flight. */
50
+ admissionScope?: PhaseScope;
51
+ /** Capability snapshot used by declared-scope admission, including after restore. */
52
+ writeCapable?: boolean;
46
53
  /** Caller-facing cwd in the original worktree. */
47
54
  cwd: string;
48
55
  /** Actual child cwd (the equivalent path inside an isolated worktree). */
@@ -77,7 +84,11 @@ export interface SubagentThread {
77
84
  retireOnSettle?: boolean;
78
85
  retired?: boolean;
79
86
  /** Installed by dispatch so the control tool can restart the same logical id. */
80
- resume: (objective?: string, ctx?: ExtensionContext) => Promise<SingleResult>;
87
+ resume: (
88
+ objective?: string,
89
+ ctx?: ExtensionContext,
90
+ metadata?: { scope?: PhaseScopeInput },
91
+ ) => Promise<SingleResult>;
81
92
  /** Dispatch-owned, generation-guarded worktree settlement hook. Its apply
82
93
  * runs under the canonical original-repository lane. */
83
94
  finalizeIsolation: (generation: number, result?: SingleResult) => Promise<WorktreeFinalization | undefined>;
@@ -28,6 +28,12 @@ import {
28
28
  } from "../presentation/format.ts";
29
29
  import { monitor } from "../presentation/monitor.ts";
30
30
  import { findDuplicateDispatch } from "../delegation/prompt.ts";
31
+ import {
32
+ findWriterLeaseScopeOverlap,
33
+ mergePhaseScopes,
34
+ normalizePhaseId,
35
+ normalizePhaseScope,
36
+ } from "../delegation/phase-scope.ts";
31
37
  import { persistRecoveryRecords, recoveryRecordFromFinalization } from "../isolation/recovery.ts";
32
38
  import type { SubagentRuntime, SubagentThread, ThreadState } from "./runtime.ts";
33
39
  import { forkRetainedSession } from "../execution/session-fork.ts";
@@ -105,6 +111,9 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
105
111
  startOptions: StartBackgroundOptions = {},
106
112
  ): Promise<SingleResult> => {
107
113
  const {
114
+ phaseId: requestedPhaseId,
115
+ scope: requestedScope,
116
+ writeCapable: requestedWriteCapable,
108
117
  existingThread,
109
118
  appendedObjectiveOnResume = false,
110
119
  environment,
@@ -127,16 +136,30 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
127
136
  const resolveLiveAgentTools = (candidate: AgentConfig): AgentConfig =>
128
137
  resolveAgentTools({ ...candidate, tools: discoveredAgent.tools }, runtime.getActiveTools());
129
138
  const agent = resolveLiveAgentTools(discoveredAgent);
139
+
140
+ const originalCwd = resolve(cwd ?? runCtx.cwd);
141
+ let phaseId: string | undefined;
142
+ let scope: ReturnType<typeof normalizePhaseScope>;
143
+ try {
144
+ phaseId = normalizePhaseId(existingThread ? existingThread.phaseId : requestedPhaseId);
145
+ const requested = normalizePhaseScope(requestedScope, originalCwd);
146
+ scope = existingThread ? mergePhaseScopes(existingThread.scope, requested) : requested;
147
+ } catch (error) {
148
+ return failedStartResult(agentName, task, error instanceof Error ? error.message : String(error));
149
+ }
150
+ const currentWriteCapable = isWriteCapableAgent(agent);
151
+ const priorWriteCapable = existingThread
152
+ ? (existingThread.writeCapable ?? existingThread.agentName !== "scout")
153
+ : Boolean(requestedWriteCapable);
154
+ const writeCapable = priorWriteCapable || currentWriteCapable;
130
155
  if (isolation === "worktree" && !isWorktreeCapableAgent(agent)) {
131
156
  return {
132
157
  ...failedStartResult(agentName, task, `Agent "${agentName}" is read-only; worktree isolation is available only to write-capable agents such as artisan.`),
133
158
  isolation,
134
159
  };
135
160
  }
136
-
137
- const originalCwd = resolve(cwd ?? runCtx.cwd);
138
161
  if (!existingThread) {
139
- const duplicate = findDuplicateDispatch(runtime.threads.values(), task, originalCwd);
162
+ const duplicate = findDuplicateDispatch(runtime.threads.values(), task, originalCwd, phaseId);
140
163
  if (duplicate?.kind === "active") {
141
164
  return failedStartResult(
142
165
  agentName,
@@ -150,7 +173,17 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
150
173
  return failedStartResult(
151
174
  agentName,
152
175
  task,
153
- `Run #${duplicate.source.id} (${duplicate.source.agentName}) already ${duplicate.source.state} this exact brief and kept its context; its result was delivered. Resume #${duplicate.source.id} with an appended objective instead of paying for a second run, or restate the brief with what changed.`,
176
+ `Run #${duplicate.source.id} (${duplicate.source.agentName}) already ${duplicate.source.state} this logical phase and kept its context; its result was delivered. Resume #${duplicate.source.id} with an appended objective instead of paying for a second run${phaseId ? "; keep using the same phaseId on that thread" : ", or restate the brief with what changed"}.`,
177
+ );
178
+ }
179
+ }
180
+ if (writeCapable && scope) {
181
+ const conflict = findWriterLeaseScopeOverlap(scope, runtime.threads.values(), existingThread?.id);
182
+ if (conflict) {
183
+ return failedStartResult(
184
+ agentName,
185
+ task,
186
+ `Declared writer scope ${conflict.overlap.left} overlaps active run #${conflict.lease.id} scope ${conflict.overlap.right}; no new generation was started.`,
154
187
  );
155
188
  }
156
189
  }
@@ -230,6 +263,9 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
230
263
  thread.generation = generation;
231
264
  thread.agentName = agent.name;
232
265
  thread.task = task;
266
+ thread.phaseId = phaseId;
267
+ thread.scope = scope;
268
+ thread.writeCapable = writeCapable;
233
269
  thread.cwd = originalCwd;
234
270
  thread.executionCwd = executionCwd;
235
271
  thread.thinkingLevel = thinkingLevel;
@@ -253,6 +289,9 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
253
289
  generation,
254
290
  agentName: agent.name,
255
291
  task,
292
+ phaseId,
293
+ scope,
294
+ writeCapable,
256
295
  cwd: originalCwd,
257
296
  executionCwd,
258
297
  thinkingLevel,
@@ -281,7 +320,7 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
281
320
  // Shared write-capable runs serialize on the repository lane so their
282
321
  // edits cannot race; the lane wait releases the process slot because it
283
322
  // is write serialization, not pool pacing.
284
- const reserveManagedLane = isolation === "shared" && isWriteCapableAgent(agent);
323
+ const reserveManagedLane = isolation === "shared" && writeCapable;
285
324
  const runGeneration = async (backgroundSignal: AbortSignal, controller: AbortController): Promise<void> => {
286
325
  if (runtime.threads.get(runId)?.generation !== generation) return;
287
326
  if (isolation === "worktree" && !worktree) {
@@ -718,7 +757,11 @@ export function installThreadLifecycle(thread: SubagentThread, deps: ThreadLifec
718
757
  });
719
758
  };
720
759
 
721
- thread.resume = async (objective?: string, resumeCtx?: ExtensionContext): Promise<SingleResult> => {
760
+ thread.resume = async (
761
+ objective?: string,
762
+ resumeCtx?: ExtensionContext,
763
+ metadata?: { scope?: Parameters<typeof normalizePhaseScope>[0] },
764
+ ): Promise<SingleResult> => {
722
765
  const requestedObjective = objective?.trim();
723
766
  if (!runtime.sessionActive || runtime.threads.get(runId) !== thread) {
724
767
  return failedStartResult(thread.agentName, thread.task, `Run #${runId} belongs to a parent session that has shut down.`);
@@ -726,6 +769,14 @@ export function installThreadLifecycle(thread: SubagentThread, deps: ThreadLifec
726
769
  if (objective !== undefined && !requestedObjective) {
727
770
  return failedStartResult(thread.agentName, thread.task, "resume objective must be non-blank when provided.");
728
771
  }
772
+ const continuationPhaseId = thread.phaseId;
773
+ let continuationScope: ReturnType<typeof normalizePhaseScope>;
774
+ try {
775
+ const additionalScope = normalizePhaseScope(metadata?.scope, thread.cwd);
776
+ continuationScope = mergePhaseScopes(thread.scope, additionalScope);
777
+ } catch (error) {
778
+ return failedStartResult(thread.agentName, thread.task, error instanceof Error ? error.message : String(error));
779
+ }
729
780
  if (thread.retired) return failedStartResult(thread.agentName, thread.task, `Run #${runId} was retired by subagent_stop.`);
730
781
  if (thread.resumeUnavailableReason) {
731
782
  return failedStartResult(thread.agentName, thread.task, thread.resumeUnavailableReason);
@@ -750,6 +801,7 @@ export function installThreadLifecycle(thread: SubagentThread, deps: ThreadLifec
750
801
  sessionId: previousSessionId,
751
802
  sessionDir: previousSessionDir,
752
803
  };
804
+ thread.admissionScope = continuationScope;
753
805
  thread.lifecycleOperation = "resume";
754
806
  thread.state = "resuming";
755
807
  const finishPreflight = beginRuntimePreflight(runtime);
@@ -763,6 +815,7 @@ export function installThreadLifecycle(thread: SubagentThread, deps: ThreadLifec
763
815
  // Never wait forever on a previous generation that is still settling
764
816
  // (e.g. blocked behind the managed repository lane in finalization).
765
817
  if (!(await quiesced(thread.generationCompletion))) {
818
+ if (ownsResumeReservation(runtime, thread, reservation)) thread.state = previousState;
766
819
  return failedStartResult(
767
820
  thread.agentName,
768
821
  thread.task,
@@ -829,6 +882,8 @@ export function installThreadLifecycle(thread: SubagentThread, deps: ThreadLifec
829
882
  thread.isolation,
830
883
  {
831
884
  existingThread: thread,
885
+ phaseId: continuationPhaseId,
886
+ scope: continuationScope,
832
887
  appendedObjectiveOnResume: objective !== undefined,
833
888
  environment: {
834
889
  ctx: currentCtx,
@@ -883,6 +938,7 @@ export function installThreadLifecycle(thread: SubagentThread, deps: ThreadLifec
883
938
  );
884
939
  } finally {
885
940
  finishPreflight();
941
+ if (thread.lifecycleVersion === reservation.version) thread.admissionScope = undefined;
886
942
  if (
887
943
  thread.lifecycleOperation === "resume" &&
888
944
  thread.lifecycleVersion === reservation.version
@@ -58,6 +58,9 @@ function createRestoredThread(
58
58
  generation: record.generation,
59
59
  agentName: record.agentName,
60
60
  task: record.task,
61
+ phaseId: record.phaseId,
62
+ scope: record.scope,
63
+ writeCapable: record.writeCapable ?? record.agentName !== "scout",
61
64
  cwd: record.cwd,
62
65
  executionCwd: record.executionCwd,
63
66
  ...(record.thinkingLevel ? { thinkingLevel: record.thinkingLevel as ThinkingLevel } : {}),
@@ -4,6 +4,7 @@ import { realpath } from "node:fs/promises";
4
4
  import { join, resolve } from "node:path";
5
5
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
6
6
  import { isWriteCapableAgent, type AgentConfig } from "../delegation/agents.ts";
7
+ import type { PhaseScope } from "../delegation/phase-scope.ts";
7
8
  import { roleThinkingLevel, type SubagentsConfig, type ThinkingLevel } from "../configuration/config.ts";
8
9
  import { removeThreadRecord, threadRecordFromThread, upsertThreadRecord } from "./durable.ts";
9
10
  import {
@@ -170,9 +171,10 @@ export function withWorktreeSystemPrompt(agent: AgentConfig): AgentConfig {
170
171
  };
171
172
  }
172
173
 
173
- /** Only write-capable agents can run in an isolated worktree. */
174
+ /** Only write-capable agents can run in an isolated worktree. Sentinel reviews
175
+ * the caller's uncommitted diff, which a detached worktree cannot contain. */
174
176
  export function isWorktreeCapableAgent(agent: AgentConfig): boolean {
175
- return isWriteCapableAgent(agent);
177
+ return agent.name !== "sentinel" && isWriteCapableAgent(agent);
176
178
  }
177
179
 
178
180
  export interface DispatchEnvironment {
@@ -194,9 +196,14 @@ export interface ResumeReservation {
194
196
  sessionDir?: string;
195
197
  }
196
198
 
197
- /** The dispatcher's full internal entry point; the public tool surface only
198
- * uses the first four parameters. */
199
+ /** Dispatcher's internal entry point. Public phase/scope claims are normalized
200
+ * into options; resume adds lifecycle-only continuation fields there too. */
199
201
  export interface StartBackgroundOptions {
202
+ /** Normalized identity and claims for a fresh or resumed generation. */
203
+ phaseId?: string;
204
+ scope?: PhaseScope;
205
+ /** Fresh-dispatch hint OR-merged with live capability; false cannot downgrade a writer. Resume stays monotonic. */
206
+ writeCapable?: boolean;
200
207
  /** Resume path only: the thread whose retained context continues. */
201
208
  existingThread?: SubagentThread;
202
209
  appendedObjectiveOnResume?: boolean;
@@ -34,6 +34,13 @@ function renderFirstLine(result: { content?: unknown }, label: string, theme: an
34
34
  }
35
35
 
36
36
  export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime): void {
37
+ const ResumeScopeSchema = Type.Optional(Type.Object({
38
+ paths: Type.Optional(Type.Array(Type.String({ minLength: 1, pattern: "\\S" }))),
39
+ symbols: Type.Optional(Type.Array(Type.Object({
40
+ path: Type.String({ minLength: 1, pattern: "\\S" }),
41
+ name: Type.String({ minLength: 1, pattern: "\\S" }),
42
+ }))),
43
+ }, { description: "Additional declarative write claims for resume; normalized claims are unioned with retained scope and cannot remove it. Scope is conflict metadata, not permissions or a sandbox." }));
37
44
  const SubagentControlParams = Type.Object({
38
45
  action: StringEnum(["steer", "resume", "park"] as const, {
39
46
  description:
@@ -43,6 +50,7 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
43
50
  objective: Type.Optional(
44
51
  Type.String({ description: "Guidance for steer (required and nonblank), or an optional appended objective for resume. Ignored by park." }),
45
52
  ),
53
+ scope: ResumeScopeSchema,
46
54
  });
47
55
 
48
56
  /** A thread that a steer can continue instead of reject: it is not live, but
@@ -52,7 +60,7 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
52
60
  pi.registerTool({
53
61
  name: "subagent_control",
54
62
  label: "Subagent Control",
55
- description: "Steer a running child with additional guidance (continuing it if it has settled or is parked), resume a parked/settled thread, or park a running thread at a stable checkpoint, by stable run id.",
63
+ description: "Steer a running child, resume a parked/settled thread, or park a running thread by stable run id. Resume keeps phaseId immutable and may only extend retained scope.",
56
64
  parameters: SubagentControlParams,
57
65
 
58
66
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
@@ -103,7 +111,9 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
103
111
  return textResult("resume objective must be non-blank when provided.");
104
112
  }
105
113
  const hadRetainedSession = Boolean(thread.sessionId && thread.sessionDir);
106
- const pending = await thread.resume(requestedObjective, ctx);
114
+ const pending = await thread.resume(requestedObjective, ctx, {
115
+ scope: params.scope,
116
+ });
107
117
  if (pending.exitCode !== -1) return textResult(getResultOutput(pending));
108
118
  const currentObjective = formatTaskSummary(requestedObjective ?? thread.task, 80, false);
109
119
  const mode = requestedObjective