pi-claude-supervisor 0.2.2 → 0.4.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/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,15 @@ 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
+ /** Persisted identity required when handing off an existing tmux lease. */
57
+ tmuxExpectedIdentity?: WorkerStartInput["tmuxExpectedIdentity"];
58
+ /** Do not replay the task when adopting an existing interactive session. */
59
+ sendInitialInput?: boolean;
60
+ /** Enable the event-driven Pi Decision Worker. Requires claude-jsonl or tmux. */
52
61
  automation?: boolean;
53
62
  /** Persistent Pi session location for the Decision Worker. */
54
63
  decisionSessionFile?: string;
@@ -94,6 +103,11 @@ export class Supervisor {
94
103
  #humanRequired = false;
95
104
  #onDecisionSessionProgress?: (info: { taskId: string; turn: number }) => Promise<void> | void;
96
105
  #onDecisionSessionClosed?: (taskId: string) => Promise<void> | void;
106
+ #startAbortController?: AbortController;
107
+ #startToken?: string;
108
+ #startStopReason?: string;
109
+ #startAbortError?: unknown;
110
+ #startAbortCompletion?: Promise<void>;
97
111
 
98
112
  constructor(adapter: WorkerAdapter, events = new EventLog(), hooks: { onHumanRequired?: (notice: HumanInterventionNotice) => Promise<void> | void } = {}) {
99
113
  this.#adapter = adapter;
@@ -131,6 +145,11 @@ export class Supervisor {
131
145
  this.#noOutputTimeoutMs = options.noOutputTimeoutMs ?? 20 * 60_000;
132
146
  this.#clearWatchdog();
133
147
  this.#machine.transition("starting");
148
+ const startAbortController = new AbortController();
149
+ this.#startAbortController = startAbortController;
150
+ this.#startToken = randomUUID();
151
+ this.#startStopReason = undefined;
152
+ this.#startAbortError = undefined;
134
153
  try {
135
154
  await this.#appendEvent({
136
155
  type: "task_started",
@@ -141,9 +160,10 @@ export class Supervisor {
141
160
  ...(options.approval ? { approval: options.approval } : {}),
142
161
  },
143
162
  });
144
- if (this.#automation && this.#adapter.capabilities().transport !== "jsonl") {
145
- throw new Error("automatic supervision requires claude-jsonl transport");
163
+ if (this.#automation && !["jsonl", "tmux"].includes(this.#adapter.capabilities().transport)) {
164
+ throw new Error("automatic supervision requires claude-jsonl or tmux transport");
146
165
  }
166
+ this.#assertStartNotAborted(startAbortController.signal);
147
167
  if (this.#automation) {
148
168
  this.#decision = new PiDecisionWorker({
149
169
  context: { taskId, task: options.task, cwd: options.cwd, state: this.#machine.state, turn: this.#turn, maxTurns: this.#task.maxTurns },
@@ -166,6 +186,7 @@ export class Supervisor {
166
186
  });
167
187
  await this.#decision.start();
168
188
  }
189
+ this.#assertStartNotAborted(startAbortController.signal);
169
190
  const input: WorkerStartInput = {
170
191
  task: options.initialInput ?? options.task,
171
192
  cwd: options.cwd,
@@ -173,35 +194,68 @@ export class Supervisor {
173
194
  args: options.args,
174
195
  env: options.env,
175
196
  approval: options.approval,
197
+ tmuxSession: options.tmuxSession,
198
+ tmuxSocket: options.tmuxSocket,
199
+ tmuxExpectedIdentity: options.tmuxExpectedIdentity,
200
+ sendInitialInput: options.sendInitialInput,
176
201
  eventListener: (event) => this.#receiveWorkerEvent(event),
202
+ abortSignal: startAbortController.signal,
203
+ startupToken: this.#startToken,
177
204
  };
178
205
  this.#handle = await this.#adapter.start(input);
206
+ this.#assertStartNotAborted(startAbortController.signal);
179
207
  this.#machine.transition("running");
180
208
  await this.#appendEvent({ type: "worker_started", taskId, workerId: this.#handle.id, data: { pid: this.#handle.pid } });
181
209
  this.#armWatchdog();
210
+ this.#startAbortController = undefined;
211
+ this.#startToken = undefined;
212
+ this.#startStopReason = undefined;
213
+ this.#startAbortError = undefined;
182
214
  return this.#handle;
183
215
  } catch (error) {
184
- const startFailureHandle = (error as { workerHandle?: WorkerHandle }).workerHandle;
185
- if (!this.#handle && startFailureHandle?.pid) this.#handle = startFailureHandle;
216
+ const startupError = error instanceof Error ? error : new Error(String(error));
217
+ const startFailure = startupError as Error & { workerHandle?: WorkerHandle; workerCleanupRequired?: boolean };
218
+ const startFailureHandle = startFailure.workerHandle;
219
+ if (!this.#handle && startFailureHandle && (startFailure.workerCleanupRequired || startFailureHandle.ownership || startFailureHandle.sessionName)) this.#handle = startFailureHandle;
186
220
  const handle = this.#handle;
221
+ const startupCancelled = Boolean(this.#startStopReason || startAbortController.signal.aborted);
222
+ let startupCleanupError: unknown = this.#startAbortError;
223
+ const abortCompletion = this.#startAbortCompletion;
224
+ if (abortCompletion) {
225
+ try { await abortCompletion; }
226
+ catch (error) { startupCleanupError ??= error; }
227
+ }
187
228
  if (handle) {
188
229
  try {
189
- await this.#adapter.stop(handle, "startup failed");
190
- } catch {
191
- try { await this.#adapter.killProcessGroup(handle, "startup cleanup"); } catch { /* preserve startup error */ }
230
+ await this.#adapter.stop(handle, startupCancelled ? (this.#startStopReason ?? "startup aborted") : "startup failed");
231
+ } catch (error) {
232
+ startupCleanupError = error;
233
+ try { await this.#adapter.killProcessGroup(handle, "startup cleanup"); }
234
+ catch (cleanupError) { startupCleanupError ??= cleanupError; }
192
235
  }
193
236
  }
194
- if (["starting", "running"].includes(this.#machine.state)) this.#machine.transition("failed");
237
+ if (startupCleanupError && !startFailure.workerCleanupRequired) {
238
+ Object.defineProperty(startupError, "workerCleanupRequired", { value: true, enumerable: false });
239
+ }
240
+ if (["starting", "running"].includes(this.#machine.state)) this.#machine.transition(startupCancelled && !startupCleanupError ? "stopped" : "failed");
195
241
  try {
196
- await this.#appendEvent({ type: "worker_start_failed", taskId, data: { error: safeMessage(error) } });
242
+ await this.#appendEvent({ type: "worker_start_failed", taskId, data: { error: safeMessage(startupError) } });
197
243
  } catch { /* logging failure must not hide the startup failure */ }
198
244
  await this.#decision?.close().catch(() => {});
199
245
  this.#decision = undefined;
200
246
  await Promise.resolve(this.#onDecisionSessionClosed?.(taskId)).catch(() => {});
201
- throw error;
247
+ this.#startAbortController = undefined;
248
+ this.#startToken = undefined;
249
+ this.#startStopReason = undefined;
250
+ this.#startAbortError = undefined;
251
+ throw startupError;
202
252
  }
203
253
  }
204
254
 
255
+ #assertStartNotAborted(signal: AbortSignal): void {
256
+ if (signal.aborted) throw new Error(`worker startup aborted: ${this.#startStopReason ?? "startup cancellation requested"}`);
257
+ }
258
+
205
259
  async poll(): Promise<{ status: WorkerStatus; output: WorkerOutputChunk[] }> {
206
260
  return this.#exclusive(() => this.#pollInternal());
207
261
  }
@@ -289,7 +343,7 @@ export class Supervisor {
289
343
  }
290
344
  try {
291
345
  if (this.#onHumanRequired) await this.#onHumanRequired(notice);
292
- else console.error(`pi-claude-supervisor human intervention required: ${reason}`);
346
+ else console.error(`pi-claude-supervisor human intervention required: ${safeMessage(reason)}`);
293
347
  } catch (notifyError) {
294
348
  console.error(`pi-claude-supervisor human intervention notification failed: ${safeMessage(notifyError)}`);
295
349
  }
@@ -350,6 +404,11 @@ export class Supervisor {
350
404
  return;
351
405
  }
352
406
  if (action.action === "verify") {
407
+ if (this.#machine.state === "waiting" && this.#adapter.capabilities().persistentSession) {
408
+ this.#machine.transition("verifying");
409
+ await this.#verifyInternal();
410
+ return;
411
+ }
353
412
  if (this.#machine.state === "waiting") {
354
413
  await this.#adapter.stop(handle, "Decision Worker requested verification");
355
414
  await this.#pollInternal(true);
@@ -393,7 +452,7 @@ export class Supervisor {
393
452
  // Alert delivery is independent from event-log persistence: a broken audit
394
453
  // path must not suppress the operator notification.
395
454
  if (this.#onHumanRequired) await this.#onHumanRequired(notice);
396
- else console.error(`pi-claude-supervisor human intervention required: ${reason}`);
455
+ else console.error(`pi-claude-supervisor human intervention required: ${safeMessage(reason)}`);
397
456
  if (logError) throw logError;
398
457
  }
399
458
 
@@ -440,7 +499,9 @@ export class Supervisor {
440
499
  if (!["running", "waiting"].includes(this.#machine.state)) throw new Error(`cannot send from ${this.#machine.state}`);
441
500
  const status = await this.#adapter.getStatus(handle);
442
501
  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");
502
+ throw new Error(this.#adapter.capabilities().transport === "jsonl"
503
+ ? "worker has an active JSONL request; poll until its result before sending the next turn"
504
+ : "worker has an active turn; wait until its interactive prompt or structured result is ready before sending another turn");
444
505
  }
445
506
  if (status.activeRequests === 0 && this.#machine.state === "running") {
446
507
  this.#machine.transition("waiting");
@@ -478,14 +539,66 @@ export class Supervisor {
478
539
  }
479
540
 
480
541
  async abortStart(reason = "startup aborted"): Promise<void> {
542
+ if (this.#machine.state !== "starting" && !this.#startAbortController) return;
481
543
  // This path intentionally bypasses #exclusive(): start() may be blocked in
482
544
  // a Decision Worker model call and shutdown must still dispose that session.
545
+ this.#startStopReason = reason;
546
+ const completion = (async () => {
547
+ this.#startAbortController?.abort(reason);
548
+ let cleanupError: unknown;
549
+ const abort = this.#adapter.abortStart?.(reason, this.#startToken);
550
+ if (abort) {
551
+ try { await abort; }
552
+ catch (error) { cleanupError = error; }
553
+ }
554
+ await this.#decision?.close().catch(() => {});
555
+ this.#decision = undefined;
556
+ if (this.#handle) {
557
+ try { await this.#adapter.stop(this.#handle, reason); }
558
+ catch (error) { cleanupError ??= error; }
559
+ }
560
+ if (cleanupError) throw cleanupError;
561
+ })();
562
+ this.#startAbortCompletion = completion;
563
+ try {
564
+ await completion;
565
+ } catch (error) {
566
+ this.#startAbortError = error;
567
+ throw error;
568
+ } finally {
569
+ if (this.#startAbortCompletion === completion) this.#startAbortCompletion = undefined;
570
+ }
571
+ }
572
+
573
+ async release(reason = "Pi session disconnected"): Promise<void> {
574
+ const handle = this.#handle;
575
+ const preemptiveRelease = handle
576
+ ? this.#adapter.release
577
+ ? this.#adapter.release(handle, reason)
578
+ : this.#adapter.stop(handle, reason)
579
+ : this.#machine.state === "starting" || this.#startAbortController
580
+ ? this.abortStart(reason)
581
+ : Promise.resolve();
483
582
  await this.#decision?.close().catch(() => {});
484
583
  this.#decision = undefined;
485
- if (this.#handle) await this.#adapter.stop(this.#handle, reason).catch(() => {});
584
+ await withTimeout(this.#exclusive(async () => {
585
+ this.#clearWatchdog();
586
+ await preemptiveRelease;
587
+ if (handle) await this.#appendEvent({ type: "worker_released", taskId: this.#task?.taskId, workerId: handle.id, data: { reason } });
588
+ }), 15_000, "persistent worker release");
486
589
  }
487
590
 
488
591
  async stop(reason = "human requested stop"): Promise<void> {
592
+ // A stop must be able to preempt startup rather than waiting behind a
593
+ // startup operation that is blocked in a provider or adapter call.
594
+ if (this.#machine.state === "starting") {
595
+ this.#startStopReason = reason;
596
+ this.#startAbortController?.abort(reason);
597
+ if (!this.#handle) {
598
+ await withTimeout(this.abortStart(reason), 15_000, "worker startup cancellation");
599
+ return;
600
+ }
601
+ }
489
602
  // Start the adapter stop immediately so a queued/hung send cannot delay
490
603
  // process termination. State/event changes still remain serialized below.
491
604
  if (!this.#preemptiveStop && this.#handle && ["starting", "running", "waiting", "paused"].includes(this.#machine.state)) {
@@ -498,30 +611,63 @@ export class Supervisor {
498
611
  async #stopInternal(reason: string, flushPendingEvents = true): Promise<void> {
499
612
  if (flushPendingEvents) await this.#flushPendingEvents();
500
613
  if (!this.#handle) throw new Error("no active task");
501
- if (this.#machine.state === "stopped") return;
614
+ if (this.#machine.state === "stopped") {
615
+ const preemptiveStop = this.#preemptiveStop;
616
+ this.#preemptiveStop = undefined;
617
+ if (preemptiveStop) await preemptiveStop;
618
+ return;
619
+ }
502
620
  if (this.#machine.state === "failed") {
503
621
  // A failed startup or cleanup attempt may still retain a live handle.
504
622
  // Retry group termination during shutdown instead of treating the state
505
623
  // as fully reclaimed.
506
- if (this.#handle) await this.#adapter.stop(this.#handle, reason);
624
+ const preemptiveStop = this.#preemptiveStop;
625
+ this.#preemptiveStop = undefined;
626
+ if (preemptiveStop) await preemptiveStop;
627
+ else if (this.#handle) await this.#adapter.stop(this.#handle, reason);
507
628
  await this.#decision?.close().catch(() => {});
508
629
  this.#decision = undefined;
509
630
  await Promise.resolve(this.#onDecisionSessionClosed?.(this.#task?.taskId ?? "")).catch(() => {});
510
631
  return;
511
632
  }
633
+ if (this.#machine.state === "completed") {
634
+ if (this.#handle) {
635
+ await this.#adapter.stop(this.#handle, reason);
636
+ await this.#drainOutputAfterStop(this.#handle);
637
+ }
638
+ return;
639
+ }
512
640
  if (!["running", "waiting", "paused", "starting"].includes(this.#machine.state)) return;
513
641
  this.#clearWatchdog();
514
642
  const preemptiveStop = this.#preemptiveStop;
515
643
  this.#preemptiveStop = undefined;
516
644
  if (preemptiveStop) await preemptiveStop;
517
645
  else await this.#adapter.stop(this.#handle, reason);
646
+ let outputError: unknown;
647
+ try {
648
+ await this.#drainOutputAfterStop(this.#handle);
649
+ } catch (error) {
650
+ outputError = error;
651
+ }
518
652
  this.#machine.transition("stopped");
519
653
  await this.#appendEvent({ type: "worker_stopped", taskId: this.#task?.taskId, workerId: this.#handle.id, data: { reason } });
654
+ if (outputError) throw outputError;
520
655
  await this.#decision?.close().catch(() => {});
521
656
  this.#decision = undefined;
522
657
  await Promise.resolve(this.#onDecisionSessionClosed?.(this.#task?.taskId ?? "")).catch(() => {});
523
658
  }
524
659
 
660
+ async #drainOutputAfterStop(handle: WorkerHandle): Promise<void> {
661
+ const output = await this.#adapter.readOutput(handle);
662
+ if (!output.length) return;
663
+ try {
664
+ await this.#appendEvent({ type: "worker_output", taskId: this.#task?.taskId, workerId: handle.id, data: { chunks: output } });
665
+ } catch (error) {
666
+ if (this.#adapter.restoreOutput) await this.#adapter.restoreOutput(handle, output).catch(() => {});
667
+ throw error;
668
+ }
669
+ }
670
+
525
671
  async verify(command?: VerificationCommand): Promise<VerificationResult> {
526
672
  return this.#exclusive(() => this.#verifyInternal(command));
527
673
  }
@@ -529,17 +675,71 @@ export class Supervisor {
529
675
  async #verifyInternal(command?: VerificationCommand): Promise<VerificationResult> {
530
676
  await this.#flushPendingEvents();
531
677
  if (!this.#task) throw new Error("no active task");
678
+ if (this.#machine.state === "waiting" && this.#adapter.capabilities().persistentSession) this.#machine.transition("verifying");
532
679
  if (this.#machine.state !== "verifying") throw new Error(`cannot verify from ${this.#machine.state}`);
533
- const result = await verify(this.#task.cwd, command);
680
+ let result: VerificationResult;
681
+ try {
682
+ result = await verify(this.#task.cwd, command);
683
+ } catch (error) {
684
+ await this.#failVerification(error);
685
+ throw error;
686
+ }
687
+ this.#clearWatchdog();
688
+ let cleanupError: unknown;
689
+ if (this.#handle) {
690
+ try {
691
+ await this.#adapter.stop(this.#handle, result.ok ? "verification passed" : "verification failed");
692
+ await this.#drainOutputAfterStop(this.#handle);
693
+ const cleanup = await this.#adapter.getStatus(this.#handle);
694
+ if (cleanup.cleanupError) throw new Error(`worker cleanup failed after verification: ${cleanup.cleanupError}`);
695
+ if (cleanup.cgroupError && cleanup.cgroupRequired !== false) throw new Error(`worker cgroup cleanup failed after verification: ${cleanup.cgroupError}`);
696
+ if (this.#handle.ownership === "owned" && (cleanup.running || cleanup.processGroupCleaned !== true)) {
697
+ throw new Error("owned worker cleanup was not confirmed after verification");
698
+ }
699
+ } catch (error) {
700
+ cleanupError = error;
701
+ }
702
+ }
534
703
  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 } });
704
+ const verificationSucceeded = result.ok && !cleanupError;
705
+ this.#machine.transition(verificationSucceeded ? "completed" : "failed");
706
+ await this.#appendEvent({ type: verificationSucceeded ? "verification_passed" : "verification_failed", taskId: this.#task.taskId, workerId: this.#handle?.id, data: { ...result, ...(cleanupError ? { cleanupError: safeMessage(cleanupError) } : {}) } });
537
707
  await this.#decision?.close().catch(() => {});
538
708
  this.#decision = undefined;
539
709
  await Promise.resolve(this.#onDecisionSessionClosed?.(this.#task.taskId)).catch(() => {});
710
+ if (cleanupError) throw cleanupError;
540
711
  return result;
541
712
  }
542
713
 
714
+ async #failVerification(error: unknown): Promise<void> {
715
+ this.#clearWatchdog();
716
+ let cleanupError: unknown;
717
+ if (this.#handle) {
718
+ try {
719
+ await this.#adapter.stop(this.#handle, "verification failed");
720
+ await this.#drainOutputAfterStop(this.#handle);
721
+ } catch (stopError) {
722
+ cleanupError = stopError;
723
+ }
724
+ }
725
+ if (this.#machine.state === "verifying") this.#machine.transition("failed");
726
+ if (this.#task) {
727
+ try {
728
+ await this.#appendEvent({
729
+ type: "verification_failed",
730
+ taskId: this.#task.taskId,
731
+ workerId: this.#handle?.id,
732
+ data: { error: safeMessage(error), ...(cleanupError ? { cleanupError: safeMessage(cleanupError) } : {}) },
733
+ });
734
+ } catch {
735
+ // Preserve the verifier error; the event remains a pending lifecycle record.
736
+ }
737
+ await this.#decision?.close().catch(() => {});
738
+ this.#decision = undefined;
739
+ await Promise.resolve(this.#onDecisionSessionClosed?.(this.#task.taskId)).catch(() => {});
740
+ }
741
+ }
742
+
543
743
  #armWatchdog(): void {
544
744
  if (this.#deadlineMs <= 0 && this.#noOutputTimeoutMs <= 0) return;
545
745
  this.#watchdog = setInterval(() => { void this.#checkWatchdog().catch(() => { /* lifecycle state is retained for the next explicit operation */ }); }, 1_000);
@@ -618,6 +818,19 @@ export class Supervisor {
618
818
  }
619
819
  }
620
820
 
821
+ async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
822
+ let timer: NodeJS.Timeout | undefined;
823
+ const timeout = new Promise<never>((_, reject) => {
824
+ timer = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs);
825
+ timer.unref();
826
+ });
827
+ try {
828
+ return await Promise.race([promise, timeout]);
829
+ } finally {
830
+ if (timer) clearTimeout(timer);
831
+ }
832
+ }
833
+
621
834
  function workerEventKey(event: WorkerEvent): string {
622
835
  if (event.type === "permission_request") return `${event.handle.id}:permission:${event.request.requestId}`;
623
836
  if (event.type === "turn_completed") return `${event.handle.id}:result:${event.sequence}`;
@@ -627,5 +840,5 @@ function workerEventKey(event: WorkerEvent): string {
627
840
  }
628
841
 
629
842
  function safeMessage(error: unknown): string {
630
- return error instanceof Error ? error.message : String(error);
843
+ return String(redactSensitive(error instanceof Error ? error.message : String(error)));
631
844
  }
package/src/types.ts CHANGED
@@ -36,6 +36,25 @@ 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
+ /** Persisted identity required when handing off an existing tmux lease. */
44
+ tmuxExpectedIdentity?: {
45
+ pid?: number;
46
+ startTime?: string;
47
+ tmuxTarget?: string;
48
+ tmuxPaneId?: string;
49
+ paneStartTime?: string;
50
+ paneCommand?: string;
51
+ };
52
+ /** Adopted sessions must not replay the task as a new user message. */
53
+ sendInitialInput?: boolean;
54
+ /** Cancel startup before a worker is fully returned to the supervisor. */
55
+ abortSignal?: AbortSignal;
56
+ /** Internal token that scopes out-of-band startup cancellation. */
57
+ startupToken?: string;
39
58
  }
40
59
 
41
60
  export interface WorkerHandle {
@@ -44,6 +63,15 @@ export interface WorkerHandle {
44
63
  startedAt: string;
45
64
  cwd: string;
46
65
  sessionId?: string;
66
+ sessionName?: string;
67
+ tmuxSocket?: string;
68
+ ownership?: "owned" | "adopted";
69
+ /** Verified process-boundary metadata persisted in the cwd lease registry. */
70
+ cgroupPath?: string;
71
+ tmuxTarget?: string;
72
+ tmuxPaneId?: string;
73
+ paneStartTime?: string;
74
+ paneCommand?: string;
47
75
  }
48
76
 
49
77
  export interface WorkerStatus {
@@ -59,6 +87,8 @@ export interface WorkerStatus {
59
87
  processGroupCleaned?: boolean;
60
88
  /** Whether a Linux cgroup provided descendant cleanup for this worker. */
61
89
  cgroupCleaned?: boolean;
90
+ /** Whether cgroup attachment was required for this worker. */
91
+ cgroupRequired?: boolean;
62
92
  /** cgroup attachment was unavailable and a fallback may have been used. */
63
93
  cgroupError?: string;
64
94
  /** Cleanup failure is diagnostic and must be treated as a safety failure. */
@@ -75,6 +105,8 @@ export interface PermissionDecision {
75
105
  export interface WorkerAdapter {
76
106
  capabilities(): WorkerCapabilities;
77
107
  start(input: WorkerStartInput): Promise<WorkerHandle>;
108
+ /** Cancel adapter-owned startup work before a WorkerHandle is returned. */
109
+ abortStart?(reason: string, startupToken?: string): Promise<void>;
78
110
  getStatus(handle: WorkerHandle): Promise<WorkerStatus>;
79
111
  readOutput(handle: WorkerHandle): Promise<WorkerOutputChunk[]>;
80
112
  /** Restore chunks when diagnostic event persistence fails before acknowledgement. */
@@ -87,6 +119,8 @@ export interface WorkerAdapter {
87
119
  pause(handle: WorkerHandle): Promise<void>;
88
120
  resume(handle: WorkerHandle): Promise<void>;
89
121
  stop(handle: WorkerHandle, reason: string): Promise<void>;
122
+ /** Disconnect the supervisor without stopping a persistent worker, if supported. */
123
+ release?(handle: WorkerHandle, reason: string): Promise<void>;
90
124
  killProcessGroup(handle: WorkerHandle, reason: string): Promise<void>;
91
125
  resumeSession(sessionId: string): Promise<WorkerHandle>;
92
126
  }
@@ -98,11 +132,13 @@ export interface WorkerOutputChunk {
98
132
  }
99
133
 
100
134
  export interface WorkerCapabilities {
101
- transport: "process-pipe" | "pty" | "jsonl";
135
+ transport: "process-pipe" | "pty" | "jsonl" | "tmux";
102
136
  interactiveInput: boolean;
103
137
  pause: boolean;
104
138
  resumeSession: boolean;
105
139
  processGroupControl: boolean;
140
+ /** The worker can remain alive while Pi disconnects from it. */
141
+ persistentSession?: boolean;
106
142
  }
107
143
 
108
144
  export interface TaskContext {