pi-claude-supervisor 0.4.0 → 0.4.1
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 +7 -0
- package/README.cn.md +3 -1
- package/README.md +10 -5
- package/docs/architecture.md +4 -1
- package/docs/testing.md +25 -13
- package/package.json +2 -1
- package/src/index.ts +27 -10
- package/src/supervisor.ts +5 -0
- package/src/worker/tmux-adapter.ts +12 -5
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented here.
|
|
4
4
|
|
|
5
|
+
## [0.4.1](https://github.com/btnalit/pi-claude-supervisor/compare/v0.4.0...v0.4.1) (2026-09-14)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Bug Fixes
|
|
9
|
+
|
|
10
|
+
* reconcile detached tmux leases and validate prompts ([d20262c](https://github.com/btnalit/pi-claude-supervisor/commit/d20262c8481ca761a48f7dc2b877951173d4681e))
|
|
11
|
+
|
|
5
12
|
## [0.4.0](https://github.com/btnalit/pi-claude-supervisor/compare/v0.3.0...v0.4.0) (2026-09-13)
|
|
6
13
|
|
|
7
14
|
|
package/README.cn.md
CHANGED
|
@@ -108,7 +108,9 @@ export PI_CLAUDE_SUPERVISOR_WORKER='claude --permission-mode plan'
|
|
|
108
108
|
/supervise adopt-tmux <tmux-session-name> <task description>
|
|
109
109
|
```
|
|
110
110
|
|
|
111
|
-
接管会检查 cwd、pane 中的进程,并拒绝已有其他输出 pipe 的 pane;但不宣称拥有该 session
|
|
111
|
+
接管会检查 cwd、pane 中的进程,并拒绝已有其他输出 pipe 的 pane;但不宣称拥有该 session。owned session 使用自动生成的私有 tmux socket,
|
|
112
|
+
请保存 `start` 输出的完整 `attach=...` 命令。Pi 重启后重新接管时,先把该命令中的 socket 路径设置到
|
|
113
|
+
`PI_CLAUDE_SUPERVISOR_TMUX_SOCKET`;只有默认 server 才能只使用 session 名称。对被接管的 session,
|
|
112
114
|
`/supervise stop` 和 Pi 关闭只会断开监督,不会杀掉你的 tmux 窗口;需要关闭时请由你执行
|
|
113
115
|
`tmux kill-session`。`/supervise takeover <task-id>` 会暂停 Decision Worker 自动发送,只有
|
|
114
116
|
`/supervise resume-auto <task-id>` 才恢复。
|
package/README.md
CHANGED
|
@@ -127,11 +127,16 @@ the task:
|
|
|
127
127
|
```
|
|
128
128
|
|
|
129
129
|
Adoption checks the session's working directory and pane command, and refuses a
|
|
130
|
-
pane that already has another output pipe.
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
130
|
+
pane that already has another output pipe. Owned sessions use a generated
|
|
131
|
+
private tmux socket, so preserve the complete `attach=...` command printed by
|
|
132
|
+
`start`. When re-adopting after a Pi restart, set
|
|
133
|
+
`PI_CLAUDE_SUPERVISOR_TMUX_SOCKET` to the socket path from that command before
|
|
134
|
+
running `adopt-tmux`; the session name alone is sufficient only for the default
|
|
135
|
+
server. Adoption does not claim ownership: `/supervise stop` and Pi shutdown
|
|
136
|
+
detach supervision rather than killing the user's tmux session. Use `tmux
|
|
137
|
+
kill-session` yourself when the adopted window should be closed.
|
|
138
|
+
`/supervise takeover <task-id>` disables automatic Decision Worker messages;
|
|
139
|
+
resume them only with `/supervise resume-auto <task-id>`.
|
|
135
140
|
|
|
136
141
|
PTY screen text is not Claude JSONL. Permission dialogs, trust prompts and
|
|
137
142
|
ambiguous TUI states are escalated to a human; tmux mode must not be treated as
|
package/docs/architecture.md
CHANGED
|
@@ -136,7 +136,10 @@ cannot consume another task's lease. If post-start worker identity registration
|
|
|
136
136
|
fails, owned workers are stopped before the lease is released; cleanup failure
|
|
137
137
|
retains both the worker and lease fail-closed. Explicit `stop` cleans owned tmux
|
|
138
138
|
sessions, while Pi shutdown detaches persistent sessions so they remain explicitly
|
|
139
|
-
re-adoptable.
|
|
139
|
+
re-adoptable. A detached adopted session retains its lease while the verified pane
|
|
140
|
+
is alive; the extension periodically rechecks released sessions and removes the
|
|
141
|
+
lease only after the pane is confirmed gone. If that check fails, the lease is
|
|
142
|
+
retained rather than allowing a cwd overlap.
|
|
140
143
|
|
|
141
144
|
Startup owns an `AbortController` and passes its signal to the adapter. A stop
|
|
142
145
|
or shutdown request aborts the controller and calls the adapter's out-of-band
|
package/docs/testing.md
CHANGED
|
@@ -73,15 +73,24 @@ npm run spike:signals
|
|
|
73
73
|
npm run spike:automation
|
|
74
74
|
SPIKE_AUTOMATION_PERMISSION=1 npm run spike:automation
|
|
75
75
|
SPIKE_AUTOMATION_QUESTION=1 npm run spike:automation
|
|
76
|
+
PI_CLAUDE_SUPERVISOR_REAL_CLAUDE_PATH=/home/yancao/.local/share/mise/installs/claude/2.1.270/claude \
|
|
76
77
|
PI_CLAUDE_SUPERVISOR_REAL_CLAUDE=1 npm run spike:tmux
|
|
78
|
+
PI_CLAUDE_SUPERVISOR_REAL_CLAUDE_PATH=/home/yancao/.local/share/mise/installs/claude/2.1.270/claude \
|
|
79
|
+
PI_CLAUDE_SUPERVISOR_REAL_CLAUDE=1 npm run spike:tmux-interactive
|
|
77
80
|
```
|
|
78
81
|
|
|
79
82
|
The tmux spike is gated, authenticated, and excluded from normal CI. It uses
|
|
80
|
-
plan mode, records only protocol metadata, and
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
83
|
+
plan mode with a fixed `opus` model, records only protocol metadata, and
|
|
84
|
+
verifies three real Claude turns, exact screen-result markers, pause/resume,
|
|
85
|
+
automation enabled with human takeover, direct human PTY input, owned detach,
|
|
86
|
+
and identity-bound restart re-adoption. The interactive spike uses a fresh temporary
|
|
87
|
+
cwd to verify Claude's trust prompt, a real Bash permission prompt, an allow-once
|
|
88
|
+
response, and an exact result marker; it also records metadata only.
|
|
89
|
+
|
|
90
|
+
For each release, pin and record the validated Claude Code version, resolved
|
|
91
|
+
executable path, and model. The spikes reject an unpinned/mismatched executable
|
|
92
|
+
version. For this release the validated version is `2.1.270` with model `opus`.
|
|
93
|
+
Record:
|
|
85
94
|
|
|
86
95
|
1. exact version and resolved executable path;
|
|
87
96
|
2. license and source revision;
|
|
@@ -103,14 +112,17 @@ approval callbacks are deliberately not accepted without a separately
|
|
|
103
112
|
authenticated endpoint.
|
|
104
113
|
|
|
105
114
|
The tmux transport is selected with `PI_CLAUDE_SUPERVISOR_TRANSPORT=tmux`. Before
|
|
106
|
-
release,
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
115
|
+
release, verify: private-socket attach, multi-line paste, prompt stability while
|
|
116
|
+
Claude is busy, trust/permission dialog takeover, duplicate send prevention, pane
|
|
117
|
+
replacement refusal, pause/resume, owned-session stop, adopted-session
|
|
118
|
+
detach/re-adoption, bounded shutdown, and Pi shutdown without closing an
|
|
119
|
+
attached window. The two gated real-Claude spikes above cover the trust prompt,
|
|
120
|
+
permission prompt, exact output, human takeover, and adopted detach paths. Use
|
|
121
|
+
`--permission-mode plan` and read-only tools for ordinary live Claude checks;
|
|
122
|
+
the interactive spike is restricted to one harmless `rm -f` in a disposable
|
|
123
|
+
fresh directory. Do not run JSONL and tmux control against the same Claude
|
|
124
|
+
process, and do not treat `capture-pane` text as a structured permission
|
|
125
|
+
response.
|
|
114
126
|
|
|
115
127
|
## Failure injection
|
|
116
128
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-claude-supervisor",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "A policy-gated Pi supervisor for observing and verifying Claude Code workers.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"publishConfig": {
|
|
@@ -57,6 +57,7 @@
|
|
|
57
57
|
"test:pi": "node scripts/test-pi.mjs",
|
|
58
58
|
"spike:transport": "node scripts/spike-claude-transport.mjs",
|
|
59
59
|
"spike:tmux": "node scripts/spike-claude-tmux.mjs",
|
|
60
|
+
"spike:tmux-interactive": "node scripts/spike-claude-tmux-interactive.mjs",
|
|
60
61
|
"spike:permissions": "node scripts/spike-claude-permissions.mjs",
|
|
61
62
|
"spike:signals": "node scripts/spike-claude-signals.mjs",
|
|
62
63
|
"spike:automation": "node scripts/spike-claude-automation.mjs",
|
package/src/index.ts
CHANGED
|
@@ -63,6 +63,7 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
|
|
|
63
63
|
let activeTaskId: string | undefined;
|
|
64
64
|
let shuttingDown = false;
|
|
65
65
|
let shutdownPromise: Promise<void> | undefined;
|
|
66
|
+
let detachedLeaseSweepTimer: NodeJS.Timeout | undefined;
|
|
66
67
|
|
|
67
68
|
const notify = (ctx: ExtensionContext, message: string, type: "info" | "warning" = "info") => {
|
|
68
69
|
if (ctx.hasUI) ctx.ui.notify(redactText(message), type);
|
|
@@ -81,29 +82,41 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
|
|
|
81
82
|
return false;
|
|
82
83
|
}
|
|
83
84
|
};
|
|
85
|
+
const forgetSession = (taskId: string): void => {
|
|
86
|
+
sessions.delete(taskId);
|
|
87
|
+
reservedCwds.delete(taskId);
|
|
88
|
+
cleanupRequiredTasks.delete(taskId);
|
|
89
|
+
if (activeTaskId === taskId) activeTaskId = undefined;
|
|
90
|
+
};
|
|
84
91
|
const releaseSettledReservations = async (): Promise<void> => {
|
|
85
92
|
for (const [taskId, session] of sessions) {
|
|
86
|
-
|
|
93
|
+
const terminal = ["completed", "stopped", "failed"].includes(session.state);
|
|
94
|
+
const detached = session.released;
|
|
95
|
+
if (!terminal && !detached) continue;
|
|
87
96
|
if (!session.handle) {
|
|
88
|
-
if (await releaseLease(taskId))
|
|
89
|
-
reservedCwds.delete(taskId);
|
|
90
|
-
cleanupRequiredTasks.delete(taskId);
|
|
91
|
-
}
|
|
97
|
+
if (await releaseLease(taskId)) forgetSession(taskId);
|
|
92
98
|
continue;
|
|
93
99
|
}
|
|
94
100
|
try {
|
|
95
101
|
const status = await adapter.getStatus(session.handle);
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
102
|
+
const adoptedDetachConfirmed = detached
|
|
103
|
+
&& session.handle.ownership === "adopted"
|
|
104
|
+
&& !status.running;
|
|
105
|
+
const cleanupConfirmed = status.processGroupCleaned === true || adoptedDetachConfirmed;
|
|
106
|
+
if (!status.running && cleanupConfirmed && !status.cleanupError && (!status.cgroupError || status.cgroupRequired === false)) {
|
|
107
|
+
if (await releaseLease(taskId)) forgetSession(taskId);
|
|
101
108
|
}
|
|
102
109
|
} catch {
|
|
103
110
|
// Keep the reservation when cleanup status cannot be confirmed.
|
|
104
111
|
}
|
|
105
112
|
}
|
|
106
113
|
};
|
|
114
|
+
detachedLeaseSweepTimer = setInterval(() => {
|
|
115
|
+
void releaseSettledReservations().catch((error) => {
|
|
116
|
+
console.error(`pi-claude-supervisor detached lease sweep failed: ${redactText(error instanceof Error ? error.message : String(error))}`);
|
|
117
|
+
});
|
|
118
|
+
}, 1_000);
|
|
119
|
+
detachedLeaseSweepTimer.unref();
|
|
107
120
|
const stopSession = async (session: Supervisor, reason: string, releasePersistent = false): Promise<void> => {
|
|
108
121
|
const handle = session.handle;
|
|
109
122
|
const persistent = adapter.capabilities().persistentSession && Boolean(handle);
|
|
@@ -572,6 +585,10 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
|
|
|
572
585
|
if (shutdownPromise) return shutdownPromise;
|
|
573
586
|
shuttingDown = true;
|
|
574
587
|
shutdownPromise = (async () => {
|
|
588
|
+
if (detachedLeaseSweepTimer) {
|
|
589
|
+
clearInterval(detachedLeaseSweepTimer);
|
|
590
|
+
detachedLeaseSweepTimer = undefined;
|
|
591
|
+
}
|
|
575
592
|
const pending = [...pendingStarts];
|
|
576
593
|
let startupFailures: PromiseRejectedResult[] = [];
|
|
577
594
|
await Promise.race([Promise.allSettled(pending), delay(5_000)]);
|
package/src/supervisor.ts
CHANGED
|
@@ -108,6 +108,7 @@ export class Supervisor {
|
|
|
108
108
|
#startStopReason?: string;
|
|
109
109
|
#startAbortError?: unknown;
|
|
110
110
|
#startAbortCompletion?: Promise<void>;
|
|
111
|
+
#released = false;
|
|
111
112
|
|
|
112
113
|
constructor(adapter: WorkerAdapter, events = new EventLog(), hooks: { onHumanRequired?: (notice: HumanInterventionNotice) => Promise<void> | void } = {}) {
|
|
113
114
|
this.#adapter = adapter;
|
|
@@ -120,6 +121,8 @@ export class Supervisor {
|
|
|
120
121
|
get handle() { return this.#handle; }
|
|
121
122
|
get lastVerification() { return this.#lastVerification; }
|
|
122
123
|
get humanRequired() { return this.#humanRequired; }
|
|
124
|
+
/** True after the persistent worker was detached from this Supervisor. */
|
|
125
|
+
get released() { return this.#released; }
|
|
123
126
|
|
|
124
127
|
async start(options: SupervisorStartOptions): Promise<WorkerHandle> {
|
|
125
128
|
return this.#exclusive(() => this.#startInternal(options));
|
|
@@ -139,6 +142,7 @@ export class Supervisor {
|
|
|
139
142
|
this.#handledEvents.clear();
|
|
140
143
|
this.#pendingPermissions.clear();
|
|
141
144
|
this.#humanRequired = false;
|
|
145
|
+
this.#released = false;
|
|
142
146
|
this.#task = { taskId, task: options.task, cwd: options.cwd, maxTurns: options.maxTurns ?? 100, startedAt: options.startedAt ?? new Date().toISOString() };
|
|
143
147
|
this.#turn = options.initialTurn ?? 0;
|
|
144
148
|
this.#deadlineMs = options.deadlineMs ?? 4 * 60 * 60_000;
|
|
@@ -584,6 +588,7 @@ export class Supervisor {
|
|
|
584
588
|
await withTimeout(this.#exclusive(async () => {
|
|
585
589
|
this.#clearWatchdog();
|
|
586
590
|
await preemptiveRelease;
|
|
591
|
+
this.#released = true;
|
|
587
592
|
if (handle) await this.#appendEvent({ type: "worker_released", taskId: this.#task?.taskId, workerId: handle.id, data: { reason } });
|
|
588
593
|
}), 15_000, "persistent worker release");
|
|
589
594
|
}
|
|
@@ -264,10 +264,16 @@ export class TmuxWorkerAdapter implements WorkerAdapter {
|
|
|
264
264
|
const pane = await this.#paneStatus(record);
|
|
265
265
|
record.paneDead = pane.dead;
|
|
266
266
|
record.panePid = pane.pid;
|
|
267
|
-
if (
|
|
267
|
+
if (pane.dead) record.cleanupError = undefined;
|
|
268
|
+
else await this.#rememberPaneIdentity(record, pane.pid);
|
|
268
269
|
} catch (error) {
|
|
269
|
-
if (isMissingSession(error)
|
|
270
|
-
|
|
270
|
+
if (isMissingSession(error)) {
|
|
271
|
+
record.paneDead = true;
|
|
272
|
+
record.cleanupError = undefined;
|
|
273
|
+
} else if (isPaneIdentityError(error)) {
|
|
274
|
+
record.paneDead = false;
|
|
275
|
+
record.cleanupError = asError(error);
|
|
276
|
+
} else record.cleanupError = asError(error);
|
|
271
277
|
}
|
|
272
278
|
return this.#status(record, !record.paneDead);
|
|
273
279
|
}
|
|
@@ -284,10 +290,11 @@ export class TmuxWorkerAdapter implements WorkerAdapter {
|
|
|
284
290
|
} catch (error) {
|
|
285
291
|
if (isMissingSession(error)) {
|
|
286
292
|
record.paneDead = true;
|
|
293
|
+
record.cleanupError = undefined;
|
|
287
294
|
if (!record.cleanupComplete && record.owned) await this.#cleanup(record, false);
|
|
288
295
|
} else {
|
|
289
296
|
record.cleanupError = asError(error);
|
|
290
|
-
if (isPaneIdentityError(error)) record.paneDead =
|
|
297
|
+
if (isPaneIdentityError(error)) record.paneDead = false;
|
|
291
298
|
}
|
|
292
299
|
}
|
|
293
300
|
return this.#status(record, !record.paneDead && !record.released);
|
|
@@ -485,7 +492,7 @@ export class TmuxWorkerAdapter implements WorkerAdapter {
|
|
|
485
492
|
#startMonitor(record: TmuxRecord): void {
|
|
486
493
|
record.monitor = setInterval(() => {
|
|
487
494
|
void this.#monitor(record).catch((error) => {
|
|
488
|
-
if (isPaneIdentityError(error)) record.paneDead =
|
|
495
|
+
if (isPaneIdentityError(error)) record.paneDead = false;
|
|
489
496
|
if (!record.cleanupComplete && !isMissingSession(error)) record.cleanupError = asError(error);
|
|
490
497
|
});
|
|
491
498
|
}, this.#pollIntervalMs);
|