pi-claude-supervisor 0.2.2 → 0.3.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/src/index.ts CHANGED
@@ -3,7 +3,9 @@ import { homedir } from "node:os";
3
3
  import { dirname, isAbsolute, join, relative, sep } from "node:path";
4
4
  import { realpath } from "node:fs/promises";
5
5
  import { EventLog } from "./events.ts";
6
+ import { redactSensitive } from "./redaction.ts";
6
7
  import { ProcessWorkerAdapter } from "./worker/process-adapter.ts";
8
+ import { TmuxWorkerAdapter, attachCommand } from "./worker/tmux-adapter.ts";
7
9
  import { Supervisor } from "./supervisor.ts";
8
10
  import { evaluateCommand } from "./policy.ts";
9
11
  import { HumanWebhookNotifier } from "./notifications.ts";
@@ -20,17 +22,24 @@ import { DecisionSessionStore, type DecisionSessionRecord } from "./decision-ses
20
22
  export default function piClaudeSupervisor(pi: ExtensionAPI): void {
21
23
  loadSupervisorEnvironment();
22
24
  const automation = process.env.PI_CLAUDE_SUPERVISOR_MODE === "auto" || process.env.PI_CLAUDE_SUPERVISOR_AUTOMATION === "1";
23
- const adapter = new ProcessWorkerAdapter({
24
- // Automatic decisions require Claude's structured event stream. The pipe
25
- // transport remains available for manual/compatibility sessions.
26
- mode: automation || process.env.PI_CLAUDE_SUPERVISOR_TRANSPORT === "jsonl" ? "claude-jsonl" : "process-pipe",
27
- });
25
+ const stateDir = process.env.PI_CLAUDE_SUPERVISOR_STATE_DIR ?? join(homedir(), ".pi", "agent", "claude-supervisor");
26
+ const configuredTransport = process.env.PI_CLAUDE_SUPERVISOR_TRANSPORT;
27
+ const transport = configuredTransport ?? (automation ? "jsonl" : "process-pipe");
28
+ if (!(["process-pipe", "jsonl", "tmux"] as string[]).includes(transport)) {
29
+ throw new Error(`Unsupported PI_CLAUDE_SUPERVISOR_TRANSPORT: ${transport}; expected process-pipe, jsonl, or tmux`);
30
+ }
31
+ const adapter = transport === "tmux"
32
+ ? new TmuxWorkerAdapter({ stateDir })
33
+ : new ProcessWorkerAdapter({
34
+ // Automatic decisions require Claude's structured event stream. The pipe
35
+ // transport remains available for manual/compatibility sessions.
36
+ mode: automation || transport === "jsonl" ? "claude-jsonl" : "process-pipe",
37
+ });
28
38
  const humanWebhook = new HumanWebhookNotifier({
29
39
  url: process.env.PI_CLAUDE_SUPERVISOR_HUMAN_WEBHOOK_URL,
30
40
  format: process.env.PI_CLAUDE_SUPERVISOR_HUMAN_WEBHOOK_FORMAT === "wecom" ? "wecom" : "generic",
31
41
  secret: process.env.PI_CLAUDE_SUPERVISOR_HUMAN_WEBHOOK_SECRET,
32
42
  });
33
- const stateDir = process.env.PI_CLAUDE_SUPERVISOR_STATE_DIR ?? join(homedir(), ".pi", "agent", "claude-supervisor");
34
43
  const events = new EventLog(join(stateDir, "events.jsonl"));
35
44
  const decisionStore = new DecisionSessionStore(join(stateDir, "decision-sessions"));
36
45
  const sessions = new Map<string, Supervisor>();
@@ -43,7 +52,7 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
43
52
  let shutdownPromise: Promise<void> | undefined;
44
53
 
45
54
  const notify = (ctx: ExtensionContext, message: string, type: "info" | "warning" = "info") => {
46
- if (ctx.hasUI) ctx.ui.notify(message, type);
55
+ if (ctx.hasUI) ctx.ui.notify(redactText(message), type);
47
56
  };
48
57
  const activeSessions = () => [...sessions.entries()].filter(([, session]) =>
49
58
  ["starting", "running", "waiting", "paused"].includes(session.state));
@@ -63,6 +72,14 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
63
72
  }
64
73
  };
65
74
  const stopSession = async (session: Supervisor, reason: string): Promise<void> => {
75
+ const handle = session.handle;
76
+ const persistent = adapter.capabilities().persistentSession && Boolean(handle);
77
+ const adoptedPersistent = persistent && handle?.ownership === "adopted";
78
+ const healthyPersistent = persistent && !["failed", "completed", "stopped"].includes(session.state);
79
+ if (adoptedPersistent || healthyPersistent) {
80
+ await session.release(reason);
81
+ return;
82
+ }
66
83
  let lifecycleError: unknown;
67
84
  try {
68
85
  await session.stop(reason);
@@ -70,7 +87,6 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
70
87
  lifecycleError = error;
71
88
  }
72
89
 
73
- const handle = session.handle;
74
90
  if (!handle) {
75
91
  if (lifecycleError) throw lifecycleError;
76
92
  return;
@@ -81,7 +97,7 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
81
97
  while (Date.now() <= deadline) {
82
98
  try {
83
99
  const status = await adapter.getStatus(handle);
84
- if (!status.running && status.processGroupCleaned === true) {
100
+ if (!status.running && status.processGroupCleaned === true && !status.cleanupError) {
85
101
  if (lifecycleError) throw lifecycleError;
86
102
  return;
87
103
  }
@@ -109,9 +125,11 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
109
125
  const tokens = args.trim() ? args.trim().split(/\s+/u) : [];
110
126
  const [operation = "status", ...rest] = tokens;
111
127
  let message = "";
112
- if (operation === "start") {
128
+ if (operation === "start" || operation === "adopt-tmux") {
129
+ const tmuxSession = operation === "adopt-tmux" ? rest.shift() : undefined;
113
130
  const task = rest.join(" ").trim();
114
- if (!task) throw new Error("Usage: /supervise start <task>");
131
+ if (!task) throw new Error(operation === "adopt-tmux" ? "Usage: /supervise adopt-tmux <tmux-session> <task>" : "Usage: /supervise start <task>");
132
+ if (operation === "adopt-tmux" && adapter.capabilities().transport !== "tmux") throw new Error("/supervise adopt-tmux requires PI_CLAUDE_SUPERVISOR_TRANSPORT=tmux");
115
133
  const [command, ...workerArgs] = parseCommand(process.env.PI_CLAUDE_SUPERVISOR_WORKER ?? "claude");
116
134
  if (!command) throw new Error("PI_CLAUDE_SUPERVISOR_WORKER must contain an executable");
117
135
  const policy = evaluateCommand(command, workerArgs);
@@ -137,15 +155,15 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
137
155
  pendingCwds.add(cwdKey);
138
156
  const session = new Supervisor(adapter, events, {
139
157
  onHumanRequired: async (notice) => {
140
- if (ctx.hasUI) ctx.ui.notify(`Claude Worker needs human intervention: ${notice.reason}`, "warning");
158
+ if (ctx.hasUI) notify(ctx, `Claude Worker needs human intervention: ${notice.reason}`, "warning");
141
159
  if (humanWebhook.enabled) {
142
160
  try {
143
161
  await humanWebhook.notify(notice);
144
162
  } catch (error) {
145
- console.error(`pi-claude-supervisor human webhook failed: ${error instanceof Error ? error.message : String(error)}`);
163
+ console.error(`pi-claude-supervisor human webhook failed: ${redactText(error instanceof Error ? error.message : String(error))}`);
146
164
  }
147
165
  } else {
148
- console.error(`pi-claude-supervisor human intervention required: ${notice.reason}`);
166
+ console.error(`pi-claude-supervisor human intervention required: ${redactText(notice.reason)}`);
149
167
  }
150
168
  },
151
169
  });
@@ -160,6 +178,9 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
160
178
  env: selectedWorkerEnvironment(),
161
179
  approval,
162
180
  automation,
181
+ tmuxSession,
182
+ tmuxSocket: process.env.PI_CLAUDE_SUPERVISOR_TMUX_SOCKET,
183
+ sendInitialInput: !tmuxSession,
163
184
  decisionSessionDir: decisionStore.directory,
164
185
  onDecisionSessionReady: (info) => decisionStore.save({
165
186
  taskId: info.taskId,
@@ -200,7 +221,8 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
200
221
  }
201
222
  throw new Error("Pi session shut down during worker start");
202
223
  }
203
- message = `Worker started: task=${taskId} worker=${handle.id} (pid ${handle.pid ?? "unknown"}); transport=${adapter.capabilities().transport}`;
224
+ const attach = handle.sessionName ? ` attach=${attachCommand(handle)}` : "";
225
+ message = `${tmuxSession ? "Tmux worker adopted" : "Worker started"}: task=${taskId} worker=${handle.id} (pid ${handle.pid ?? "unknown"}); transport=${adapter.capabilities().transport}${attach}`;
204
226
  } catch (error) {
205
227
  // Register failed starts before the promise settles, so shutdown
206
228
  // cannot snapshot sessions before a returned handle is retained.
@@ -263,12 +285,12 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
263
285
  pendingCwds.add(cwdKey);
264
286
  const session = new Supervisor(adapter, events, {
265
287
  onHumanRequired: async (notice) => {
266
- if (ctx.hasUI) ctx.ui.notify(`Claude Worker needs human intervention: ${notice.reason}`, "warning");
288
+ if (ctx.hasUI) notify(ctx, `Claude Worker needs human intervention: ${notice.reason}`, "warning");
267
289
  if (humanWebhook.enabled) {
268
290
  try { await humanWebhook.notify(notice); }
269
- catch (error) { console.error(`pi-claude-supervisor human webhook failed: ${error instanceof Error ? error.message : String(error)}`); }
291
+ catch (error) { console.error(`pi-claude-supervisor human webhook failed: ${redactText(error instanceof Error ? error.message : String(error))}`); }
270
292
  } else {
271
- console.error(`pi-claude-supervisor human intervention required: ${notice.reason}`);
293
+ console.error(`pi-claude-supervisor human intervention required: ${redactText(notice.reason)}`);
272
294
  }
273
295
  },
274
296
  });
@@ -282,6 +304,7 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
282
304
  // must explicitly send the next instruction.
283
305
  task: record.task,
284
306
  initialInput: "",
307
+ sendInitialInput: false,
285
308
  cwd: record.cwd,
286
309
  command: record.command,
287
310
  args: record.args,
@@ -386,7 +409,7 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
386
409
  } else if (operation === "resume-auto") {
387
410
  await session.resumeAutomation(); message = `Automatic decisions resumed: ${sessionId}.`;
388
411
  } else {
389
- throw new Error("Usage: /supervise start|recover|sessions|status|poll [all|taskId]|send [taskId]|pause [taskId]|resume [taskId]|stop [taskId]|verify [taskId]|approve [taskId] <allow|deny>|takeover [taskId]|resume-auto [taskId]|capabilities");
412
+ throw new Error("Usage: /supervise start|adopt-tmux|recover|sessions|status|poll [all|taskId]|send [taskId]|pause [taskId]|resume [taskId]|stop [taskId]|verify [taskId]|approve [taskId] <allow|deny>|takeover [taskId]|resume-auto [taskId]|capabilities");
390
413
  }
391
414
  }
392
415
  notify(ctx, message);
@@ -401,15 +424,20 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
401
424
  shuttingDown = true;
402
425
  shutdownPromise = (async () => {
403
426
  const pending = [...pendingStarts];
427
+ let startupFailures: PromiseRejectedResult[] = [];
404
428
  await Promise.race([Promise.allSettled(pending), delay(5_000)]);
405
429
  if (pendingStarts.size > 0) {
406
- await Promise.allSettled([...pendingStartSessions].map((session) => session.abortStart("Pi session shutdown during startup")));
407
- await Promise.race([Promise.allSettled([...pendingStarts]), delay(5_000)]);
430
+ const abortResults = await Promise.allSettled([...pendingStartSessions].map((session) => session.abortStart("Pi session shutdown during startup")));
431
+ startupFailures = abortResults.filter((result): result is PromiseRejectedResult => result.status === "rejected");
432
+ // Startup adapters own their cleanup and expose bounded cancellation;
433
+ // do not exit while one of those cleanups is still in flight. A model
434
+ // provider can still fail to honor disposal, so retain a final bound.
435
+ await Promise.race([Promise.allSettled([...pendingStarts]), delay(30_000)]);
408
436
  }
409
437
  const results = await Promise.allSettled([...sessions.values()].map((session) => stopSession(session, "Pi session shutdown")));
410
- const failures = results.filter((result): result is PromiseRejectedResult => result.status === "rejected");
438
+ const failures = [...startupFailures, ...results.filter((result): result is PromiseRejectedResult => result.status === "rejected")];
411
439
  if (failures.length > 0) {
412
- for (const failure of failures) console.error(`pi-claude-supervisor shutdown cleanup failed: ${failure.reason instanceof Error ? failure.reason.message : String(failure.reason)}`);
440
+ for (const failure of failures) console.error(`pi-claude-supervisor shutdown cleanup failed: ${redactText(failure.reason instanceof Error ? failure.reason.message : String(failure.reason))}`);
413
441
  if (exitCode === undefined) process.exitCode = 1;
414
442
  }
415
443
  if (exitCode !== undefined) process.exitCode = exitCode;
@@ -493,8 +521,5 @@ function delay(ms: number): Promise<void> {
493
521
  }
494
522
 
495
523
  function redactText(value: string): string {
496
- return value
497
- .replace(/\b(sk-ant-[A-Za-z0-9_-]+)\b/gu, "[REDACTED]")
498
- .replace(/\b(Bearer\s+)[^\s]+/giu, "$1[REDACTED]")
499
- .replace(/(--?(?:token|api[-_]?key|secret|password|authorization)(?:=|\s+))[^\s]+/giu, "$1[REDACTED]");
524
+ return String(redactSensitive(value));
500
525
  }
@@ -1,5 +1,6 @@
1
1
  import { createHmac, randomUUID } from "node:crypto";
2
2
  import type { HumanInterventionNotice } from "./supervisor.ts";
3
+ import { redactSensitive } from "./redaction.ts";
3
4
 
4
5
  export interface HumanWebhookOptions {
5
6
  url?: string;
@@ -46,38 +47,36 @@ function toGeneric(notice: HumanInterventionNotice): Record<string, unknown> {
46
47
  eventId: randomUUID(),
47
48
  event: "human_intervention_required",
48
49
  occurredAt: new Date().toISOString(),
49
- task: { id: notice.taskId, goal: notice.task, cwd: notice.cwd },
50
- worker: { id: notice.workerId },
51
- reason: notice.reason,
52
- question: notice.question,
53
- permission: notice.permission ? { ...notice.permission, input: sanitize(notice.permission.input) } : undefined,
50
+ task: { id: sanitize(notice.taskId), goal: sanitize(notice.task), cwd: sanitize(notice.cwd) },
51
+ worker: { id: sanitize(notice.workerId) },
52
+ reason: sanitize(notice.reason),
53
+ question: sanitize(notice.question),
54
+ permission: notice.permission ? sanitize(notice.permission) : undefined,
54
55
  actions: ["approve_or_deny_permission", "send_instruction", "stop_worker", "takeover"],
55
56
  note: "This is an outbound notification. Use the Pi session or a separately authenticated callback service to approve actions.",
56
57
  };
57
58
  }
58
59
 
59
60
  function toWeCom(notice: HumanInterventionNotice): Record<string, unknown> {
60
- const permission = notice.permission ? `\n工具: ${notice.permission.toolName}\n请求 ID: ${notice.permission.requestId}` : "";
61
- const question = notice.question ? `\n问题: ${notice.question}` : "";
61
+ const permission = notice.permission ? `\n工具: ${safeText(notice.permission.toolName)}\n请求 ID: ${safeText(notice.permission.requestId)}` : "";
62
+ const question = notice.question ? `\n问题: ${safeText(notice.question)}` : "";
62
63
  return {
63
64
  msgtype: "markdown",
64
65
  markdown: {
65
- content: `### Claude Supervisor 需要人工介入\n> 任务: ${escapeMarkdown(notice.task)}\n> Task ID: ${notice.taskId}\n> 原因: ${escapeMarkdown(notice.reason)}${escapeMarkdown(question)}${escapeMarkdown(permission)}\n\n请在 Pi 中执行对应的 approve/deny、send、stop 或 takeover 操作。`,
66
+ content: `### Claude Supervisor 需要人工介入\n> 任务: ${safeText(notice.task)}\n> Task ID: ${safeText(notice.taskId)}\n> 原因: ${safeText(notice.reason)}${escapeMarkdown(question)}${escapeMarkdown(permission)}\n\n请在 Pi 中执行对应的 approve/deny、send、stop 或 takeover 操作。`,
66
67
  },
67
68
  };
68
69
  }
69
70
 
70
71
  function sanitize(value: unknown, key?: string): unknown {
71
- if (key && /(password|secret|token|api[-_]?key|authorization|credential)/iu.test(key)) return "[REDACTED]";
72
- if (typeof value === "string") {
73
- return value
74
- .replace(/\\b(sk-ant-[A-Za-z0-9_-]+)\\b/gu, "[REDACTED]")
75
- .replace(/\\b(Bearer\\s+)[^\\s]+/giu, "$1[REDACTED]")
76
- .slice(0, 4_000);
77
- }
72
+ if (typeof value === "string") return String(redactSensitive(value, key)).slice(0, 4_000);
78
73
  if (Array.isArray(value)) return value.slice(0, 50).map((item) => sanitize(item, key));
79
74
  if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).slice(0, 50).map(([childKey, childValue]) => [childKey, sanitize(childValue, childKey)]));
80
- return value;
75
+ return redactSensitive(value, key);
76
+ }
77
+
78
+ function safeText(value: unknown): string {
79
+ return String(sanitize(value));
81
80
  }
82
81
 
83
82
  function escapeMarkdown(value: string): string {
@@ -0,0 +1,20 @@
1
+ const sensitiveKeyPattern = /(password|secret|token|api[-_]?key|authorization|credential)/iu;
2
+
3
+ /** Recursively redact credential-shaped values before persistence or model prompts. */
4
+ export function redactSensitive(value: unknown, key?: string): unknown {
5
+ if (key && sensitiveKeyPattern.test(key)) return "[REDACTED]";
6
+ if (typeof value === "string") {
7
+ return value
8
+ .replace(/\b(sk-ant-[A-Za-z0-9_-]+)\b/gu, "[REDACTED]")
9
+ .replace(/\b(?:gh[pousr]_[A-Za-z0-9_]{20,}|github_pat_[A-Za-z0-9_]{20,}|xox[baprs]-[A-Za-z0-9-]{20,}|npm_[A-Za-z0-9]{20,})\b/gu, "[REDACTED]")
10
+ .replace(/\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/gu, "[REDACTED]")
11
+ .replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/gu, "[REDACTED]")
12
+ .replace(/\b(Bearer\s+)[^\s]+/giu, "$1[REDACTED]")
13
+ .replace(/\b((?:AWS_ACCESS_KEY_ID|AWS_SECRET_ACCESS_KEY|AWS_SESSION_TOKEN|[A-Z][A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD)))=([^\s]+)/gu, "$1=[REDACTED]")
14
+ .replace(/\b((?:authorization|x-api-key|api-key)\s*:\s*)[^\s]+/giu, "$1[REDACTED]")
15
+ .replace(/(--?(?:token|api[-_]?key|secret|password|authorization)(?:=|\s+))[^\s]+/giu, "$1[REDACTED]");
16
+ }
17
+ if (Array.isArray(value)) return value.map((item) => redactSensitive(item, key));
18
+ if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([childKey, childValue]) => [childKey, redactSensitive(childValue, childKey)]));
19
+ return value;
20
+ }
package/src/supervisor.ts CHANGED
@@ -5,6 +5,7 @@ import { SupervisorStateMachine } from "./state.ts";
5
5
  import { evaluatePermission } from "./policy.ts";
6
6
  import { PiDecisionWorker, type DecisionAction } from "./decision-worker.ts";
7
7
  import { verify, type VerificationCommand } from "./verifier.ts";
8
+ import { redactSensitive } from "./redaction.ts";
8
9
  import type {
9
10
  TaskContext,
10
11
  VerificationResult,
@@ -48,7 +49,13 @@ export interface SupervisorStartOptions {
48
49
  noOutputTimeoutMs?: number;
49
50
  /** Human approval for a review-level worker command. */
50
51
  approval?: { actor: "human"; reason: string };
51
- /** Enable the event-driven Pi Decision Worker. Requires claude-jsonl. */
52
+ /** Adopt an existing tmux session instead of starting a new worker. */
53
+ tmuxSession?: string;
54
+ /** Optional socket path for an existing non-default tmux server. */
55
+ tmuxSocket?: string;
56
+ /** Do not replay the task when adopting an existing interactive session. */
57
+ sendInitialInput?: boolean;
58
+ /** Enable the event-driven Pi Decision Worker. Requires claude-jsonl or tmux. */
52
59
  automation?: boolean;
53
60
  /** Persistent Pi session location for the Decision Worker. */
54
61
  decisionSessionFile?: string;
@@ -141,8 +148,8 @@ export class Supervisor {
141
148
  ...(options.approval ? { approval: options.approval } : {}),
142
149
  },
143
150
  });
144
- if (this.#automation && this.#adapter.capabilities().transport !== "jsonl") {
145
- throw new Error("automatic supervision requires claude-jsonl transport");
151
+ if (this.#automation && !["jsonl", "tmux"].includes(this.#adapter.capabilities().transport)) {
152
+ throw new Error("automatic supervision requires claude-jsonl or tmux transport");
146
153
  }
147
154
  if (this.#automation) {
148
155
  this.#decision = new PiDecisionWorker({
@@ -173,6 +180,9 @@ export class Supervisor {
173
180
  args: options.args,
174
181
  env: options.env,
175
182
  approval: options.approval,
183
+ tmuxSession: options.tmuxSession,
184
+ tmuxSocket: options.tmuxSocket,
185
+ sendInitialInput: options.sendInitialInput,
176
186
  eventListener: (event) => this.#receiveWorkerEvent(event),
177
187
  };
178
188
  this.#handle = await this.#adapter.start(input);
@@ -181,8 +191,9 @@ export class Supervisor {
181
191
  this.#armWatchdog();
182
192
  return this.#handle;
183
193
  } catch (error) {
184
- const startFailureHandle = (error as { workerHandle?: WorkerHandle }).workerHandle;
185
- if (!this.#handle && startFailureHandle?.pid) this.#handle = startFailureHandle;
194
+ const startFailure = error as { workerHandle?: WorkerHandle; workerCleanupRequired?: boolean };
195
+ const startFailureHandle = startFailure.workerHandle;
196
+ if (!this.#handle && startFailureHandle && (startFailure.workerCleanupRequired || startFailureHandle.ownership || startFailureHandle.sessionName)) this.#handle = startFailureHandle;
186
197
  const handle = this.#handle;
187
198
  if (handle) {
188
199
  try {
@@ -289,7 +300,7 @@ export class Supervisor {
289
300
  }
290
301
  try {
291
302
  if (this.#onHumanRequired) await this.#onHumanRequired(notice);
292
- else console.error(`pi-claude-supervisor human intervention required: ${reason}`);
303
+ else console.error(`pi-claude-supervisor human intervention required: ${safeMessage(reason)}`);
293
304
  } catch (notifyError) {
294
305
  console.error(`pi-claude-supervisor human intervention notification failed: ${safeMessage(notifyError)}`);
295
306
  }
@@ -350,6 +361,11 @@ export class Supervisor {
350
361
  return;
351
362
  }
352
363
  if (action.action === "verify") {
364
+ if (this.#machine.state === "waiting" && this.#adapter.capabilities().persistentSession) {
365
+ this.#machine.transition("verifying");
366
+ await this.#verifyInternal();
367
+ return;
368
+ }
353
369
  if (this.#machine.state === "waiting") {
354
370
  await this.#adapter.stop(handle, "Decision Worker requested verification");
355
371
  await this.#pollInternal(true);
@@ -393,7 +409,7 @@ export class Supervisor {
393
409
  // Alert delivery is independent from event-log persistence: a broken audit
394
410
  // path must not suppress the operator notification.
395
411
  if (this.#onHumanRequired) await this.#onHumanRequired(notice);
396
- else console.error(`pi-claude-supervisor human intervention required: ${reason}`);
412
+ else console.error(`pi-claude-supervisor human intervention required: ${safeMessage(reason)}`);
397
413
  if (logError) throw logError;
398
414
  }
399
415
 
@@ -440,7 +456,9 @@ export class Supervisor {
440
456
  if (!["running", "waiting"].includes(this.#machine.state)) throw new Error(`cannot send from ${this.#machine.state}`);
441
457
  const status = await this.#adapter.getStatus(handle);
442
458
  if (status.activeRequests !== undefined && status.activeRequests > 0) {
443
- throw new Error("worker has an active JSONL request; poll until its result before sending another turn");
459
+ throw new Error(this.#adapter.capabilities().transport === "jsonl"
460
+ ? "worker has an active JSONL request; poll until its result before sending the next turn"
461
+ : "worker has an active turn; wait until its interactive prompt or structured result is ready before sending another turn");
444
462
  }
445
463
  if (status.activeRequests === 0 && this.#machine.state === "running") {
446
464
  this.#machine.transition("waiting");
@@ -480,9 +498,35 @@ export class Supervisor {
480
498
  async abortStart(reason = "startup aborted"): Promise<void> {
481
499
  // This path intentionally bypasses #exclusive(): start() may be blocked in
482
500
  // a Decision Worker model call and shutdown must still dispose that session.
501
+ let cleanupError: unknown;
502
+ const abort = this.#adapter.abortStart?.(reason);
503
+ if (abort) {
504
+ try { await abort; }
505
+ catch (error) { cleanupError = error; }
506
+ }
483
507
  await this.#decision?.close().catch(() => {});
484
508
  this.#decision = undefined;
485
- if (this.#handle) await this.#adapter.stop(this.#handle, reason).catch(() => {});
509
+ if (this.#handle) {
510
+ try { await this.#adapter.stop(this.#handle, reason); }
511
+ catch (error) { cleanupError ??= error; }
512
+ }
513
+ if (cleanupError) throw cleanupError;
514
+ }
515
+
516
+ async release(reason = "Pi session disconnected"): Promise<void> {
517
+ const handle = this.#handle;
518
+ const preemptiveRelease = handle
519
+ ? this.#adapter.release
520
+ ? this.#adapter.release(handle, reason)
521
+ : this.#adapter.stop(handle, reason)
522
+ : this.#adapter.abortStart?.(reason) ?? Promise.resolve();
523
+ await this.#decision?.close().catch(() => {});
524
+ this.#decision = undefined;
525
+ await withTimeout(this.#exclusive(async () => {
526
+ this.#clearWatchdog();
527
+ await preemptiveRelease;
528
+ if (handle) await this.#appendEvent({ type: "worker_released", taskId: this.#task?.taskId, workerId: handle.id, data: { reason } });
529
+ }), 15_000, "persistent worker release");
486
530
  }
487
531
 
488
532
  async stop(reason = "human requested stop"): Promise<void> {
@@ -509,19 +553,44 @@ export class Supervisor {
509
553
  await Promise.resolve(this.#onDecisionSessionClosed?.(this.#task?.taskId ?? "")).catch(() => {});
510
554
  return;
511
555
  }
556
+ if (this.#machine.state === "completed") {
557
+ if (this.#handle) {
558
+ await this.#adapter.stop(this.#handle, reason);
559
+ await this.#drainOutputAfterStop(this.#handle);
560
+ }
561
+ return;
562
+ }
512
563
  if (!["running", "waiting", "paused", "starting"].includes(this.#machine.state)) return;
513
564
  this.#clearWatchdog();
514
565
  const preemptiveStop = this.#preemptiveStop;
515
566
  this.#preemptiveStop = undefined;
516
567
  if (preemptiveStop) await preemptiveStop;
517
568
  else await this.#adapter.stop(this.#handle, reason);
569
+ let outputError: unknown;
570
+ try {
571
+ await this.#drainOutputAfterStop(this.#handle);
572
+ } catch (error) {
573
+ outputError = error;
574
+ }
518
575
  this.#machine.transition("stopped");
519
576
  await this.#appendEvent({ type: "worker_stopped", taskId: this.#task?.taskId, workerId: this.#handle.id, data: { reason } });
577
+ if (outputError) throw outputError;
520
578
  await this.#decision?.close().catch(() => {});
521
579
  this.#decision = undefined;
522
580
  await Promise.resolve(this.#onDecisionSessionClosed?.(this.#task?.taskId ?? "")).catch(() => {});
523
581
  }
524
582
 
583
+ async #drainOutputAfterStop(handle: WorkerHandle): Promise<void> {
584
+ const output = await this.#adapter.readOutput(handle);
585
+ if (!output.length) return;
586
+ try {
587
+ await this.#appendEvent({ type: "worker_output", taskId: this.#task?.taskId, workerId: handle.id, data: { chunks: output } });
588
+ } catch (error) {
589
+ if (this.#adapter.restoreOutput) await this.#adapter.restoreOutput(handle, output).catch(() => {});
590
+ throw error;
591
+ }
592
+ }
593
+
525
594
  async verify(command?: VerificationCommand): Promise<VerificationResult> {
526
595
  return this.#exclusive(() => this.#verifyInternal(command));
527
596
  }
@@ -529,17 +598,70 @@ export class Supervisor {
529
598
  async #verifyInternal(command?: VerificationCommand): Promise<VerificationResult> {
530
599
  await this.#flushPendingEvents();
531
600
  if (!this.#task) throw new Error("no active task");
601
+ if (this.#machine.state === "waiting" && this.#adapter.capabilities().persistentSession) this.#machine.transition("verifying");
532
602
  if (this.#machine.state !== "verifying") throw new Error(`cannot verify from ${this.#machine.state}`);
533
- const result = await verify(this.#task.cwd, command);
603
+ let result: VerificationResult;
604
+ try {
605
+ result = await verify(this.#task.cwd, command);
606
+ } catch (error) {
607
+ await this.#failVerification(error);
608
+ throw error;
609
+ }
610
+ this.#clearWatchdog();
611
+ let cleanupError: unknown;
612
+ if (this.#handle) {
613
+ try {
614
+ await this.#adapter.stop(this.#handle, result.ok ? "verification passed" : "verification failed");
615
+ await this.#drainOutputAfterStop(this.#handle);
616
+ const cleanup = await this.#adapter.getStatus(this.#handle);
617
+ if (cleanup.cleanupError) throw new Error(`worker cleanup failed after verification: ${cleanup.cleanupError}`);
618
+ if (this.#handle.ownership === "owned" && (cleanup.running || cleanup.processGroupCleaned !== true)) {
619
+ throw new Error("owned worker cleanup was not confirmed after verification");
620
+ }
621
+ } catch (error) {
622
+ cleanupError = error;
623
+ }
624
+ }
534
625
  this.#lastVerification = result;
535
- this.#machine.transition(result.ok ? "completed" : "failed");
536
- await this.#appendEvent({ type: result.ok ? "verification_passed" : "verification_failed", taskId: this.#task.taskId, workerId: this.#handle?.id, data: { ...result } });
626
+ const verificationSucceeded = result.ok && !cleanupError;
627
+ this.#machine.transition(verificationSucceeded ? "completed" : "failed");
628
+ await this.#appendEvent({ type: verificationSucceeded ? "verification_passed" : "verification_failed", taskId: this.#task.taskId, workerId: this.#handle?.id, data: { ...result, ...(cleanupError ? { cleanupError: safeMessage(cleanupError) } : {}) } });
537
629
  await this.#decision?.close().catch(() => {});
538
630
  this.#decision = undefined;
539
631
  await Promise.resolve(this.#onDecisionSessionClosed?.(this.#task.taskId)).catch(() => {});
632
+ if (cleanupError) throw cleanupError;
540
633
  return result;
541
634
  }
542
635
 
636
+ async #failVerification(error: unknown): Promise<void> {
637
+ this.#clearWatchdog();
638
+ let cleanupError: unknown;
639
+ if (this.#handle) {
640
+ try {
641
+ await this.#adapter.stop(this.#handle, "verification failed");
642
+ await this.#drainOutputAfterStop(this.#handle);
643
+ } catch (stopError) {
644
+ cleanupError = stopError;
645
+ }
646
+ }
647
+ if (this.#machine.state === "verifying") this.#machine.transition("failed");
648
+ if (this.#task) {
649
+ try {
650
+ await this.#appendEvent({
651
+ type: "verification_failed",
652
+ taskId: this.#task.taskId,
653
+ workerId: this.#handle?.id,
654
+ data: { error: safeMessage(error), ...(cleanupError ? { cleanupError: safeMessage(cleanupError) } : {}) },
655
+ });
656
+ } catch {
657
+ // Preserve the verifier error; the event remains a pending lifecycle record.
658
+ }
659
+ await this.#decision?.close().catch(() => {});
660
+ this.#decision = undefined;
661
+ await Promise.resolve(this.#onDecisionSessionClosed?.(this.#task.taskId)).catch(() => {});
662
+ }
663
+ }
664
+
543
665
  #armWatchdog(): void {
544
666
  if (this.#deadlineMs <= 0 && this.#noOutputTimeoutMs <= 0) return;
545
667
  this.#watchdog = setInterval(() => { void this.#checkWatchdog().catch(() => { /* lifecycle state is retained for the next explicit operation */ }); }, 1_000);
@@ -618,6 +740,19 @@ export class Supervisor {
618
740
  }
619
741
  }
620
742
 
743
+ async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
744
+ let timer: NodeJS.Timeout | undefined;
745
+ const timeout = new Promise<never>((_, reject) => {
746
+ timer = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs);
747
+ timer.unref();
748
+ });
749
+ try {
750
+ return await Promise.race([promise, timeout]);
751
+ } finally {
752
+ if (timer) clearTimeout(timer);
753
+ }
754
+ }
755
+
621
756
  function workerEventKey(event: WorkerEvent): string {
622
757
  if (event.type === "permission_request") return `${event.handle.id}:permission:${event.request.requestId}`;
623
758
  if (event.type === "turn_completed") return `${event.handle.id}:result:${event.sequence}`;
@@ -627,5 +762,5 @@ function workerEventKey(event: WorkerEvent): string {
627
762
  }
628
763
 
629
764
  function safeMessage(error: unknown): string {
630
- return error instanceof Error ? error.message : String(error);
765
+ return String(redactSensitive(error instanceof Error ? error.message : String(error)));
631
766
  }
package/src/types.ts CHANGED
@@ -36,6 +36,12 @@ export interface WorkerStartInput {
36
36
  env?: NodeJS.ProcessEnv;
37
37
  approval?: { actor: "human"; reason: string };
38
38
  eventListener?: WorkerEventListener;
39
+ /** Attach to an existing tmux session instead of starting a new worker. */
40
+ tmuxSession?: string;
41
+ /** Optional tmux socket path; omitted means the user's default server. */
42
+ tmuxSocket?: string;
43
+ /** Adopted sessions must not replay the task as a new user message. */
44
+ sendInitialInput?: boolean;
39
45
  }
40
46
 
41
47
  export interface WorkerHandle {
@@ -44,6 +50,9 @@ export interface WorkerHandle {
44
50
  startedAt: string;
45
51
  cwd: string;
46
52
  sessionId?: string;
53
+ sessionName?: string;
54
+ tmuxSocket?: string;
55
+ ownership?: "owned" | "adopted";
47
56
  }
48
57
 
49
58
  export interface WorkerStatus {
@@ -75,6 +84,8 @@ export interface PermissionDecision {
75
84
  export interface WorkerAdapter {
76
85
  capabilities(): WorkerCapabilities;
77
86
  start(input: WorkerStartInput): Promise<WorkerHandle>;
87
+ /** Cancel adapter-owned startup work before a WorkerHandle is returned. */
88
+ abortStart?(reason: string): Promise<void>;
78
89
  getStatus(handle: WorkerHandle): Promise<WorkerStatus>;
79
90
  readOutput(handle: WorkerHandle): Promise<WorkerOutputChunk[]>;
80
91
  /** Restore chunks when diagnostic event persistence fails before acknowledgement. */
@@ -87,6 +98,8 @@ export interface WorkerAdapter {
87
98
  pause(handle: WorkerHandle): Promise<void>;
88
99
  resume(handle: WorkerHandle): Promise<void>;
89
100
  stop(handle: WorkerHandle, reason: string): Promise<void>;
101
+ /** Disconnect the supervisor without stopping a persistent worker, if supported. */
102
+ release?(handle: WorkerHandle, reason: string): Promise<void>;
90
103
  killProcessGroup(handle: WorkerHandle, reason: string): Promise<void>;
91
104
  resumeSession(sessionId: string): Promise<WorkerHandle>;
92
105
  }
@@ -98,11 +111,13 @@ export interface WorkerOutputChunk {
98
111
  }
99
112
 
100
113
  export interface WorkerCapabilities {
101
- transport: "process-pipe" | "pty" | "jsonl";
114
+ transport: "process-pipe" | "pty" | "jsonl" | "tmux";
102
115
  interactiveInput: boolean;
103
116
  pause: boolean;
104
117
  resumeSession: boolean;
105
118
  processGroupControl: boolean;
119
+ /** The worker can remain alive while Pi disconnects from it. */
120
+ persistentSession?: boolean;
106
121
  }
107
122
 
108
123
  export interface TaskContext {
@@ -192,7 +192,11 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
192
192
  } catch (error) {
193
193
  try { await this.#ensureGroupCleanup(record); } catch (cleanupError) { record.cleanupError = cleanupError instanceof Error ? cleanupError : new Error(String(cleanupError)); }
194
194
  const startupError = error instanceof Error ? error : new Error(String(error));
195
- Object.defineProperty(startupError, "workerHandle", { value: handle, enumerable: false });
195
+ if (record.cleanupError) {
196
+ startupError.message = `${startupError.message}; startup cleanup failed: ${record.cleanupError.message}`;
197
+ Object.defineProperty(startupError, "workerHandle", { value: handle, enumerable: false });
198
+ Object.defineProperty(startupError, "workerCleanupRequired", { value: true, enumerable: false });
199
+ }
196
200
  throw startupError;
197
201
  }
198
202
  if (input.task) {
@@ -200,7 +204,16 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
200
204
  try {
201
205
  await this.#writeInput(record, this.#encodeMessage(input.task));
202
206
  } catch (error) {
203
- await this.stop(handle, "initial worker input failed");
207
+ let cleanupError: unknown;
208
+ try { await this.stop(handle, "initial worker input failed"); }
209
+ catch (stopError) { cleanupError = stopError; }
210
+ if (cleanupError) {
211
+ const startupError = error instanceof Error ? error : new Error(String(error));
212
+ startupError.message = `${startupError.message}; startup cleanup failed: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`;
213
+ Object.defineProperty(startupError, "workerHandle", { value: handle, enumerable: false });
214
+ Object.defineProperty(startupError, "workerCleanupRequired", { value: true, enumerable: false });
215
+ throw startupError;
216
+ }
204
217
  throw error;
205
218
  }
206
219
  }