pi-pier 0.3.1 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,6 +15,11 @@ export interface SettleWakeInput {
15
15
  lastStopReason: string | null;
16
16
  /** True when abort was triggered intentionally by internal mechanisms like OCC. */
17
17
  intentionalAbort?: boolean;
18
+ /**
19
+ * pi ≥ 1.1 carries `aborted` on agent_settled itself: authoritative for runs cancelled without a
20
+ * final assistant message (ESC mid-tool), where stopReason tracking sees a stale natural reason.
21
+ */
22
+ aborted?: boolean;
18
23
  running: ReadonlyArray<{ paneId: string }>;
19
24
  lastNoticeKey: string | null;
20
25
  lastNoticeAt: number;
@@ -50,7 +55,7 @@ export function planSettleWake(input: SettleWakeInput): SettleWakePlan {
50
55
  noticeAt: input.lastNoticeAt,
51
56
  };
52
57
  }
53
- if (input.intentionalAbort || input.lastStopReason === ABORT_STOP_REASON) {
58
+ if (input.aborted === true || input.intentionalAbort || input.lastStopReason === ABORT_STOP_REASON) {
54
59
  return {
55
60
  wake: false,
56
61
  compact: input.intentionalAbort === true,
@@ -79,10 +84,13 @@ export function planSettleWake(input: SettleWakeInput): SettleWakePlan {
79
84
  */
80
85
  export function shouldPushSettleReply(input: {
81
86
  intentionalAbort?: boolean;
87
+ /** pi ≥ 1.1 agent_settled.aborted: the run was cancelled — the closing message may still come after a re-run. */
88
+ aborted?: boolean;
82
89
  compactionInFlight?: boolean;
83
90
  lastStopReason: string | null;
84
91
  }): boolean {
85
92
  return !input.intentionalAbort
93
+ && input.aborted !== true
86
94
  && !input.compactionInFlight
87
95
  && input.lastStopReason !== ABORT_STOP_REASON;
88
96
  }
@@ -3,6 +3,7 @@
3
3
  * decisions, task-id resolution, and the outbound port types. Pure: planners decide, adapters do I/O.
4
4
  */
5
5
  import { normalizeEntryKind } from './history-store.ts';
6
+ import { Type } from 'typebox';
6
7
 
7
8
  export function sleep(ms: number): Promise<void> {
8
9
  const { promise, resolve } = Promise.withResolvers<void>();
@@ -24,6 +25,113 @@ export function buildLaunchLine(parts: readonly string[], platform: NodeJS.Platf
24
25
  return (win ? '& ' : '') + quoted.join(' ');
25
26
  }
26
27
 
28
+ /** Shells that mean a freshly split pane is at its prompt and can accept a launch line. */
29
+ const SHELL_PROCESS_NAMES: ReadonlySet<string> = new Set([
30
+ 'sh', 'bash', 'zsh', 'fish', 'dash', 'ksh', 'mksh', 'tcsh', 'csh', 'nu', 'login',
31
+ 'pwsh', 'powershell', 'cmd',
32
+ ]);
33
+
34
+ /** Normalize `name` / `argv0` (`/bin/zsh`, `C:\\Windows\\System32\\cmd.exe`, `-zsh`) to a shell key. */
35
+ export function shellProcessName(value: string | null | undefined): string {
36
+ const base = String(value ?? '').replace(/\\/g, '/').split('/').pop()?.toLowerCase() ?? '';
37
+ const stripped = base.replace(/^-/, '').replace(/\.exe$/, '');
38
+ return stripped;
39
+ }
40
+
41
+ /**
42
+ * Decide whether a pane's foreground set means its shell is ready for input. `null`/`undefined`
43
+ * means the probe was unavailable: return ready so an older herdr never adds spawn latency.
44
+ * An empty set is not proof (the shell may still be starting), so callers wait for the timeout.
45
+ */
46
+ export function planShellReady(
47
+ foreground: ReadonlyArray<{ name?: string | null; argv0?: string | null }> | null | undefined,
48
+ ): 'ready' | 'wait' {
49
+ if (foreground == null) return 'ready';
50
+ if (foreground.length === 0) return 'wait';
51
+ return foreground.some((p) => SHELL_PROCESS_NAMES.has(shellProcessName(p.name)) || SHELL_PROCESS_NAMES.has(shellProcessName(p.argv0)))
52
+ ? 'ready'
53
+ : 'wait';
54
+ }
55
+
56
+ /** Flags pier passes at spawn (buildLaunchParts) that a herdr 0.9.2+ resume command must replay. */
57
+ const RESUME_FLAG_ARITY: Record<string, number> = {
58
+ '-a': 0,
59
+ '-e': 1,
60
+ '--extension': 1,
61
+ '--tui-mode': 1,
62
+ '--provider': 1,
63
+ '--model': 1,
64
+ '--session': 1,
65
+ };
66
+
67
+ const RESUME_MAX_ARGS = 64;
68
+ const RESUME_MAX_BYTES = 8 * 1024;
69
+
70
+ /**
71
+ * herdr 0.9.2 self-report (docs "Add Herdr support to your agent"): rebuild this worker's launch
72
+ * line as a `resume_argv` herdr runs after a server restart. The worker's own process.argv is the
73
+ * source of truth — interpreter and cli.js paths are dropped, whitelisted spawn flags are mirrored
74
+ * (`--flag=value` splits), argv[0] becomes a plain PATH command name. herdr rules: argv[0] plain,
75
+ * no apostrophes/control characters, ≤64 args, ≤8 KiB; violations (or a missing `-e`, which could
76
+ * not restore the extension) return null and the caller skips the report — restore stays best-effort.
77
+ */
78
+ export function buildResumeArgv(workerArgv: readonly string[], piBin = 'pi'): string[] | null {
79
+ if (!/^[A-Za-z0-9._-]+$/.test(piBin)) return null;
80
+ const collected: string[] = [];
81
+ let index = 0;
82
+ // Skip interpreter + cli script (everything before the first flag).
83
+ while (index < workerArgv.length && !workerArgv[index]!.startsWith('-')) index++;
84
+ for (; index < workerArgv.length; index++) {
85
+ const raw = workerArgv[index]!;
86
+ if (!raw.startsWith('-')) continue; // positional (unknown flag's value) — not pier spawn shape
87
+ const eq = raw.indexOf('=');
88
+ const flag = eq > 0 ? raw.slice(0, eq) : raw;
89
+ const inlineValue = eq > 0 ? raw.slice(eq + 1) : null;
90
+ const arity = RESUME_FLAG_ARITY[flag];
91
+ if (arity === undefined) continue; // pi-internal flags are not replayed
92
+ if (arity === 0) {
93
+ collected.push(flag);
94
+ } else if (inlineValue !== null) {
95
+ collected.push(flag, inlineValue);
96
+ } else if (index + 1 < workerArgv.length) {
97
+ collected.push(flag, workerArgv[++index]!);
98
+ }
99
+ }
100
+ if (!collected.some((f, i) => (f === '-e' || f === '--extension') && i + 1 < collected.length)) return null;
101
+ const argv = [piBin, ...collected];
102
+ if (argv.length > RESUME_MAX_ARGS) return null;
103
+ if (argv.some((a) => /['\u0000-\u001f\u007f]/.test(a))) return null;
104
+ if (argv.reduce((sum, a) => sum + a.length + 1, 0) > RESUME_MAX_BYTES) return null;
105
+ return argv;
106
+ }
107
+
108
+ /**
109
+ * pi ≥ 0.99 outputSchema for the subagent tool: the `details` each action already produces, stamped
110
+ * with the normalized action. Codemode scripts (and any structured consumer) read this instead of
111
+ * parsing the prose reply; the text content stays authoritative for the transcript. Hard failures
112
+ * throw (toolError), so every returned value is a success and always carries structuredContent.
113
+ */
114
+ export const SUBAGENT_OUTPUT_SCHEMA = Type.Object({
115
+ action: Type.Union([
116
+ Type.Literal('spawn'), Type.Literal('resume'), Type.Literal('list'),
117
+ Type.Literal('send'), Type.Literal('interrupt'), Type.Literal('output'), Type.Literal('role'),
118
+ ]),
119
+ paneId: Type.Optional(Type.String()),
120
+ taskId: Type.Optional(Type.String()),
121
+ role: Type.Optional(Type.String()),
122
+ background: Type.Optional(Type.Boolean()),
123
+ alreadyRunning: Type.Optional(Type.Boolean()),
124
+ }, { additionalProperties: true });
125
+
126
+ export function attachSubagentStructuredContent(action: string, result: unknown): unknown {
127
+ if (result === null || typeof result !== 'object' || Array.isArray(result)) return result;
128
+ const record = result as Record<string, unknown>;
129
+ const details = record.details !== null && typeof record.details === 'object' && !Array.isArray(record.details)
130
+ ? record.details as Record<string, unknown>
131
+ : {};
132
+ return { ...record, structuredContent: { action, ...details } };
133
+ }
134
+
27
135
  /** Bounds concurrent delegation spawns (SUBAGENT_CONCURRENCY). */
28
136
  export class Semaphore {
29
137
  private active = 0;
@@ -147,6 +255,9 @@ export interface SubEntry {
147
255
  /** 'task' or a role name; only a display label for GC. */
148
256
  kind: string;
149
257
  paneId: string;
258
+ /** Phase 2 task slug (kebab-case of description; RFC rfc-delegation-identity §3.1): tool args
259
+ * accept `slug@paneId` or the bare slug; allocated once at spawn and stable across revives. */
260
+ slug?: string;
150
261
  tabId: string;
151
262
  tabName: string;
152
263
  cwd: string;
@@ -173,6 +284,41 @@ export interface SubEntry {
173
284
  };
174
285
  }
175
286
 
287
+ /* ── task slugs (RFC rfc-delegation-identity §3.1) ───────────────── */
288
+
289
+ /** Slug cap: short enough for tool args and list rows, long enough to stay distinctive. */
290
+ const SLUG_MAX = 24;
291
+
292
+ /** Kebab-case a task description; non-ASCII (CJK descriptions) sanitizes to '' — the allocator
293
+ * then falls back to a taskId-derived ascii slug, because tool args must stay typeable. */
294
+ export function slugifyTask(description: string): string {
295
+ return description
296
+ .toLowerCase()
297
+ .replace(/[''']/g, '')
298
+ .replace(/[^a-z0-9]+/g, '-')
299
+ .replace(/^-+|-+$/g, '')
300
+ .slice(0, SLUG_MAX)
301
+ .replace(/-+$/g, '');
302
+ }
303
+
304
+ /** Allocate a unique slug among live rows: base, then base-2, base-3, …; a description that
305
+ * sanitizes to nothing (CJK, emoji-only) falls back to `task-<taskId prefix>`. */
306
+ export function allocateSubSlug(description: string, taskId: string, taken: ReadonlySet<string>): string {
307
+ const base = slugifyTask(description) || `task-${taskId.slice(0, 6)}`;
308
+ if (!taken.has(base)) return base;
309
+ for (let n = 2; ; n++) {
310
+ const next = `${base}-${n}`;
311
+ if (!taken.has(next)) return next;
312
+ }
313
+ }
314
+
315
+ /** Stable display/argument form for a subagent row: `slug@paneId` when a slug exists. */
316
+ export function subRef(sub: { paneId: string; slug?: string }): string {
317
+ return sub.slug ? `${sub.slug}@${sub.paneId}` : sub.paneId;
318
+ }
319
+
320
+ export const PROGRESS_CUSTOM_TYPE = 'pi-herdr.progress';
321
+
176
322
  export interface SubsRegistry {
177
323
  version: 2;
178
324
  subs: SubEntry[];
@@ -198,6 +344,7 @@ export function foldSubsRegistry(entries: ReadonlyArray<{ type?: string; customT
198
344
  taskId: typeof s.taskId === 'string' ? s.taskId : s.paneId,
199
345
  kind: normalizeEntryKind(typeof s.kind === 'string' ? s.kind : undefined),
200
346
  paneId: s.paneId,
347
+ slug: typeof s.slug === 'string' && s.slug ? s.slug : undefined,
201
348
  tabId: typeof s.tabId === 'string' ? s.tabId : '',
202
349
  tabName: typeof s.tabName === 'string' ? s.tabName : '',
203
350
  cwd: typeof s.cwd === 'string' ? s.cwd : '',
@@ -395,6 +542,12 @@ export function buildLaunchParts(
395
542
 
396
543
  const ISOLATE_SLUG_MAX = 40;
397
544
 
545
+ /**
546
+ * D-3 (verified 2026-10-20): herdr 0.9.5 `worktree create --workspace <parent>` still opens a new
547
+ * top-level workspace, not a tab inside the parent workspace, so it cannot replace pier's isolate
548
+ * placement. Keep `git worktree add` + the master-workspace tab; native worktree management remains
549
+ * an option only for workflows that accept a separate workspace.
550
+ */
398
551
  export function planIsolateWorktree(opts: {
399
552
  description: string;
400
553
  taskHex: string;
@@ -553,7 +706,7 @@ export function planForegroundTick(input: {
553
706
 
554
707
  /* ── task-id resolution ─────────────────────────────────────────── */
555
708
 
556
- type TaskIdResolutionResult =
709
+ export type TaskIdResolutionResult =
557
710
  | { kind: 'resolved'; taskId: string }
558
711
  | { kind: 'ambiguous'; query: string; candidates: string[] }
559
712
  | { kind: 'too_short'; query: string }
@@ -583,13 +736,28 @@ export function resolveTaskIdPrefix(
583
736
  return { kind: 'not_found', query: trimmed };
584
737
  }
585
738
 
739
+ /** Error text for a non-resolved `resolveTaskIdPrefix` outcome; null for `resolved` (each caller owns
740
+ * its success path) and `not_found` (each caller words its own unknown-id hint). `rawQuery` keeps
741
+ * the caller's original string in the message even though matching works on the trimmed form. */
742
+ export function taskIdResolutionError(
743
+ res: TaskIdResolutionResult,
744
+ label: 'task id' | 'subagent id',
745
+ rawQuery: string,
746
+ ): string | null {
747
+ if (res.kind === 'too_short') return `Error: ${label} prefix "${rawQuery}" is too short (minimum 4 characters).`;
748
+ if (res.kind === 'ambiguous') return ambiguousIdError(label, rawQuery, res.candidates);
749
+ return null;
750
+ }
751
+
586
752
  /* ── outbound port (composition root → plugin) ──────────────────── */
587
753
 
588
754
  /** Bound atomically at session_start: consumers read `current` and must tolerate null. */
589
755
  export interface SubagentPort {
590
756
  applyReplySession(paneId: string, sessionFile: string | null): void;
591
757
  reconcileOnReply(paneId: string): string[];
592
- listRunningSubs(): Array<{ paneId: string; description: string }>;
758
+ listRunningSubs(): Array<{ paneId: string; slug?: string; description: string; kind: string }>;
759
+ /** D113: non-closed rows for the /pier-tree view (settled nodes carry their settle time). */
760
+ listRows(): Array<{ paneId: string; slug?: string; description: string; kind: string; status: SubEntry['status']; settledAt: number | null }>;
593
761
  settleStatLine(paneId: string): Promise<string | null>;
594
762
  /** Pipe settlement: drop the row out of listRunningSubs before the finish notice wakes the master. */
595
763
  consumeReply(paneId: string, outcome: string | null): void;
@@ -375,7 +375,23 @@ export function createPoller(h: PollerHost): Poller {
375
375
  if (tail && settleAskIsSafe(tail)) {
376
376
  const res = await h.jev.ask(
377
377
  settleVerdictRequest({ description: current.description, tail }),
378
- { questionId: 'settle-attribution', timeoutMs: 2500 },
378
+ {
379
+ questionId: 'settle-attribution',
380
+ timeoutMs: 2500,
381
+ // Calibration fields (D115): noul answers carry no confidence, so the
382
+ // probabilities and the composed verdict are what SETTLE_*_MIN_NOUL
383
+ // tuning reads from jev.jsonl.
384
+ enrich: ({ ok, answers }) => {
385
+ if (!ok || !answers) return {};
386
+ const match = answers.tail_matches_task;
387
+ const fin = answers.tail_has_final_answer;
388
+ return {
389
+ tailMatchesTaskNoul: match?.type === 'noul' ? match.noul : null,
390
+ tailHasFinalAnswerNoul: fin?.type === 'noul' ? fin.noul : null,
391
+ settleVerdict: evaluateSettleVerdict(answers) ?? 'unanswered',
392
+ };
393
+ },
394
+ },
379
395
  );
380
396
  if (res.ok) {
381
397
  const verdict = evaluateSettleVerdict(res.answers);
@@ -8,6 +8,8 @@ import { mkdirSync, stat as statCb } from 'node:fs';
8
8
  import { basename, dirname, join, resolve as pathResolve } from 'node:path';
9
9
  import { promisify } from 'node:util';
10
10
  import type { HerdrClientLike } from './herdr-client.ts';
11
+ import { delegationEnv } from './delegation-core.ts';
12
+ import { bareSessionId } from './session-tail.ts';
11
13
  import { pipeNameCandidates, pipeNameFor, pipeRequest, pipeRequestTo } from './pipe-channel.ts';
12
14
  import { composeForRole } from './manifest-compose.ts';
13
15
  import { platformPaths } from './platform-paths.ts';
@@ -18,6 +20,7 @@ import type { HistoryEntry } from './history-store.ts';
18
20
  import {
19
21
  FOREGROUND_POLL_MS,
20
22
  Semaphore,
23
+ allocateSubSlug,
21
24
  buildAliveNotice,
22
25
  buildBlockedGateNotice,
23
26
  buildIsolatePreamble,
@@ -32,8 +35,10 @@ import {
32
35
  planForegroundTick,
33
36
  planIsolateWorktree,
34
37
  planLaunchValidation,
38
+ planShellReady,
35
39
  planTabPlacement,
36
40
  sleep,
41
+ subRef,
37
42
  type SubEntry,
38
43
  type TabPlacementPlan,
39
44
  type WorktreeZone,
@@ -45,6 +50,11 @@ import type { SessionIo } from './subagent-session.ts';
45
50
 
46
51
  const statAsync = promisify(statCb);
47
52
 
53
+ /** Bound on waiting for a fresh pane's shell to reach its prompt. On timeout the launch line is sent
54
+ * anyway: `waitSubReady` still diagnoses a failed boot, and an unsupported probe must not stall spawn. */
55
+ export const SHELL_READY_TIMEOUT_MS = 2_000;
56
+ const SHELL_READY_POLL_MS = 100;
57
+
48
58
  /* ── git adapter ────────────────────────────────────────────────── */
49
59
 
50
60
  export interface GitResult {
@@ -302,6 +312,18 @@ export function createSpawner(h: SpawnerHost): Spawner {
302
312
  return buildLaunchLine(buildLaunchParts(h.runtime, { resumeFile, roleModel, approve }));
303
313
  }
304
314
 
315
+ /** herdr 0.9.4 refuses over-long commands on a shell that is still starting; `pane.send_text`
316
+ * has no such guard. Poll the foreground process until the shell owns the PTY (or give up). */
317
+ async function waitShellReady(paneId: string): Promise<void> {
318
+ const deadline = Date.now() + SHELL_READY_TIMEOUT_MS;
319
+ while (Date.now() < deadline) {
320
+ const info = await h.client.processInfo(paneId).catch(() => null);
321
+ if (info === null) return; // older server or pane already gone: keep the old best-effort path
322
+ if (planShellReady(info.foreground) === 'ready') return;
323
+ await sleep(SHELL_READY_POLL_MS);
324
+ }
325
+ }
326
+
305
327
  async function approveFor(cwd: string, masterCwd: string): Promise<boolean> {
306
328
  if (isPathUnder(cwd, masterCwd)) return true;
307
329
  const zone = classifyWorktreeZone({ cwd, masterCwd, worktrees: await h.git.listWorktrees(masterCwd) });
@@ -471,6 +493,7 @@ export function createSpawner(h: SpawnerHost): Spawner {
471
493
  focus: false,
472
494
  ...(ratio != null ? { ratio } : {}),
473
495
  });
496
+ await waitShellReady(paneId);
474
497
  await h.client.sendPaneText(paneId, launch);
475
498
  return { tabId: plan.tabId, paneId, tabName: plan.tabName };
476
499
  }
@@ -482,6 +505,7 @@ export function createSpawner(h: SpawnerHost): Spawner {
482
505
  cwd,
483
506
  env: envOver,
484
507
  });
508
+ await waitShellReady(created.paneId);
485
509
  await h.client.sendPaneText(created.paneId, launch);
486
510
  return { tabId: created.tabId, paneId: created.paneId, tabName: plan.tabName };
487
511
  } finally {
@@ -517,11 +541,13 @@ interface SpawnActionHost {
517
541
  writeHistory(entry: SubEntry, patch?: Partial<HistoryEntry>, via?: string): void;
518
542
  pendingIsolateBranches: Set<string>;
519
543
  lastMachineInjectAt: Map<string, number>;
520
- lastRequestIdByPane: Map<string, string>;
544
+ lastRequestIdByPane: Map<string, number | string>;
521
545
  git: GitIo;
522
546
  session: SessionIo;
523
547
  spawn: Spawner;
524
548
  poller: Poller;
549
+ /** Master session id for the delegation identity handed to the worker. */
550
+ getSessionId: () => string;
525
551
  /** Phase 0 routing telemetry (RFC rfc-jev-role-routing §8): best-effort spawn profile; absent in tests. */
526
552
  logRouting?: (row: RoutingTelemetryRecord) => void;
527
553
  }
@@ -545,6 +571,8 @@ export function createSpawnAction(h: SpawnActionHost) {
545
571
  // Validate the role before any isolate worktree exists: this early return has no cleanup path.
546
572
  let roleManifestEnv: Record<string, string> = {};
547
573
  let roleModel: string | null = null;
574
+ // Delegation identity (RFC rfc-delegation-identity §2.1): the worker learns who delegated it.
575
+ let delegationEnvPatch: Record<string, string> = {};
548
576
  try {
549
577
  const { role, manifest } = composeForRole(manifestRole ?? 'worker-default', suggested, { loadRoleOpts: { baseDir: masterCwd } });
550
578
  roleManifestEnv = {
@@ -561,6 +589,15 @@ export function createSpawnAction(h: SpawnActionHost) {
561
589
  // Role base: /pier-role and the pipe switch resolve .pi-herdr/roles/ against the MASTER's checkout.
562
590
  PI_HERDR_ROLE_BASE: masterCwd,
563
591
  };
592
+ delegationEnvPatch = delegationEnv({
593
+ parentPaneId: env?.paneId ?? '',
594
+ parentSessionId: bareSessionId(h.getSessionId()),
595
+ // Phase 2: the master's own pipe so the worker's report_progress can dial home (D49).
596
+ parentPipe: pipeNameFor(masterCwd, env?.paneId ?? ''),
597
+ taskId,
598
+ description: spec.description,
599
+ role: role.role,
600
+ });
564
601
  if (typeof role.model === 'string' && role.model.trim()) roleModel = role.model.trim();
565
602
  h.logRouting?.(
566
603
  planSpawnProfileRow({
@@ -631,14 +668,24 @@ export function createSpawnAction(h: SpawnActionHost) {
631
668
  const spawned = await spawnPaneInTaskTab(
632
669
  { desiredTab: tab, description: spec.description, zone },
633
670
  cwd,
634
- { PI_HERDR_SUBAGENT: '1', ...roleManifestEnv },
671
+ // pier owns worker state reporting (report_agent with todo/blocked badges); pi 1.1+'s own
672
+ // OSC 7501 program-status emission would become a second, competing source once a terminal
673
+ // or dashboard starts reading it. One reporter per pane.
674
+ { PI_HERDR_SUBAGENT: '1', PI_PROGRAM_STATUS: '0', ...roleManifestEnv, ...delegationEnvPatch },
635
675
  launchLine(null, roleModel, approve),
636
676
  );
637
677
  paneId = spawned.paneId;
678
+ // Phase 2: allocate once per spawn; revives/history keep the row's slug (stability beats beauty).
679
+ const slug = allocateSubSlug(
680
+ spec.description,
681
+ taskId,
682
+ new Set([...subs.values()].map((s) => s.slug ?? '').filter(Boolean)),
683
+ );
638
684
  const entry: SubEntry = {
639
685
  taskId,
640
686
  kind,
641
687
  paneId,
688
+ slug,
642
689
  tabId: spawned.tabId,
643
690
  tabName: spawned.tabName,
644
691
  cwd,
@@ -686,8 +733,8 @@ export function createSpawnAction(h: SpawnActionHost) {
686
733
  if (background) {
687
734
  handToPoller(`prompt-${taskId}`);
688
735
  return {
689
- content: [{ type: 'text', text: `started subagent ${paneId} (task ${taskId})` }],
690
- details: { paneId, taskId, background: true, role: kind },
736
+ content: [{ type: 'text', text: `started subagent ${subRef(entry)} (task ${taskId})` }],
737
+ details: { paneId, taskId, background: true, role: kind, slug },
691
738
  };
692
739
  }
693
740
 
@@ -69,7 +69,9 @@ export interface TerminalEntry {
69
69
  label: string | null;
70
70
  createdAt: number;
71
71
  lastActivityAt: number;
72
- /** Idle-nudge bookkeeping: set when this terminal has been nudged once (per-entry, persisted). */
72
+ /** Nudges delivered for this terminal (0..TERM_REMINDERS_MAX), persisted. */
73
+ nudges: number;
74
+ /** Timestamp of the last nudge; kept alongside `nudges` for legacy records and debugging. */
73
75
  nudgedAt: number | null;
74
76
  status: 'open' | 'closed';
75
77
  closedAt: number | null;
@@ -123,6 +125,7 @@ export function registerTerminal(
123
125
  label: opts.label ?? (opts.cwd ? basename(opts.cwd.replace(/\\/g, '/')) || opts.cwd : null),
124
126
  createdAt: opts.createdAt,
125
127
  lastActivityAt: opts.createdAt,
128
+ nudges: 0,
126
129
  nudgedAt: null,
127
130
  status: 'open',
128
131
  closedAt: null,
@@ -413,6 +416,9 @@ export function foldTerminalsRegistry(
413
416
  label: typeof t.label === 'string' ? t.label : null,
414
417
  createdAt: typeof t.createdAt === 'number' ? t.createdAt : 0,
415
418
  lastActivityAt: typeof t.lastActivityAt === 'number' ? t.lastActivityAt : 0,
419
+ nudges: typeof t.nudges === 'number'
420
+ ? Math.max(0, Math.min(TERM_REMINDERS_MAX, Math.floor(t.nudges)))
421
+ : (typeof t.nudgedAt === 'number' ? 1 : 0),
416
422
  nudgedAt: typeof t.nudgedAt === 'number' ? t.nudgedAt : null,
417
423
  status: t.status === 'closed' ? ('closed' as const) : ('open' as const),
418
424
  closedAt: typeof t.closedAt === 'number' ? t.closedAt : null,
@@ -426,17 +432,20 @@ export function foldTerminalsRegistry(
426
432
  return found;
427
433
  }
428
434
 
429
- /* ── Idle-terminal nudge: turn-end self-cleanup for shells the model forgot to close ──
430
- * Terminals persist by design, so a finished shell lingers as a dead split; delivery wiring mirrors
431
- * the todo stop-reminder in plugins/terminal. */
435
+ /* ── Terminal nudge: turn-end self-cleanup for shells the model forgot to close ──
436
+ * Terminals persist by design, so a finished shell lingers as a dead split. Two notices per terminal:
437
+ * 1. at the end of the turn that used it ("used this turn") — or the first turn that finds it idle
438
+ * past the threshold, for a shell nobody touched recently;
439
+ * 2. on a later settle once it has been idle past PIER_TERM_IDLE_MS.
440
+ * Delivery wiring mirrors the todo stop-reminder in plugins/terminal. */
432
441
 
433
- /** Cap idle-terminal nudges for the lifetime of the process (same shape as TODO_REMINDERS_MAX). */
442
+ /** Per-terminal nudge cap: one after use, one after the idle threshold (goodbye-loop guard). */
434
443
  export const TERM_REMINDERS_MAX = 2;
435
444
 
436
- /** Idle threshold before a nudge is due; the env var is read per call so tests can use small values. */
445
+ /** Idle threshold for the second nudge; the env var is read per call so tests can use small values. */
437
446
  export function terminalIdleMs(): number {
438
447
  // B10: canonical PIER_TERM_IDLE_MS, legacy PI_HERDR_TERM_IDLE_MS.
439
- return Number(pierOption('PIER_TERM_IDLE_MS') ?? 30 * 60_000) || 30 * 60_000;
448
+ return Number(pierOption('PIER_TERM_IDLE_MS') ?? 5 * 60_000) || 5 * 60_000;
440
449
  }
441
450
 
442
451
  /** Grace from agent_settled to injection, letting the user read the answer and intervene; cancel on agent_start. */
@@ -446,55 +455,80 @@ export function terminalReminderGraceMs(): number {
446
455
 
447
456
  export const TERM_REMINDER_CUSTOM_TYPE = 'pi-herdr.term-reminder';
448
457
 
449
- export interface IdleTerminalInput {
458
+ export interface TerminalNudgeInput {
450
459
  terminalId: string;
451
460
  cwd: string;
452
461
  label: string | null;
453
462
  lastActivityAt: number;
454
- /** Set once this terminal has been nudged; a nudged terminal is never nudged again (goodbye-loop guard). */
463
+ /** Nudges already delivered; when absent, legacy `nudgedAt` counts as one. */
464
+ nudges?: number;
455
465
  nudgedAt?: number | null;
466
+ /** A non-shell foreground process owns the pane (dev server / running job); never nudge it. */
467
+ busy?: boolean;
456
468
  }
457
469
 
458
- export interface IdleTerminalReminderPlan {
470
+ export interface TerminalReminderPlan {
459
471
  due: boolean;
460
472
  content: string | null;
461
- /** Nudge count after successful injection, or the original count when due is false. */
462
- nextReminders: number;
463
- /** Terminal ids this nudge covers; the caller marks them nudged at schedule time. */
473
+ /** Terminals covered by this notice; the caller bumps each one's `nudges` at schedule time. */
464
474
  ids: string[];
465
475
  }
466
476
 
467
- function noIdleInject(reminders: number): IdleTerminalReminderPlan {
468
- return { due: false, content: null, nextReminders: reminders, ids: [] };
477
+ function noTerminalInject(): TerminalReminderPlan {
478
+ return { due: false, content: null, ids: [] };
479
+ }
480
+
481
+ function nudgesOf(t: TerminalNudgeInput): number {
482
+ return t.nudges ?? (t.nudgedAt != null ? 1 : 0);
483
+ }
484
+
485
+ /** `72m` / `3h12m`: the reason column reads at a glance without a timestamp. */
486
+ function formatIdleAge(ms: number): string {
487
+ const minutes = Math.max(0, Math.floor(ms / 60_000));
488
+ if (minutes < 60) return `${minutes}m`;
489
+ const hours = Math.floor(minutes / 60);
490
+ return `${hours}h${String(minutes % 60).padStart(2, '0')}m`;
469
491
  }
470
492
 
471
493
  /**
472
- * Due only when an OPEN terminal is idle past the threshold and never nudged; the idle filter also
473
- * keeps dev-server shells touched recently out of the nudge.
494
+ * First notice: the terminal was used by the agent run that just settled, or it is already idle past
495
+ * the threshold. Second notice: a terminal with one nudge is idle past the threshold again. A shell
496
+ * touched recently is never nudged, so a dev server in active use stays out.
474
497
  */
475
- export function planIdleTerminalReminder(
476
- input: { open: readonly IdleTerminalInput[]; now: number; reminders: number },
477
- ): IdleTerminalReminderPlan {
498
+ export function planTerminalReminder(
499
+ input: { open: readonly TerminalNudgeInput[]; now: number; turnStartedAt?: number | null },
500
+ ): TerminalReminderPlan {
478
501
  const idleMs = terminalIdleMs();
479
- const idle = input.open.filter(
480
- (t) => !t.nudgedAt && input.now - t.lastActivityAt >= idleMs,
481
- );
482
- if (idle.length === 0 || input.reminders >= TERM_REMINDERS_MAX) {
483
- return noIdleInject(input.reminders);
502
+ const rows: string[] = [];
503
+ const ids: string[] = [];
504
+ for (const t of input.open) {
505
+ if (t.busy) continue;
506
+ const nudges = nudgesOf(t);
507
+ if (nudges >= TERM_REMINDERS_MAX) continue;
508
+ const idleFor = input.now - t.lastActivityAt;
509
+ const sinceNudge = t.nudgedAt != null ? input.now - t.nudgedAt : Number.POSITIVE_INFINITY;
510
+ const usedThisTurn = input.turnStartedAt != null && t.lastActivityAt >= input.turnStartedAt;
511
+ // Second notice waits another full idle window after the first one, so a pane that was already
512
+ // idle for an hour at nudge 1 is not nudged twice back-to-back on the next settle.
513
+ const reason = nudges === 0 && usedThisTurn
514
+ ? 'used this turn'
515
+ : nudges === 0 && idleFor >= idleMs
516
+ ? `idle ${formatIdleAge(idleFor)}`
517
+ : nudges === 1 && idleFor >= idleMs && sinceNudge >= idleMs
518
+ ? `still idle ${formatIdleAge(idleFor)}`
519
+ : null;
520
+ if (reason == null) continue;
521
+ rows.push(`- ${t.terminalId} (${t.label ?? t.cwd}) — ${reason} · nudge ${nudges + 1}/${TERM_REMINDERS_MAX}`);
522
+ ids.push(t.terminalId);
484
523
  }
485
- const list = idle.map((t) => `- ${t.terminalId} (${t.label ?? t.cwd})`).join('\n');
524
+ if (ids.length === 0) return noTerminalInject();
486
525
  const content = [
487
- `<system-reminder>Open terminal(s) idle for a while (nudge ${input.reminders + 1}/${TERM_REMINDERS_MAX}):`,
488
- list,
526
+ '<system-reminder>Open terminal(s) that may be finished:',
527
+ ...rows,
489
528
  'This is an internal housekeeping notice — do NOT reply to the user and do NOT send any farewell on this notice.',
490
529
  'If the work in them is done, close them now with terminal(action: "close", terminal_id: "…").',
491
530
  'Keep a terminal only if a long-running process still needs that shell.',
492
531
  '</system-reminder>',
493
532
  ].join('\n');
494
- return {
495
- due: true,
496
- content,
497
- nextReminders: input.reminders + 1,
498
- ids: idle.map((t) => t.terminalId),
499
- };
533
+ return { due: true, content, ids };
500
534
  }
@@ -13,6 +13,29 @@ import { todoMark } from './todo-window.ts';
13
13
  const EMPTY_GUARD_EVERY_N = 4;
14
14
  const TODO_READ_CUSTOM_TYPE = 'pi-herdr.todo-read';
15
15
 
16
+ /**
17
+ * pi 1.2 fires `before_agent_start` for runs started by `sendMessage(..., { triggerTurn: true })`
18
+ * too. The todo hook wants the plan in every *user* run and after a compaction continuation (the
19
+ * model needs the plan to resume), but not in its own stop reminder or the config guide: those
20
+ * already carry their own text, and counting them as turns would advance the guard cadence.
21
+ * Keep this list in sync with COMPACTION_CONTINUE_TYPE in compact-coordinator.ts.
22
+ */
23
+ export const TODO_READ_CUSTOM_TYPES: readonly string[] = ['pi-herdr.compaction-continue'];
24
+
25
+ /** Whether this run should receive the todo read hook. Missing `messageType` is pi < 1.2, where
26
+ * only user runs fired `before_agent_start`; treat it as a user run for compatibility. */
27
+ export function shouldRunTodoRead(event: unknown): boolean {
28
+ if (event === null || typeof event !== 'object' || !('messageType' in event)) return true;
29
+ const messageType = (event as { messageType?: unknown }).messageType;
30
+ if (messageType === 'user') return true;
31
+ if (messageType !== 'custom') return false;
32
+ const message = (event as { message?: unknown }).message;
33
+ const customType = message !== null && typeof message === 'object' && 'customType' in message
34
+ ? (message as { customType?: unknown }).customType
35
+ : undefined;
36
+ return typeof customType === 'string' && TODO_READ_CUSTOM_TYPES.includes(customType);
37
+ }
38
+
16
39
  type TodoReadEffect =
17
40
  | 'recite' // Recite normally while the list is fresh.
18
41
  | 'empty-guard' // Guard against an empty list.