pi-pier 0.2.3 → 0.2.5

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.
@@ -72,13 +72,18 @@ interface SubagentDeps {
72
72
 
73
73
  const SUBAGENT_DESCRIPTION = [
74
74
  'Delegate a self-contained subtask to an isolated subagent that runs in its own herdr pane as an interactive pi session (separate context window; it does NOT see this conversation). A human can also open that pane and talk to the subagent directly.',
75
- '`description`: short display label for the pane; `prompt`: the COMPLETE task — include all needed context, since only the prompt reaches the subagent. The description also doubles as the todo-reconcile key: when delegating a todo entry, use the entry content WITHOUT its ` <sub>` marker as the description, and the entry is auto-completed when this subagent settles.',
76
- 'The subagent shares this workspace and works independently; the result is its final text answer.',
77
- 'Concurrent delegation is supported: several subagent calls in one message run in parallel (at most 4 at once). Use this for well-scoped, independent subtasks; do not delegate the current step itself.',
78
- '`run_in_background` (default false): when true, the call returns immediately with an agentId; the subagent keeps running in its pane. Use list_agents to see its state, send_message to give it follow-up work, interrupt_agent to stop it. When it settles, you receive a notification message with its closing output.',
79
- '`tab` (optional): name of a task tab to place the subagent into (join if a tab with this name exists, otherwise create it). Overrides the default placement. Default placement groups by git worktree: a subagent working in your checkout shares your tab; one working in a separate worktree (pass its path via `cwd`; create worktrees with `git worktree add`) gets its own tab named after the worktree directory.',
80
- '`role` + `allowed_tools`: when role matches a profile (searched: workspace .pi-herdr/roles/ → user-global ~/.pi/agent/herdr-pi/roles/ → builtin), the worker toolset becomes the composed manifest — baseline ∪ allowed_tools minus role-deny tools (deny always wins). Custom roles: drop a JSON profile into .pi-herdr/roles/ (master/worker-default reserved). Unknown role names remain display labels only.',
81
- '`isolate` (default false): creates a FRESH git worktree for this subagent and runs it there (branch pier/<slug> from your HEAD under ~/.herdr/worktrees/<repo>/). Three-way choice: heavy independent writing in parallel with your own edits or other workers, or work needing its own clean reviewable diff → isolate; read-mostly or sequential helper work → omit (shared checkout, writes guarded by the write-lock); targeting an existing directory/worktree → cwd. In isolate mode the subagent\'s writes cannot conflict with your checkout; its panes group into a tab named after the worktree; its prompt is prefixed with commit discipline (commit to its own branch, NEVER push); when it settles you get a diff summary (commits since base, files changed, uncommitted count). Review with git log/diff HEAD..<branch>, merge with git merge --no-ff <branch>; once merged and clean, the worktree auto-removes (the branch is kept for audit). Mutually exclusive with cwd.',
75
+ '`action` (optional, default "spawn"): spawn | resume | list | send | interrupt.',
76
+ '[spawn] `description`: short display label for the pane; `prompt`: the COMPLETE task — include all needed context, since only the prompt reaches the subagent. The description also doubles as the todo-reconcile key: when delegating a todo entry, use the entry content WITHOUT its ` <sub>` marker as the description, and the entry is auto-completed when this subagent settles.',
77
+ '[spawn] The subagent shares this workspace and works independently; the result is its final text answer.',
78
+ '[spawn] Concurrent delegation is supported: several spawn calls in one message run in parallel (at most 4 at once). Use this for well-scoped, independent subtasks; do not delegate the current step itself.',
79
+ '[spawn] `run_in_background` (default false): when true, the call returns immediately with an agentId; the subagent keeps running in its pane. Use action list to see its state, action send to give it follow-up work, action interrupt to stop it. When it settles, you receive a notification message with its closing output.',
80
+ '[spawn] `tab` (optional): name of a task tab to place the subagent into (join if a tab with this name exists, otherwise create it). Overrides the default placement. Default placement groups by git worktree: a subagent working in your checkout shares your tab; one working in a separate worktree (pass its path via `cwd`; create worktrees with `git worktree add`) gets its own tab named after the worktree directory.',
81
+ '[spawn] `role` + `allowed_tools`: when role matches a profile (searched: workspace .pi-herdr/roles/ → user-global ~/.pi/agent/herdr-pi/roles/ → builtin), the worker toolset becomes the composed manifest — baseline ∪ allowed_tools minus role-deny tools (deny always wins). Custom roles: drop a JSON profile into .pi-herdr/roles/ (master/worker-default reserved). Unknown role names remain display labels only.',
82
+ '[spawn] `isolate` (default false): creates a FRESH git worktree for this subagent and runs it there (branch pier/<slug> from your HEAD under ~/.herdr/worktrees/<repo>/). Three-way choice: heavy independent writing in parallel with your own edits or other workers, or work needing its own clean reviewable diff → isolate; read-mostly or sequential helper work → omit (shared checkout, writes guarded by the write-lock); targeting an existing directory/worktree → cwd. In isolate mode the subagent\'s writes cannot conflict with your checkout; its panes group into a tab named after the worktree; its prompt is prefixed with commit discipline (commit to its own branch, NEVER push); when it settles you get a diff summary (commits since base, files changed, uncommitted count). Review with git log/diff HEAD..<branch>, merge with git merge --no-ff <branch>; once merged and clean the worktree auto-removes (branch kept). Mutually exclusive with cwd.',
83
+ '[resume] `taskId`: revive a finished (collected) subagent from the delegation ledger — opens its saved conversation in a new pane (pi --session), then use action send to give it new work. The ledger is an append-only JSONL file, one row per status change (same taskId rows = generations, latest row is current): fields taskId, description, status (running|settled|consumed|closed), outcome (closing text), paneId, sessionFile, launchCommand, createdAt. It is per-checkout at ~/.pi/agent/herdr-pi/history/<flattened-cwd>/history.jsonl. Use action list for live panes from this session; for earlier sessions or closed panes, grep the ledger for the taskId.',
84
+ '[list] no extra parameters: list background subagents with live state (running / idle), pane ids, last activity, role, and descriptions. Foreground one-shot panes are not listed.',
85
+ '[send] `agentId` + `message`: follow-up to a background subagent. If working, delivered at next tool-call gap (steer, seconds); if idle, wakes a new turn. After settle, send to wake it; do not spawn a duplicate.',
86
+ '[interrupt] `agentId`: abort the current turn (fire-and-return). The pane stays; you can send again.',
82
87
  ].join(' ');
83
88
 
84
89
  /** Subagent concurrency limit (max parallel delegations) */
@@ -413,20 +418,248 @@ export default function subagentPlugin(ctx: Context): void {
413
418
  return entry;
414
419
  }
415
420
 
421
+
422
+ async function executeSubagentResume(params, toolCtx) {
423
+ if (!client.available) {
424
+ return {
425
+ content: [{ type: 'text', text: 'Error: requires a herdr-managed pane.' }],
426
+ details: {},
427
+ };
428
+ }
429
+ const cwd = (toolCtx as { cwd?: string }).cwd ?? process.cwd();
430
+ const taskId = String(params?.taskId ?? '');
431
+ const latest = latestGeneration(readHistory(histFile(cwd)), taskId);
432
+ if (!latest) {
433
+ return {
434
+ content: [{ type: 'text', text: `Error: no history for task "${taskId}" in this workspace.` }],
435
+ details: {},
436
+ };
437
+ }
438
+ const release = await subSemaphore.acquire();
439
+ try {
440
+ // D94: Reuse an existing pane for the same session to avoid competing pi processes.
441
+ const existing = await findExistingPane(latest.sessionFile);
442
+ if (existing) {
443
+ const entry: SubEntry = {
444
+ taskId,
445
+ kind: latest.kind,
446
+ paneId: existing.paneId,
447
+ tabId: existing.tabId,
448
+ tabName: latest.tabName ?? tabNameForTask(latest.description),
449
+ cwd,
450
+ description: latest.description,
451
+ background: true,
452
+ status: 'running',
453
+ sessionFile: latest.sessionFile,
454
+ launchCommand: latest.launchCommand,
455
+ createdAt: Date.now(),
456
+ revivedFrom: latest.paneId,
457
+ consumedAt: null,
458
+ };
459
+ subs.set(entry.paneId, entry);
460
+ persistSubs();
461
+ writeHistory(entry, undefined, 'resume');
462
+ return {
463
+ content: [{
464
+ type: 'text',
465
+ text: `resumed subagent ${entry.paneId} from task ${taskId} (reused existing pane with same session; pi still running there).`,
466
+ }],
467
+ details: { paneId: entry.paneId, taskId },
468
+ };
469
+ }
470
+ // Create a new pane only when no existing one can be reused.
471
+ const entry: SubEntry = {
472
+ taskId,
473
+ kind: latest.kind,
474
+ paneId: '',
475
+ tabId: '',
476
+ tabName: latest.tabName ?? tabNameForTask(latest.description),
477
+ cwd,
478
+ description: latest.description,
479
+ background: true,
480
+ status: 'running',
481
+ sessionFile: latest.sessionFile,
482
+ launchCommand: latest.launchCommand,
483
+ createdAt: Date.now(),
484
+ revivedFrom: latest.paneId,
485
+ consumedAt: null,
486
+ };
487
+ await reviveEntry(entry);
488
+ subs.set(entry.paneId, entry);
489
+ persistSubs();
490
+ return {
491
+ content: [{
492
+ type: 'text',
493
+ text: entry.sessionFile
494
+ ? `resumed subagent ${entry.paneId} from task ${taskId} (session restored).`
495
+ : `resumed subagent ${entry.paneId} from task ${taskId} (session file missing; fresh conversation).`,
496
+ }],
497
+ details: { paneId: entry.paneId, taskId },
498
+ };
499
+ } catch (err) {
500
+ return {
501
+ content: [{ type: 'text', text: `Error: failed to resume task "${taskId}": ${(err as Error).message}` }],
502
+ details: {},
503
+ };
504
+ } finally {
505
+ release();
506
+ }
507
+ }
508
+
509
+ async function executeSubagentList() {
510
+ const listed = [...subs.values()].filter((s) => s.background);
511
+ if (listed.length === 0) {
512
+ return { content: [{ type: 'text', text: 'No background subagents started (from this session branch).' }], details: {} };
513
+ }
514
+ // Deduplicate by taskId so only the newest generation appears after revival; old paneIds do not repeat.
515
+ const byTask = new Map<string, SubEntry>();
516
+ for (const sub of listed) {
517
+ const prev = byTask.get(sub.taskId);
518
+ if (!prev || sub.createdAt >= prev.createdAt) byTask.set(sub.taskId, sub);
519
+ }
520
+ // C1: Probe liveness in real time (pane status and session activity) so the master can distinguish work from a true hang.
521
+ const probes = new Map<string, AliveProbe>();
522
+ await Promise.all([...byTask.values()].map(async (sub) => {
523
+ if (sub.status === 'closed') return;
524
+ try { probes.set(sub.paneId, await probeAlive(sub.paneId, sub.cwd)); } catch { /* Best effort for display. */ }
525
+ }));
526
+ const lines: string[] = [];
527
+ for (const sub of byTask.values()) {
528
+ const tabTag = sub.tabName ? ` [tab: ${sub.tabName}]` : '';
529
+ // D98: Include the worktree branch on isolate entries, including closed ones; settlement stats already report merge state.
530
+ const wtTag = sub.isolate ? ` [wt: ${sub.isolate.branch}]` : '';
531
+ if (sub.status === 'closed') {
532
+ lines.push(`${sub.taskId.slice(0, 8)} [idle] (${sub.kind}, closed; action send revives)${tabTag}${wtTag} ${sub.description}`);
533
+ continue;
534
+ }
535
+ // The registry is authoritative: settled is idle, everything else is running, including blocked human gates (DSH mapping).
536
+ const state = sub.status === 'settled' ? 'idle' : 'running';
537
+ // D94: Mark when the user has taken over.
538
+ const takeoverMark = sub.userTakeover ? ', user-controlled' : '';
539
+ const probe = probes.get(sub.paneId);
540
+ const statusTag = probe?.agentStatus ? ` ${probe.agentStatus}` : '';
541
+ const activityTag = probe?.lastActivityMs != null ? `, active ${agoText(probe.lastActivityMs, Date.now())}` : '';
542
+ // E: For blocked agents, include the gate-question summary so the master sends the user to that pane instead of taking over.
543
+ let gateTag = '';
544
+ if (probe?.agentStatus === 'blocked') {
545
+ const q = await readAskFlag(sub.paneId);
546
+ gateTag = q ? ` — AWAITING HUMAN: "${q}"` : ' — AWAITING HUMAN decision';
547
+ }
548
+ lines.push(`${sub.paneId} [${state}${takeoverMark}${statusTag}${activityTag}${gateTag}] (${sub.kind})${tabTag}${wtTag} ${sub.description}`);
549
+ }
550
+ return { content: [{ type: 'text', text: lines.join('\n') }], details: {} };
551
+ }
552
+
553
+ async function executeSubagentSend(params) {
554
+ const entry = subs.get(String(params?.agentId ?? ''));
555
+ if (!entry) {
556
+ return { content: [{ type: 'text', text: `Error: unknown subagent id "${params?.agentId}" (see action list)` }], details: {} };
557
+ }
558
+ const spawnedAt = Date.now();
559
+ try {
560
+ // A closed task is revived automatically because the pane is only a temporary host.
561
+ if (entry.status === 'closed') {
562
+ await reviveEntry(entry);
563
+ subs.set(entry.paneId, entry);
564
+ persistSubs();
565
+ }
566
+ // M11 (D46): follow_up uses the extension pipe. B3 adds steering so supplemental instructions reach a long-running worker
567
+ // within seconds during a tool-call gap; the old followUp queue waited for the entire run and caused 20-minute rework (01a03c0d).
568
+ const ready = await waitSubReady(entry.cwd, entry.paneId);
569
+ if (!ready) throw new Error(`subagent pane ${entry.paneId} pipe not ready`);
570
+ const fuId = `fu-${Date.now()}`;
571
+ const res = await pipeRequestTo(entry.cwd, entry.paneId, {
572
+ type: 'follow_up',
573
+ id: fuId,
574
+ text: String(params?.message ?? ''),
575
+ from: pipeNameFor(entry.cwd, env?.paneId ?? ''),
576
+ push: true,
577
+ steer: true,
578
+ });
579
+ if (res.type !== 'ok') {
580
+ throw new Error(`pipe follow_up rejected: ${res.type === 'error' ? res.message : 'unknown response'}`);
581
+ }
582
+ lastMachineInjectAt.set(entry.paneId, Date.now()); // B4: attribute working state during the observation window.
583
+ // Regression hardening (D98 liveness evidence): an exception between status='running' and startPoller could leave a ghost
584
+ // running entry without a poller, never settling and skipped by GC; roll back to the previous status on failure.
585
+ const prevStatus = entry.status;
586
+ entry.status = 'running';
587
+ persistSubs();
588
+ try {
589
+ lastRequestIdByPane.set(entry.paneId, fuId);
590
+ startPoller(entry.paneId, entry.cwd, spawnedAt, Date.now(), entry.description, fuId);
591
+ } catch (inner) {
592
+ entry.status = prevStatus;
593
+ persistSubs();
594
+ throw inner;
595
+ }
596
+ return { content: [{ type: 'text', text: `Message sent to subagent ${entry.paneId}.` }], details: { paneId: entry.paneId } };
597
+ } catch (err) {
598
+ return { content: [{ type: 'text', text: `Error: failed to reach subagent ${entry.paneId}: ${(err as Error).message}` }], details: {} };
599
+ }
600
+ }
601
+
602
+ async function executeSubagentInterrupt(params) {
603
+ const entry = subs.get(String(params?.agentId ?? ''));
604
+ if (!entry) {
605
+ return { content: [{ type: 'text', text: `Error: unknown subagent id "${params?.agentId}" (see action list)` }], details: {} };
606
+ }
607
+ if (entry.status === 'closed') {
608
+ // DSH alignment: an idle or finished target is an idempotent no-op.
609
+ return { content: [{ type: 'text', text: `Interrupt accepted for subagent ${entry.paneId} (already idle/closed; no-op).` }], details: {} };
610
+ }
611
+ try {
612
+ const res = await pipeRequestTo(entry.cwd, entry.paneId, {
613
+ type: 'interrupt',
614
+ id: `int-${Date.now()}`,
615
+ });
616
+ if (res.type !== 'ok') {
617
+ throw new Error(`pipe interrupt rejected: ${res.type === 'error' ? res.message : 'unknown response'}`);
618
+ }
619
+ // Do not send a settlement notice for an interrupted turn because the requester already knows.
620
+ const lastId = lastRequestIdByPane.get(entry.paneId);
621
+ if (lastId) d.claimSettleNotice(`${entry.paneId}:${lastId}`);
622
+ return { content: [{ type: 'text', text: `Interrupt accepted for subagent ${entry.paneId} (fire-and-return).` }], details: { paneId: entry.paneId } };
623
+ } catch (err) {
624
+ return { content: [{ type: 'text', text: `Error: failed to reach subagent ${entry.paneId}: ${(err as Error).message}` }], details: {} };
625
+ }
626
+ }
627
+
416
628
  scoped.registerTool({
417
629
  name: 'subagent',
418
630
  label: 'Subagent',
419
631
  description: SUBAGENT_DESCRIPTION,
420
632
  parameters: Type.Object({
421
- description: Type.String({ description: 'Short label for this subtask (pane title)' }),
422
- prompt: Type.String({ description: 'The complete self-contained task for the subagent' }),
423
- run_in_background: Type.Optional(Type.Boolean({ description: 'Return immediately with an agentId; the subagent keeps running in its own pane (default false)' })),
424
- cwd: Type.Optional(Type.String({ description: 'Working directory for the subagent (absolute, or relative to this workspace). Use it to delegate into a git worktree: panes group by worktree — same checkout as you share your tab; a separate worktree gets its own tab named after the worktree directory. Create worktrees yourself with git worktree add. Use isolate:true instead when you want a FRESH worktree created for this task rather than targeting an existing one' })),
425
- isolate: Type.Optional(Type.Boolean({ description: 'Create a fresh git worktree and run the subagent there. Use when the task writes files heavily and independently — in parallel with your own edits or other workers\' — or needs its own clean, reviewable diff. For read-mostly or sequential helper work omit it (shared checkout, writes guarded by the write-lock); use `cwd` only to target an existing directory/worktree (e.g. a retained pier worktree). Mechanics: branch pier/<slug> from your HEAD under ~/.herdr/worktrees/<repo>/; its writes cannot conflict with your checkout; panes group into a tab named after the worktree; the prompt is prefixed with commit discipline (commit to its branch, never push); on settle you get a diff summary. Review with git log/diff HEAD..<branch>, merge with git merge --no-ff <branch>; once merged and clean the worktree auto-removes (branch kept). Mutually exclusive with cwd' })),
633
+ action: Type.Optional(Type.Union([
634
+ Type.Literal('spawn'),
635
+ Type.Literal('resume'),
636
+ Type.Literal('list'),
637
+ Type.Literal('send'),
638
+ Type.Literal('interrupt'),
639
+ ], { description: 'Operation to perform (default: spawn)' })),
640
+ description: Type.Optional(Type.String({ description: '[spawn] Short label for this subtask (pane title)' })),
641
+ prompt: Type.Optional(Type.String({ description: '[spawn] The complete self-contained task for the subagent' })),
642
+ run_in_background: Type.Optional(Type.Boolean({ description: '[spawn] Return immediately with an agentId; the subagent keeps running in its own pane (default false)' })),
643
+ cwd: Type.Optional(Type.String({ description: '[spawn] Working directory for the subagent (absolute, or relative to this workspace). Use it to delegate into a git worktree: panes group by worktree — same checkout as you share your tab; a separate worktree gets its own tab named after the worktree directory. Create worktrees yourself with git worktree add. Use isolate:true instead when you want a FRESH worktree created for this task rather than targeting an existing one' })),
644
+ isolate: Type.Optional(Type.Boolean({ description: '[spawn] Create a fresh git worktree and run the subagent there. Use when the task writes files heavily and independently — in parallel with your own edits or other workers\' — or needs its own clean, reviewable diff. For read-mostly or sequential helper work omit it (shared checkout, writes guarded by the write-lock); use `cwd` only to target an existing directory/worktree (e.g. a retained pier worktree). Mechanics: branch pier/<slug> from your HEAD under ~/.herdr/worktrees/<repo>/; its writes cannot conflict with your checkout; panes group into a tab named after the worktree; the prompt is prefixed with commit discipline (commit to its branch, never push); on settle you get a diff summary. Review with git log/diff HEAD..<branch>, merge with git merge --no-ff <branch>; once merged and clean the worktree auto-removes (branch kept). Mutually exclusive with cwd' })),
645
+ role: Type.Optional(Type.String({ description: '[spawn] Role label or profile name. When role matches a profile (searched: workspace .pi-herdr/roles/ → user-global ~/.pi/agent/herdr-pi/roles/ → builtin), the worker toolset becomes the composed manifest. Unknown role names remain display labels only.' })),
646
+ tab: Type.Optional(Type.String({ description: '[spawn] Name of a task tab to place the subagent into (join if exists, otherwise create). Default placement groups by git worktree.' })),
647
+ allowed_tools: Type.Optional(Type.Array(Type.String(), { description: '[spawn] Additional tools for role composition (union with role baseline)' })),
648
+ taskId: Type.Optional(Type.String({ description: '[resume] The task id to revive from the delegation ledger' })),
649
+ agentId: Type.Optional(Type.String({ description: '[send|interrupt] The subagent id (herdr pane id)' })),
650
+ message: Type.Optional(Type.String({ description: '[send] The follow-up message' })),
426
651
  }),
427
652
  async execute(toolCallId, params, signal, onUpdate, toolCtx) {
428
653
  void toolCallId;
429
654
  void signal;
655
+ const action = typeof params?.action === 'string' && params.action.trim() ? params.action.trim() : 'spawn';
656
+ if (action === 'resume') return executeSubagentResume(params, toolCtx);
657
+ if (action === 'list') return executeSubagentList();
658
+ if (action === 'send') return executeSubagentSend(params);
659
+ if (action === 'interrupt') return executeSubagentInterrupt(params);
660
+ if (action !== 'spawn') {
661
+ return { content: [{ type: 'text', text: `Error: unknown action "${action}" (valid: spawn, resume, list, send, interrupt)` }], details: {} };
662
+ }
430
663
  const launch = planLaunchValidation(params, client.available);
431
664
  if (launch.kind === 'error') {
432
665
  return { content: [{ type: 'text', text: launch.text }], details: {} };
@@ -673,253 +906,4 @@ export default function subagentPlugin(ctx: Context): void {
673
906
  }
674
907
  },
675
908
  });
676
-
677
- /* ── Tool: resume_subagent (historical revival, v1.2) ── */
678
-
679
- scoped.registerTool({
680
- name: 'resume_subagent',
681
- label: 'Resume Subagent',
682
- description:
683
- 'Revive a finished (collected) subagent from the delegation ledger: opens its saved conversation in a new pane (pi --session), then use send_message to give it new work. The ledger is an append-only JSONL file, one row per status change (same taskId rows = generations, latest row is current): fields taskId, description, status (running|settled|consumed|closed), outcome (closing text), paneId, sessionFile, launchCommand, createdAt. It is per-checkout at ~/.pi/agent/herdr-pi/history/<flattened-cwd>/history.jsonl (e.g. checkout F:\\repo -> --F--repo--), so each git worktree has its own volume. Use list_agents for live panes from this session; for tasks from earlier sessions or closed panes, read/grep the ledger file for the taskId, then pass it here.',
684
- parameters: Type.Object({
685
- taskId: Type.String({ description: 'The task id to revive' }),
686
- }),
687
- async execute(_tc, params, _sig, _upd, toolCtx) {
688
- if (!client.available) {
689
- return {
690
- content: [{ type: 'text', text: 'Error: requires a herdr-managed pane.' }],
691
- details: {},
692
- };
693
- }
694
- const cwd = (toolCtx as { cwd?: string }).cwd ?? process.cwd();
695
- const taskId = String(params?.taskId ?? '');
696
- const latest = latestGeneration(readHistory(histFile(cwd)), taskId);
697
- if (!latest) {
698
- return {
699
- content: [{ type: 'text', text: `Error: no history for task "${taskId}" in this workspace.` }],
700
- details: {},
701
- };
702
- }
703
- const release = await subSemaphore.acquire();
704
- try {
705
- // D94: Reuse an existing pane for the same session to avoid competing pi processes.
706
- const existing = await findExistingPane(latest.sessionFile);
707
- if (existing) {
708
- const entry: SubEntry = {
709
- taskId,
710
- kind: latest.kind,
711
- paneId: existing.paneId,
712
- tabId: existing.tabId,
713
- tabName: latest.tabName ?? tabNameForTask(latest.description),
714
- cwd,
715
- description: latest.description,
716
- background: true,
717
- status: 'running',
718
- sessionFile: latest.sessionFile,
719
- launchCommand: latest.launchCommand,
720
- createdAt: Date.now(),
721
- revivedFrom: latest.paneId,
722
- consumedAt: null,
723
- };
724
- subs.set(entry.paneId, entry);
725
- persistSubs();
726
- writeHistory(entry, undefined, 'resume');
727
- return {
728
- content: [{
729
- type: 'text',
730
- text: `resumed subagent ${entry.paneId} from task ${taskId} (reused existing pane with same session; pi still running there).`,
731
- }],
732
- details: { paneId: entry.paneId, taskId },
733
- };
734
- }
735
- // Create a new pane only when no existing one can be reused.
736
- const entry: SubEntry = {
737
- taskId,
738
- kind: latest.kind,
739
- paneId: '',
740
- tabId: '',
741
- tabName: latest.tabName ?? tabNameForTask(latest.description),
742
- cwd,
743
- description: latest.description,
744
- background: true,
745
- status: 'running',
746
- sessionFile: latest.sessionFile,
747
- launchCommand: latest.launchCommand,
748
- createdAt: Date.now(),
749
- revivedFrom: latest.paneId,
750
- consumedAt: null,
751
- };
752
- await reviveEntry(entry);
753
- subs.set(entry.paneId, entry);
754
- persistSubs();
755
- return {
756
- content: [{
757
- type: 'text',
758
- text: entry.sessionFile
759
- ? `resumed subagent ${entry.paneId} from task ${taskId} (session restored).`
760
- : `resumed subagent ${entry.paneId} from task ${taskId} (session file missing; fresh conversation).`,
761
- }],
762
- details: { paneId: entry.paneId, taskId },
763
- };
764
- } catch (err) {
765
- return {
766
- content: [{ type: 'text', text: `Error: failed to resume task "${taskId}": ${(err as Error).message}` }],
767
- details: {},
768
- };
769
- } finally {
770
- release();
771
- }
772
- },
773
- });
774
-
775
- /* ── Tool: list_agents (background/long-lived subagents only; foreground short panes are transient UI) ── */
776
-
777
- scoped.registerTool({
778
- name: 'list_agents',
779
- label: 'List Subagents',
780
- description:
781
- 'List the background subagents you started, with their live state: running (working or blocked), idle (settled), plus pane ids, live agent status, last session activity time, role (task or a role label), and descriptions. Foreground one-shot panes are transient and not listed. Covers only this session branch — for tasks from earlier sessions or collected panes, read the delegation ledger (path and format in resume_subagent) and resume by taskId.',
782
- parameters: Type.Object({}),
783
- async execute() {
784
- const listed = [...subs.values()].filter((s) => s.background);
785
- if (listed.length === 0) {
786
- return { content: [{ type: 'text', text: 'No background subagents started (from this session branch).' }], details: {} };
787
- }
788
- // Deduplicate by taskId so only the newest generation appears after revival; old paneIds do not repeat.
789
- const byTask = new Map<string, SubEntry>();
790
- for (const sub of listed) {
791
- const prev = byTask.get(sub.taskId);
792
- if (!prev || sub.createdAt >= prev.createdAt) byTask.set(sub.taskId, sub);
793
- }
794
- // C1: Probe liveness in real time (pane status and session activity) so the master can distinguish work from a true hang.
795
- const probes = new Map<string, AliveProbe>();
796
- await Promise.all([...byTask.values()].map(async (sub) => {
797
- if (sub.status === 'closed') return;
798
- try { probes.set(sub.paneId, await probeAlive(sub.paneId, sub.cwd)); } catch { /* Best effort for display. */ }
799
- }));
800
- const lines: string[] = [];
801
- for (const sub of byTask.values()) {
802
- const tabTag = sub.tabName ? ` [tab: ${sub.tabName}]` : '';
803
- // D98: Include the worktree branch on isolate entries, including closed ones; settlement stats already report merge state.
804
- const wtTag = sub.isolate ? ` [wt: ${sub.isolate.branch}]` : '';
805
- if (sub.status === 'closed') {
806
- lines.push(`${sub.taskId.slice(0, 8)} [idle] (${sub.kind}, closed; send_message revives)${tabTag}${wtTag} ${sub.description}`);
807
- continue;
808
- }
809
- // The registry is authoritative: settled is idle, everything else is running, including blocked human gates (DSH mapping).
810
- const state = sub.status === 'settled' ? 'idle' : 'running';
811
- // D94: Mark when the user has taken over.
812
- const takeoverMark = sub.userTakeover ? ', user-controlled' : '';
813
- const probe = probes.get(sub.paneId);
814
- const statusTag = probe?.agentStatus ? ` ${probe.agentStatus}` : '';
815
- const activityTag = probe?.lastActivityMs != null ? `, active ${agoText(probe.lastActivityMs, Date.now())}` : '';
816
- // E: For blocked agents, include the gate-question summary so the master sends the user to that pane instead of taking over.
817
- let gateTag = '';
818
- if (probe?.agentStatus === 'blocked') {
819
- const q = await readAskFlag(sub.paneId);
820
- gateTag = q ? ` — AWAITING HUMAN: "${q}"` : ' — AWAITING HUMAN decision';
821
- }
822
- lines.push(`${sub.paneId} [${state}${takeoverMark}${statusTag}${activityTag}${gateTag}] (${sub.kind})${tabTag}${wtTag} ${sub.description}`);
823
- }
824
- return { content: [{ type: 'text', text: lines.join('\n') }], details: {} };
825
- },
826
- });
827
-
828
- /* ── Tool: send_message (delivery during steer gaps) ── */
829
-
830
- scoped.registerTool({
831
- name: 'send_message',
832
- label: 'Send to Subagent',
833
- description:
834
- 'Send a follow-up message to a background subagent. If it is working, the message is delivered at its next tool-call gap (steer, seconds); if it is idle, it wakes the subagent for a new turn. `agentId` is the id returned by the subagent tool or shown by list_agents.',
835
- parameters: Type.Object({
836
- agentId: Type.String({ description: 'The subagent id (herdr pane id)' }),
837
- message: Type.String({ description: 'The follow-up message' }),
838
- }),
839
- async execute(_tc, params) {
840
- const entry = subs.get(String(params?.agentId ?? ''));
841
- if (!entry) {
842
- return { content: [{ type: 'text', text: `Error: unknown subagent id "${params?.agentId}" (see list_agents)` }], details: {} };
843
- }
844
- const spawnedAt = Date.now();
845
- try {
846
- // A closed task is revived automatically because the pane is only a temporary host.
847
- if (entry.status === 'closed') {
848
- await reviveEntry(entry);
849
- subs.set(entry.paneId, entry);
850
- persistSubs();
851
- }
852
- // M11 (D46): follow_up uses the extension pipe. B3 adds steering so supplemental instructions reach a long-running worker
853
- // within seconds during a tool-call gap; the old followUp queue waited for the entire run and caused 20-minute rework (01a03c0d).
854
- const ready = await waitSubReady(entry.cwd, entry.paneId);
855
- if (!ready) throw new Error(`subagent pane ${entry.paneId} pipe not ready`);
856
- const fuId = `fu-${Date.now()}`;
857
- const res = await pipeRequestTo(entry.cwd, entry.paneId, {
858
- type: 'follow_up',
859
- id: fuId,
860
- text: String(params?.message ?? ''),
861
- from: pipeNameFor(entry.cwd, env?.paneId ?? ''),
862
- push: true,
863
- steer: true,
864
- });
865
- if (res.type !== 'ok') {
866
- throw new Error(`pipe follow_up rejected: ${res.type === 'error' ? res.message : 'unknown response'}`);
867
- }
868
- lastMachineInjectAt.set(entry.paneId, Date.now()); // B4: attribute working state during the observation window.
869
- // Regression hardening (D98 liveness evidence): an exception between status='running' and startPoller could leave a ghost
870
- // running entry without a poller, never settling and skipped by GC; roll back to the previous status on failure.
871
- const prevStatus = entry.status;
872
- entry.status = 'running';
873
- persistSubs();
874
- try {
875
- lastRequestIdByPane.set(entry.paneId, fuId);
876
- startPoller(entry.paneId, entry.cwd, spawnedAt, Date.now(), entry.description, fuId);
877
- } catch (inner) {
878
- entry.status = prevStatus;
879
- persistSubs();
880
- throw inner;
881
- }
882
- return { content: [{ type: 'text', text: `Message sent to subagent ${entry.paneId}.` }], details: { paneId: entry.paneId } };
883
- } catch (err) {
884
- return { content: [{ type: 'text', text: `Error: failed to reach subagent ${entry.paneId}: ${(err as Error).message}` }], details: {} };
885
- }
886
- },
887
- });
888
-
889
- /* ── Tool: interrupt_agent (D48: pipe → child extension ctx.abort(), in-process cancellation) ── */
890
-
891
- scoped.registerTool({
892
- name: 'interrupt_agent',
893
- label: 'Interrupt Subagent',
894
- description:
895
- 'Interrupt a background subagent (stops its current turn; fire-and-return). The subagent stays alive and can receive further send_message work.',
896
- parameters: Type.Object({
897
- agentId: Type.String({ description: 'The subagent id (herdr pane id)' }),
898
- }),
899
- async execute(_tc, params) {
900
- const entry = subs.get(String(params?.agentId ?? ''));
901
- if (!entry) {
902
- return { content: [{ type: 'text', text: `Error: unknown subagent id "${params?.agentId}" (see list_agents)` }], details: {} };
903
- }
904
- if (entry.status === 'closed') {
905
- // DSH alignment: an idle or finished target is an idempotent no-op.
906
- return { content: [{ type: 'text', text: `Interrupt accepted for subagent ${entry.paneId} (already idle/closed; no-op).` }], details: {} };
907
- }
908
- try {
909
- const res = await pipeRequestTo(entry.cwd, entry.paneId, {
910
- type: 'interrupt',
911
- id: `int-${Date.now()}`,
912
- });
913
- if (res.type !== 'ok') {
914
- throw new Error(`pipe interrupt rejected: ${res.type === 'error' ? res.message : 'unknown response'}`);
915
- }
916
- // Do not send a settlement notice for an interrupted turn because the requester already knows.
917
- const lastId = lastRequestIdByPane.get(entry.paneId);
918
- if (lastId) d.claimSettleNotice(`${entry.paneId}:${lastId}`);
919
- return { content: [{ type: 'text', text: `Interrupt accepted for subagent ${entry.paneId} (fire-and-return).` }], details: { paneId: entry.paneId } };
920
- } catch (err) {
921
- return { content: [{ type: 'text', text: `Error: failed to reach subagent ${entry.paneId}: ${(err as Error).message}` }], details: {} };
922
- }
923
- },
924
- });
925
909
  }