pi-claude-supervisor 0.5.4 → 0.6.0
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/CHANGELOG.md +18 -0
- package/README.cn.md +15 -13
- package/README.md +61 -24
- package/docs/architecture.md +67 -32
- package/docs/autonomy-target.md +42 -25
- package/docs/engineering-plan.md +15 -12
- package/docs/implementation-review.md +25 -22
- package/docs/independent-review.md +7 -6
- package/docs/testing.md +73 -37
- package/package.json +1 -1
- package/src/cwd-lease.ts +736 -49
- package/src/index.ts +83 -8
- package/src/policy.ts +21 -34
- package/src/supervisor.ts +19 -3
- package/src/types.ts +16 -3
- package/src/worker/environment.ts +187 -124
- package/src/worker/process-adapter.ts +60 -105
- package/src/worker/process-tree.ts +1 -59
- package/src/worker/tmux-adapter.ts +264 -121
package/src/index.ts
CHANGED
|
@@ -252,9 +252,10 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
|
|
|
252
252
|
throw new Error("An active, starting, or unreaped worker uses an overlapping cwd; use a separate worktree for concurrent sessions");
|
|
253
253
|
}
|
|
254
254
|
const taskId = randomUUID();
|
|
255
|
-
const lease = await cwdLeaseStore.acquire(cwdKey, taskId, adapter.capabilities().transport,
|
|
256
|
-
|
|
257
|
-
: {})
|
|
255
|
+
const lease = await cwdLeaseStore.acquire(cwdKey, taskId, adapter.capabilities().transport, {
|
|
256
|
+
startup: taskAutomation,
|
|
257
|
+
...(tmuxSession ? { handoff: { sessionName: tmuxSession, tmuxSocket: process.env.PI_CLAUDE_SUPERVISOR_TMUX_SOCKET } } : {}),
|
|
258
|
+
});
|
|
258
259
|
cwdLeases.set(taskId, lease);
|
|
259
260
|
if (shuttingDown) {
|
|
260
261
|
await releaseLease(taskId);
|
|
@@ -286,6 +287,42 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
|
|
|
286
287
|
env: selectedWorkerEnvironment(taskAutomation),
|
|
287
288
|
approval,
|
|
288
289
|
automation: taskAutomation,
|
|
290
|
+
retainCgroupUntilLeaseRelease: taskAutomation,
|
|
291
|
+
onWorkerStartup: taskAutomation
|
|
292
|
+
? async (startupHandle) => {
|
|
293
|
+
await lease.updateWorker({
|
|
294
|
+
transport: adapter.capabilities().transport,
|
|
295
|
+
workerId: startupHandle.id,
|
|
296
|
+
cgroupPath: startupHandle.cgroupPath,
|
|
297
|
+
sessionName: startupHandle.sessionName,
|
|
298
|
+
tmuxSocket: startupHandle.tmuxSocket,
|
|
299
|
+
ownership: startupHandle.ownership,
|
|
300
|
+
retainCgroupUntilLeaseRelease: startupHandle.retainCgroupUntilLeaseRelease,
|
|
301
|
+
}, { preserveStartup: true });
|
|
302
|
+
}
|
|
303
|
+
: undefined,
|
|
304
|
+
onWorkerPrepared: taskAutomation
|
|
305
|
+
? async (preparedHandle) => {
|
|
306
|
+
await lease.updateWorker({
|
|
307
|
+
transport: adapter.capabilities().transport,
|
|
308
|
+
...(await workerIdentity(preparedHandle)),
|
|
309
|
+
sessionName: preparedHandle.sessionName,
|
|
310
|
+
tmuxSocket: preparedHandle.tmuxSocket,
|
|
311
|
+
ownership: preparedHandle.ownership,
|
|
312
|
+
}, { preserveStartup: true });
|
|
313
|
+
}
|
|
314
|
+
: undefined,
|
|
315
|
+
onWorkerPreSpawn: taskAutomation
|
|
316
|
+
? async (preSpawnHandle) => {
|
|
317
|
+
await lease.updateWorker({
|
|
318
|
+
transport: adapter.capabilities().transport,
|
|
319
|
+
...(await workerIdentity(preSpawnHandle)),
|
|
320
|
+
sessionName: preSpawnHandle.sessionName,
|
|
321
|
+
tmuxSocket: preSpawnHandle.tmuxSocket,
|
|
322
|
+
ownership: preSpawnHandle.ownership,
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
: undefined,
|
|
289
326
|
tmuxSession,
|
|
290
327
|
tmuxSocket: process.env.PI_CLAUDE_SUPERVISOR_TMUX_SOCKET,
|
|
291
328
|
tmuxExpectedIdentity: tmuxSession && lease.record.worker ? {
|
|
@@ -453,14 +490,15 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
|
|
|
453
490
|
const automaticRecovery = record.spec?.autonomy.unattended !== false;
|
|
454
491
|
if (automaticRecovery && !record.resolvedExecutable) throw new Error("automatic recovery requires a persisted resolved Claude executable identity");
|
|
455
492
|
const approval = record.approval;
|
|
456
|
-
const lease = await cwdLeaseStore.acquire(cwdKey, record.taskId, adapter.capabilities().transport,
|
|
457
|
-
|
|
493
|
+
const lease = await cwdLeaseStore.acquire(cwdKey, record.taskId, adapter.capabilities().transport, {
|
|
494
|
+
startup: automaticRecovery,
|
|
495
|
+
...(takeover ? {
|
|
458
496
|
takeover: {
|
|
459
497
|
taskId: record.taskId,
|
|
460
498
|
...(staleRecovery ? { beforeReplace: () => decisionStore.reconcileStaleRecovery(record.taskId) } : {}),
|
|
461
499
|
},
|
|
462
|
-
}
|
|
463
|
-
|
|
500
|
+
} : {}),
|
|
501
|
+
});
|
|
464
502
|
if (staleRecovery && lease.replacedTaskId !== record.taskId) {
|
|
465
503
|
await lease.release();
|
|
466
504
|
throw new Error(`Stale Decision Worker recovery has no verified old lease to take over: ${record.taskId}`);
|
|
@@ -507,6 +545,42 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
|
|
|
507
545
|
env: selectedWorkerEnvironment(automaticRecovery),
|
|
508
546
|
approval,
|
|
509
547
|
automation: automaticRecovery,
|
|
548
|
+
retainCgroupUntilLeaseRelease: automaticRecovery,
|
|
549
|
+
onWorkerStartup: automaticRecovery
|
|
550
|
+
? async (startupHandle) => {
|
|
551
|
+
await lease.updateWorker({
|
|
552
|
+
transport: adapter.capabilities().transport,
|
|
553
|
+
workerId: startupHandle.id,
|
|
554
|
+
cgroupPath: startupHandle.cgroupPath,
|
|
555
|
+
sessionName: startupHandle.sessionName,
|
|
556
|
+
tmuxSocket: startupHandle.tmuxSocket,
|
|
557
|
+
ownership: startupHandle.ownership,
|
|
558
|
+
retainCgroupUntilLeaseRelease: startupHandle.retainCgroupUntilLeaseRelease,
|
|
559
|
+
}, { preserveStartup: true });
|
|
560
|
+
}
|
|
561
|
+
: undefined,
|
|
562
|
+
onWorkerPrepared: automaticRecovery
|
|
563
|
+
? async (preparedHandle) => {
|
|
564
|
+
await lease.updateWorker({
|
|
565
|
+
transport: adapter.capabilities().transport,
|
|
566
|
+
...(await workerIdentity(preparedHandle)),
|
|
567
|
+
sessionName: preparedHandle.sessionName,
|
|
568
|
+
tmuxSocket: preparedHandle.tmuxSocket,
|
|
569
|
+
ownership: preparedHandle.ownership,
|
|
570
|
+
}, { preserveStartup: true });
|
|
571
|
+
}
|
|
572
|
+
: undefined,
|
|
573
|
+
onWorkerPreSpawn: automaticRecovery
|
|
574
|
+
? async (preSpawnHandle) => {
|
|
575
|
+
await lease.updateWorker({
|
|
576
|
+
transport: adapter.capabilities().transport,
|
|
577
|
+
...(await workerIdentity(preSpawnHandle)),
|
|
578
|
+
sessionName: preSpawnHandle.sessionName,
|
|
579
|
+
tmuxSocket: preSpawnHandle.tmuxSocket,
|
|
580
|
+
ownership: preSpawnHandle.ownership,
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
: undefined,
|
|
510
584
|
...(automaticRecovery && record.resolvedExecutable ? { expectedClaudeExecutable: record.resolvedExecutable } : {}),
|
|
511
585
|
maxTurns: record.maxTurns,
|
|
512
586
|
deadlineMs: record.deadlineMs,
|
|
@@ -768,6 +842,7 @@ function formatSessions(sessions: Map<string, Supervisor>, recoverable: Decision
|
|
|
768
842
|
}
|
|
769
843
|
|
|
770
844
|
function selectedWorkerEnvironment(automatic = false): NodeJS.ProcessEnv {
|
|
845
|
+
if (automatic) return automaticWorkerEnvironment(process.env);
|
|
771
846
|
const result: NodeJS.ProcessEnv = {};
|
|
772
847
|
const names = (process.env.PI_CLAUDE_SUPERVISOR_WORKER_ENV ?? "")
|
|
773
848
|
.split(",")
|
|
@@ -776,7 +851,7 @@ function selectedWorkerEnvironment(automatic = false): NodeJS.ProcessEnv {
|
|
|
776
851
|
for (const name of names) {
|
|
777
852
|
if (process.env[name] !== undefined) result[name] = process.env[name];
|
|
778
853
|
}
|
|
779
|
-
return
|
|
854
|
+
return result;
|
|
780
855
|
}
|
|
781
856
|
|
|
782
857
|
async function readTaskSpecFile(path: string, cwd: string): Promise<TaskSpec> {
|
package/src/policy.ts
CHANGED
|
@@ -11,14 +11,12 @@ export interface PolicyResult {
|
|
|
11
11
|
/**
|
|
12
12
|
* Permission decisions are autonomous for local development. A denial is a
|
|
13
13
|
* hard capability boundary, not a request for a human to approve the next
|
|
14
|
-
* turn.
|
|
15
|
-
*
|
|
14
|
+
* turn. Claude's built-in tools, agents, background tasks and MCP tools are
|
|
15
|
+
* allowed; only the explicit unattended interaction and repository/remote
|
|
16
|
+
* authority boundaries below remain special-cased.
|
|
16
17
|
*/
|
|
17
|
-
const allowedWorkerTools = new Set(["Bash", "Edit", "Glob", "Grep", "Read", "Write", "NotebookEdit"]);
|
|
18
|
-
|
|
19
18
|
export function evaluatePermission(toolName: string, input: unknown, cwd = process.cwd()): PolicyResult {
|
|
20
19
|
if (toolName === "AskUserQuestion") return { decision: "deny", reason: "interactive questions are converted to ordinary Worker text" };
|
|
21
|
-
if (!allowedWorkerTools.has(toolName)) return { decision: "deny", reason: `Worker tool is outside the automatic allowlist: ${toolName}` };
|
|
22
20
|
if (toolName === "Edit" || toolName === "Write" || toolName === "NotebookEdit") {
|
|
23
21
|
const paths = fileToolPaths(input);
|
|
24
22
|
if (paths.length === 0) return { decision: "deny", reason: `${toolName} request has no recognizable file path` };
|
|
@@ -30,9 +28,6 @@ export function evaluatePermission(toolName: string, input: unknown, cwd = proce
|
|
|
30
28
|
? (input as { command: string }).command
|
|
31
29
|
: "";
|
|
32
30
|
if (!command) return { decision: "deny", reason: "Bash request has no recognizable command" };
|
|
33
|
-
if (containsNestedClaude(command)) {
|
|
34
|
-
return { decision: "deny", reason: "Worker cannot start a nested Claude or background Reviewer" };
|
|
35
|
-
}
|
|
36
31
|
return evaluateCommand("bash", ["-lc", command]);
|
|
37
32
|
}
|
|
38
33
|
|
|
@@ -95,8 +90,7 @@ function isGitMetadataPath(value: string, cwd: string): boolean {
|
|
|
95
90
|
|
|
96
91
|
const deniedPatterns = [
|
|
97
92
|
/\b(?:npm|pnpm|yarn)\b[\s\S]*\bpublish\b/iu,
|
|
98
|
-
/\b(?:
|
|
99
|
-
/\b(?:curl|wget)\b[\s\S]*(?:-X\s*(?:POST|PUT|PATCH|DELETE)|--method(?:=|\s+)(?:POST|PUT|PATCH|DELETE)|https?:\/\/(?:api\.)?(?:github|gitlab|bitbucket|registry\.npmjs)\.)/iu,
|
|
93
|
+
/\b(?:curl|wget)\b[\s\S]*(?:-X\s*(?:POST|PUT|PATCH|DELETE)|--request(?:=|\s+)(?:POST|PUT|PATCH|DELETE)|--method(?:=|\s+)(?:POST|PUT|PATCH|DELETE)|(?:^|\s)(?:-d|--data(?:[-a-z]*)(?:=|\s+)|--post-data(?:=|\s+)|--body-data(?:=|\s+)))[\s\S]*https?:\/\/(?:api\.)?(?:github|gitlab|bitbucket|registry\.npmjs)\b/iu,
|
|
100
94
|
/(?:\$\{?[^\s`}]+\}?|`[^`]*`|\$\([^)]*\))[\s\S]*\b(?:push|merge|publish)\b|\b(?:push|merge|publish)\b[\s\S]*(?:\$\{?[^\s`}]+\}?|`[^`]*`|\$\([^)]*\))/iu,
|
|
101
95
|
/--(?:allow-)?dangerously-skip-permissions\b/iu,
|
|
102
96
|
/--permission-mode\s+(?:bypasspermissions|dontask)\b/iu,
|
|
@@ -117,6 +111,18 @@ const protectedBranches = new Set(["main", "master", "trunk", "integration", "de
|
|
|
117
111
|
const protectedBranchOperations = new Set(["checkout", "switch", "branch", "reset", "restore", "worktree", "update-ref", "symbolic-ref"]);
|
|
118
112
|
const remoteOperations = new Set(["push", "merge", "send-pack", "receive-pack", "update-ref"]);
|
|
119
113
|
|
|
114
|
+
function containsRemoteCliMutation(command: string): boolean {
|
|
115
|
+
return /\b(?:gh|glab|hub)\b[\s\S]*(?:\b(?:pr|mr|pull-request)\s+(?:merge|create|close|delete|edit|comment)\b|\b(?:release)\s+(?:create|delete|edit|upload)\b|\bapi\b[\s\S]*(?:-X|--request|--method(?:=|\s+))(?:\s*=?)\s*(?:POST|PUT|PATCH|DELETE)\b)/iu.test(command);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function containsRemoteHttpMutation(command: string): boolean {
|
|
119
|
+
const hasHttpClient = /\b(?:curl|wget)\b/iu.test(command);
|
|
120
|
+
const hasMutationFlag = /(?:-X\s*(?:POST|PUT|PATCH|DELETE)\b|--request(?:=|\s+)(?:POST|PUT|PATCH|DELETE)\b|--method(?:=|\s+)(?:POST|PUT|PATCH|DELETE)\b)/iu.test(command)
|
|
121
|
+
|| /(?:^|\s)(?:-d\b|--data(?:[-a-z]*)(?:=|\s+)|--post-data(?:=|\s+)|--body-data(?:=|\s+))/iu.test(command);
|
|
122
|
+
const hasProtectedEndpoint = /https:\/\/(?:api\.)?(?:github|gitlab|bitbucket|registry\.npmjs)\b/iu.test(command);
|
|
123
|
+
return hasHttpClient && hasMutationFlag && hasProtectedEndpoint;
|
|
124
|
+
}
|
|
125
|
+
|
|
120
126
|
export function evaluateCommand(command: string, args: readonly string[] = []): PolicyResult {
|
|
121
127
|
const normalized = command.trim();
|
|
122
128
|
if (!normalized) return { decision: "deny", reason: "empty command" };
|
|
@@ -132,8 +138,7 @@ function evaluateRepositoryBoundary(tokens: readonly ShellToken[], canonical: st
|
|
|
132
138
|
const hasGit = values.some((value) => /(?:^|[\\/])git$/iu.test(value) || /^(?:git-(?:send|receive|upload)-pack)$/iu.test(value));
|
|
133
139
|
const hasGitTransport = lower.some((value) => /^(?:git-(?:send|receive|upload)-pack)$/u.test(value));
|
|
134
140
|
const hasRemoteOperation = lower.some((value) => remoteOperations.has(value));
|
|
135
|
-
const hasGhRemote =
|
|
136
|
-
&& lower.some((value) => value === "api" || value === "release" || value === "pull-request" || value === "pr" || value === "mr");
|
|
141
|
+
const hasGhRemote = containsRemoteCliMutation(canonical);
|
|
137
142
|
const hasPackagePublication = lower.some((value) => value === "npm" || value === "pnpm" || value === "yarn") && lower.includes("publish");
|
|
138
143
|
const hasGitAliasConfiguration = hasGit && lower.some((value) => /^alias\.[^=]*(?:=|$)/u.test(value));
|
|
139
144
|
if (depth < 4) {
|
|
@@ -152,7 +157,7 @@ function evaluateRepositoryBoundary(tokens: readonly ShellToken[], canonical: st
|
|
|
152
157
|
}
|
|
153
158
|
if (/\bgit\b[\s\S]*\b(?:push|merge|send-pack|receive-pack|update-ref)\b/iu.test(canonical)
|
|
154
159
|
|| /\bgit-(?:send|receive|upload)-pack\b/iu.test(canonical)
|
|
155
|
-
||
|
|
160
|
+
|| containsRemoteCliMutation(canonical)) {
|
|
156
161
|
return { decision: "deny", reason: "Worker has no remote repository or main/integration merge authority" };
|
|
157
162
|
}
|
|
158
163
|
if (hasGit && (hasRemoteOperation || hasGitTransport) || hasGhRemote) {
|
|
@@ -181,27 +186,6 @@ function evaluateRepositoryBoundary(tokens: readonly ShellToken[], canonical: st
|
|
|
181
186
|
return undefined;
|
|
182
187
|
}
|
|
183
188
|
|
|
184
|
-
function containsNestedClaude(command: string, depth = 0): boolean {
|
|
185
|
-
// Bash can hand an opaque quoted program to Python, Node, eval, env,
|
|
186
|
-
// command, or another interpreter. Once this permission boundary sees a
|
|
187
|
-
// Claude executable reference, fail closed rather than trying to prove which
|
|
188
|
-
// wrapper will eventually exec it. This intentionally rejects harmless prose
|
|
189
|
-
// mentions too; false positives cannot grant a nested Worker capability.
|
|
190
|
-
if (/\b(?:claude(?:\.exe)?|review(?:er)?|code[-_ ]?review|read[-_ ]?only[-_ ]?review|independent[-_ ]?review|codex|cursor(?:-agent)?|aider|opencode|gemini)\b/iu.test(command)) return true;
|
|
191
|
-
if (depth > 4) return false;
|
|
192
|
-
const lexical = lexShell(command);
|
|
193
|
-
if (lexical.error) return true;
|
|
194
|
-
for (const token of lexical.tokens) {
|
|
195
|
-
if (token.operator) continue;
|
|
196
|
-
const executable = token.value.split(/[\\/]/u).at(-1)?.toLowerCase();
|
|
197
|
-
if (executable === "claude" || executable === "claude.exe") return true;
|
|
198
|
-
// Quote concatenation such as c'l'a'u'd'e is normalized by the shell
|
|
199
|
-
// lexer into one token. Recurse into quoted interpreter payloads too.
|
|
200
|
-
if (/\s/u.test(token.value) && containsNestedClaude(token.value, depth + 1)) return true;
|
|
201
|
-
}
|
|
202
|
-
return false;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
189
|
function nestedShellCommands(lower: readonly string[], values: readonly string[]): string[] {
|
|
206
190
|
const nested: string[] = [];
|
|
207
191
|
const shellNames = new Set(["sh", "bash", "dash", "zsh", "fish", "ksh"]);
|
|
@@ -237,6 +221,9 @@ function evaluateTokens(tokens: readonly ShellToken[], depth: number): PolicyRes
|
|
|
237
221
|
if (!canonical) return { decision: "deny", reason: "empty command" };
|
|
238
222
|
const boundary = evaluateRepositoryBoundary(tokens, canonical, depth);
|
|
239
223
|
if (boundary) return boundary;
|
|
224
|
+
if (containsRemoteHttpMutation(canonical)) {
|
|
225
|
+
return { decision: "deny", reason: "Worker has no remote repository or main/integration merge authority" };
|
|
226
|
+
}
|
|
240
227
|
if (deniedPatterns.some((pattern) => pattern.test(canonical))) {
|
|
241
228
|
if (/\b(?:curl|wget)\b[\s\S]*(?:github|gitlab|bitbucket|registry\.npmjs)\b/iu.test(canonical)) {
|
|
242
229
|
return { decision: "deny", reason: "Worker has no remote repository or main/integration merge authority" };
|
package/src/supervisor.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { PiDecisionWorker, type DecisionAction, type DecisionWorkerFactory, type
|
|
|
7
7
|
import { collectRepositoryEvidence, repositoryBranch, repositoryCommitExists, repositoryHead, repositoryWorkTree, verifyAll, type RepositoryEvidence, type VerificationCommand } from "./verifier.ts";
|
|
8
8
|
import { normalizeTaskSpec } from "./acceptance.ts";
|
|
9
9
|
import { redactSensitive } from "./redaction.ts";
|
|
10
|
-
import { assertTrustedAutomaticClaudeExecutable, automaticClaudeArgs, automaticWorkerEnvironment } from "./worker/environment.ts";
|
|
10
|
+
import { assertAutomaticClaudePermissionConfiguration, assertTrustedAutomaticClaudeExecutable, automaticClaudeArgs, automaticWorkerEnvironment } from "./worker/environment.ts";
|
|
11
11
|
import { normalizeReviewReport, type ReviewInput, type TaskReviewer } from "./reviewer.ts";
|
|
12
12
|
import type {
|
|
13
13
|
AcceptanceReport,
|
|
@@ -93,6 +93,14 @@ export interface SupervisorStartOptions {
|
|
|
93
93
|
sendInitialInput?: boolean;
|
|
94
94
|
/** Enable the event-driven Pi Decision Worker. Automatic mode requires claude-jsonl. */
|
|
95
95
|
automation?: boolean;
|
|
96
|
+
/** Index-owned cwd leases retain the empty automatic cgroup until release. */
|
|
97
|
+
retainCgroupUntilLeaseRelease?: boolean;
|
|
98
|
+
/** Persist the planned adapter resource identity before resource creation. */
|
|
99
|
+
onWorkerStartup?: (handle: WorkerHandle) => Promise<void> | void;
|
|
100
|
+
/** Persist the cgroup identity before creating external Worker resources. */
|
|
101
|
+
onWorkerPrepared?: (handle: WorkerHandle) => Promise<void> | void;
|
|
102
|
+
/** Persist the adapter's final resource identity before Worker spawn. */
|
|
103
|
+
onWorkerPreSpawn?: (handle: WorkerHandle) => Promise<void> | void;
|
|
96
104
|
/** Persistent Pi session location for the Decision Worker. */
|
|
97
105
|
decisionSessionFile?: string;
|
|
98
106
|
decisionSessionDir?: string;
|
|
@@ -267,10 +275,13 @@ export class Supervisor {
|
|
|
267
275
|
if (this.#automation && !["jsonl", "tmux"].includes(this.#adapter.capabilities().transport)) {
|
|
268
276
|
throw new Error("automatic supervision requires the claude-jsonl or automated tmux transport");
|
|
269
277
|
}
|
|
270
|
-
const workerEnvironment = this.#automation
|
|
278
|
+
const workerEnvironment = this.#automation
|
|
279
|
+
? automaticWorkerEnvironment(options.env)
|
|
280
|
+
: options.env;
|
|
271
281
|
if (this.#automation) {
|
|
272
282
|
trustedWorkerCommand = await assertTrustedAutomaticClaudeExecutable(options.command, options.expectedClaudeExecutable);
|
|
273
283
|
workerArgs = automaticClaudeArgs(options.command, options.args);
|
|
284
|
+
await assertAutomaticClaudePermissionConfiguration(options.cwd, workerArgs, workerEnvironment);
|
|
274
285
|
await this.#appendEvent({ type: "worker_executable_pinned", taskId, data: { command: options.command, resolvedExecutable: trustedWorkerCommand } });
|
|
275
286
|
}
|
|
276
287
|
await this.#adapter.preflight?.({
|
|
@@ -330,13 +341,18 @@ export class Supervisor {
|
|
|
330
341
|
tmuxExpectedIdentity: options.tmuxExpectedIdentity,
|
|
331
342
|
sendInitialInput: options.sendInitialInput,
|
|
332
343
|
automatic: this.#automation,
|
|
344
|
+
retainCgroupUntilLeaseRelease: this.#automation && options.retainCgroupUntilLeaseRelease === true,
|
|
333
345
|
eventListener: (event) => this.#receiveWorkerEvent(event),
|
|
334
346
|
env: workerEnvironment,
|
|
335
347
|
abortSignal: startAbortController.signal,
|
|
336
348
|
startupToken: this.#startToken,
|
|
349
|
+
onWorkerStartup: this.#automation ? options.onWorkerStartup : undefined,
|
|
350
|
+
onWorkerPrepared: this.#automation ? options.onWorkerPrepared : undefined,
|
|
337
351
|
preSpawnCheck: this.#automation
|
|
338
|
-
? async () => {
|
|
352
|
+
? async (provisionalHandle) => {
|
|
353
|
+
if (provisionalHandle) await options.onWorkerPreSpawn?.(provisionalHandle);
|
|
339
354
|
await automaticRepositoryBoundary(options.cwd, this.#task!.baseCommit, this.#task!.baseBranch, startAbortController.signal, startupHead);
|
|
355
|
+
await assertAutomaticClaudePermissionConfiguration(options.cwd, workerArgs ?? [], workerEnvironment);
|
|
340
356
|
this.#assertStartNotAborted(startAbortController.signal);
|
|
341
357
|
}
|
|
342
358
|
: undefined,
|
package/src/types.ts
CHANGED
|
@@ -54,15 +54,23 @@ export interface WorkerStartInput {
|
|
|
54
54
|
sendInitialInput?: boolean;
|
|
55
55
|
/** Automatic mode selects the structured tmux bridge instead of the manual TUI transport. */
|
|
56
56
|
automatic?: boolean;
|
|
57
|
+
/** Keep an empty automatic cgroup until the owning cwd lease is finalized. */
|
|
58
|
+
retainCgroupUntilLeaseRelease?: boolean;
|
|
59
|
+
/** Persist the planned automatic resource identity before adapter setup. */
|
|
60
|
+
onWorkerStartup?: (provisionalHandle: WorkerHandle) => Promise<void> | void;
|
|
61
|
+
/** Persist the cgroup identity before creating any external Worker resource. */
|
|
62
|
+
onWorkerPrepared?: (provisionalHandle: WorkerHandle) => Promise<void> | void;
|
|
57
63
|
/** Cancel startup before a worker is fully returned to the supervisor. */
|
|
58
64
|
abortSignal?: AbortSignal;
|
|
59
65
|
/** Internal token that scopes out-of-band startup cancellation. */
|
|
60
66
|
startupToken?: string;
|
|
61
67
|
/**
|
|
62
|
-
* Automatic-start repository assertion. Built-in adapters invoke
|
|
63
|
-
*
|
|
68
|
+
* Automatic-start repository/settings assertion. Built-in adapters invoke
|
|
69
|
+
* this after asynchronous setup; the tmux bridge repeats its permission
|
|
70
|
+
* check immediately before spawning the Claude child. The provisional
|
|
71
|
+
* handle contains the cgroup identity before Worker spawn.
|
|
64
72
|
*/
|
|
65
|
-
preSpawnCheck?: () => Promise<void>;
|
|
73
|
+
preSpawnCheck?: (provisionalHandle?: WorkerHandle) => Promise<void>;
|
|
66
74
|
}
|
|
67
75
|
|
|
68
76
|
export interface WorkerHandle {
|
|
@@ -76,6 +84,11 @@ export interface WorkerHandle {
|
|
|
76
84
|
ownership?: "owned" | "adopted";
|
|
77
85
|
/** Verified process-boundary metadata persisted in the cwd lease registry. */
|
|
78
86
|
cgroupPath?: string;
|
|
87
|
+
cgroupIdentity?: { device: string; inode: string };
|
|
88
|
+
/** Automatic adapters retain the empty cgroup until the cwd lease is released. */
|
|
89
|
+
retainCgroupUntilLeaseRelease?: boolean;
|
|
90
|
+
tmuxServerPid?: number;
|
|
91
|
+
tmuxServerStartTime?: string;
|
|
79
92
|
tmuxTarget?: string;
|
|
80
93
|
tmuxPaneId?: string;
|
|
81
94
|
paneStartTime?: string;
|