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 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。对被接管的 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. It does not claim ownership: `/supervise stop` and Pi shutdown detach supervision rather
131
- than killing the user's tmux session. Use `tmux kill-session` yourself when the
132
- adopted window should be closed. `/supervise takeover <task-id>` disables
133
- automatic Decision Worker messages; resume them only with
134
- `/supervise resume-auto <task-id>`.
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
@@ -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 verifies two real Claude turns,
81
- pause/resume, owned detach, and identity-bound restart re-adoption.
82
-
83
- For each release, pin and record the validated Claude Code version and resolved
84
- executable path. For this release the validated version is `2.1.270`. Record:
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, manually verify: private-socket attach, multi-line paste, prompt
107
- stability while Claude is busy, trust/permission dialog takeover, duplicate
108
- send prevention, pane replacement refusal, pause/resume, owned-session stop,
109
- adopted-session detach/re-adoption, bounded shutdown, and Pi shutdown without
110
- closing an attached window. Use `--permission-mode plan`
111
- and read-only tools for live Claude checks. Do not run JSONL and tmux control
112
- against the same Claude process, and do not treat `capture-pane` text as a
113
- structured permission response.
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.0",
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
- if (!["completed", "stopped", "failed"].includes(session.state)) continue;
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
- if (!status.running && status.processGroupCleaned === true && !status.cleanupError && (!status.cgroupError || status.cgroupRequired === false)) {
97
- if (await releaseLease(taskId)) {
98
- reservedCwds.delete(taskId);
99
- cleanupRequiredTasks.delete(taskId);
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 (!pane.dead) await this.#rememberPaneIdentity(record, pane.pid);
267
+ if (pane.dead) record.cleanupError = undefined;
268
+ else await this.#rememberPaneIdentity(record, pane.pid);
268
269
  } catch (error) {
269
- if (isMissingSession(error) || isPaneIdentityError(error)) record.paneDead = true;
270
- else record.cleanupError = asError(error);
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 = true;
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 = true;
495
+ if (isPaneIdentityError(error)) record.paneDead = false;
489
496
  if (!record.cleanupComplete && !isMissingSession(error)) record.cleanupError = asError(error);
490
497
  });
491
498
  }, this.#pollIntervalMs);