pi-pier 0.3.1 → 0.4.2

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.
Files changed (54) hide show
  1. package/README.md +7 -5
  2. package/package.json +3 -3
  3. package/schemas/efficiency-config.schema.json +6 -0
  4. package/src/ask-dialog.ts +9 -2
  5. package/src/ask-user.ts +142 -74
  6. package/src/bootstrap.ts +2 -1
  7. package/src/compact-coordinator.ts +207 -35
  8. package/src/compact-economics-core.ts +62 -29
  9. package/src/config-catalog-core.ts +1 -0
  10. package/src/cordis-services.ts +64 -0
  11. package/src/dashboard-command.ts +40 -15
  12. package/src/delegation-core.ts +115 -0
  13. package/src/efficiency-config-core.ts +17 -6
  14. package/src/efficiency-store.ts +43 -6
  15. package/src/guards.ts +6 -0
  16. package/src/herdr-client.ts +141 -36
  17. package/src/history-store.ts +2 -0
  18. package/src/index-gates.ts +6 -12
  19. package/src/index-master.ts +12 -16
  20. package/src/index-pipe.ts +6 -0
  21. package/src/index-runtime.ts +14 -0
  22. package/src/index-worker.ts +4 -10
  23. package/src/index.ts +310 -76
  24. package/src/jev-client.ts +32 -7
  25. package/src/jev-core.ts +71 -36
  26. package/src/observation-core.ts +13 -1
  27. package/src/panel-open.ts +47 -0
  28. package/src/pier-options.ts +1 -2
  29. package/src/pipe-channel.ts +9 -0
  30. package/src/plugins/heat-plan.ts +18 -3
  31. package/src/plugins/observation.ts +118 -46
  32. package/src/plugins/subagent.ts +119 -68
  33. package/src/plugins/terminal.ts +221 -57
  34. package/src/plugins/todo.ts +63 -63
  35. package/src/reducer-core.ts +3 -5
  36. package/src/reducer-invoker.ts +27 -13
  37. package/src/renderers.ts +25 -2
  38. package/src/role-manifest.ts +2 -3
  39. package/src/roles/worker-default.json +2 -2
  40. package/src/settle-wake-core.ts +17 -3
  41. package/src/subagent-core.ts +209 -3
  42. package/src/subagent-poller.ts +69 -19
  43. package/src/subagent-session.ts +67 -4
  44. package/src/subagent-spawn.ts +55 -7
  45. package/src/terminal-core.ts +178 -50
  46. package/src/todo-core.ts +47 -2
  47. package/src/todo-read-hook.ts +54 -17
  48. package/src/todo-reminder-core.ts +9 -5
  49. package/src/todo-window.ts +8 -4
  50. package/src/todos-service.ts +12 -0
  51. package/src/tree-view.ts +162 -0
  52. package/src/vocab.ts +7 -1
  53. package/src/worker-marker.ts +76 -0
  54. package/src/focus-poller.ts +0 -223
@@ -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,12 +20,14 @@ 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,
24
27
  buildLaunchLine,
25
28
  buildLaunchParts,
26
29
  classifyWorktreeZone,
30
+ formatIsolateLocation,
27
31
  formatSubagentResult,
28
32
  formatWorktreeStat,
29
33
  isAlive,
@@ -32,8 +36,10 @@ import {
32
36
  planForegroundTick,
33
37
  planIsolateWorktree,
34
38
  planLaunchValidation,
39
+ planShellReady,
35
40
  planTabPlacement,
36
41
  sleep,
42
+ subRef,
37
43
  type SubEntry,
38
44
  type TabPlacementPlan,
39
45
  type WorktreeZone,
@@ -45,6 +51,11 @@ import type { SessionIo } from './subagent-session.ts';
45
51
 
46
52
  const statAsync = promisify(statCb);
47
53
 
54
+ /** Bound on waiting for a fresh pane's shell to reach its prompt. On timeout the launch line is sent
55
+ * anyway: `waitSubReady` still diagnoses a failed boot, and an unsupported probe must not stall spawn. */
56
+ export const SHELL_READY_TIMEOUT_MS = 2_000;
57
+ const SHELL_READY_POLL_MS = 100;
58
+
48
59
  /* ── git adapter ────────────────────────────────────────────────── */
49
60
 
50
61
  export interface GitResult {
@@ -302,6 +313,18 @@ export function createSpawner(h: SpawnerHost): Spawner {
302
313
  return buildLaunchLine(buildLaunchParts(h.runtime, { resumeFile, roleModel, approve }));
303
314
  }
304
315
 
316
+ /** herdr 0.9.4 refuses over-long commands on a shell that is still starting; `pane.send_text`
317
+ * has no such guard. Poll the foreground process until the shell owns the PTY (or give up). */
318
+ async function waitShellReady(paneId: string): Promise<void> {
319
+ const deadline = Date.now() + SHELL_READY_TIMEOUT_MS;
320
+ while (Date.now() < deadline) {
321
+ const info = await h.client.processInfo(paneId).catch(() => null);
322
+ if (info === null) return; // older server or pane already gone: keep the old best-effort path
323
+ if (planShellReady(info.foreground) === 'ready') return;
324
+ await sleep(SHELL_READY_POLL_MS);
325
+ }
326
+ }
327
+
305
328
  async function approveFor(cwd: string, masterCwd: string): Promise<boolean> {
306
329
  if (isPathUnder(cwd, masterCwd)) return true;
307
330
  const zone = classifyWorktreeZone({ cwd, masterCwd, worktrees: await h.git.listWorktrees(masterCwd) });
@@ -471,6 +494,7 @@ export function createSpawner(h: SpawnerHost): Spawner {
471
494
  focus: false,
472
495
  ...(ratio != null ? { ratio } : {}),
473
496
  });
497
+ await waitShellReady(paneId);
474
498
  await h.client.sendPaneText(paneId, launch);
475
499
  return { tabId: plan.tabId, paneId, tabName: plan.tabName };
476
500
  }
@@ -482,6 +506,7 @@ export function createSpawner(h: SpawnerHost): Spawner {
482
506
  cwd,
483
507
  env: envOver,
484
508
  });
509
+ await waitShellReady(created.paneId);
485
510
  await h.client.sendPaneText(created.paneId, launch);
486
511
  return { tabId: created.tabId, paneId: created.paneId, tabName: plan.tabName };
487
512
  } finally {
@@ -517,11 +542,13 @@ interface SpawnActionHost {
517
542
  writeHistory(entry: SubEntry, patch?: Partial<HistoryEntry>, via?: string): void;
518
543
  pendingIsolateBranches: Set<string>;
519
544
  lastMachineInjectAt: Map<string, number>;
520
- lastRequestIdByPane: Map<string, string>;
545
+ lastRequestIdByPane: Map<string, number | string>;
521
546
  git: GitIo;
522
547
  session: SessionIo;
523
548
  spawn: Spawner;
524
549
  poller: Poller;
550
+ /** Master session id for the delegation identity handed to the worker. */
551
+ getSessionId: () => string;
525
552
  /** Phase 0 routing telemetry (RFC rfc-jev-role-routing §8): best-effort spawn profile; absent in tests. */
526
553
  logRouting?: (row: RoutingTelemetryRecord) => void;
527
554
  }
@@ -545,6 +572,8 @@ export function createSpawnAction(h: SpawnActionHost) {
545
572
  // Validate the role before any isolate worktree exists: this early return has no cleanup path.
546
573
  let roleManifestEnv: Record<string, string> = {};
547
574
  let roleModel: string | null = null;
575
+ // Delegation identity (RFC rfc-delegation-identity §2.1): the worker learns who delegated it.
576
+ let delegationEnvPatch: Record<string, string> = {};
548
577
  try {
549
578
  const { role, manifest } = composeForRole(manifestRole ?? 'worker-default', suggested, { loadRoleOpts: { baseDir: masterCwd } });
550
579
  roleManifestEnv = {
@@ -561,6 +590,15 @@ export function createSpawnAction(h: SpawnActionHost) {
561
590
  // Role base: /pier-role and the pipe switch resolve .pi-herdr/roles/ against the MASTER's checkout.
562
591
  PI_HERDR_ROLE_BASE: masterCwd,
563
592
  };
593
+ delegationEnvPatch = delegationEnv({
594
+ parentPaneId: env?.paneId ?? '',
595
+ parentSessionId: bareSessionId(h.getSessionId()),
596
+ // The master's own pipe so the worker's todo_sync push can dial home (D49).
597
+ parentPipe: pipeNameFor(masterCwd, env?.paneId ?? ''),
598
+ taskId,
599
+ description: spec.description,
600
+ role: role.role,
601
+ });
564
602
  if (typeof role.model === 'string' && role.model.trim()) roleModel = role.model.trim();
565
603
  h.logRouting?.(
566
604
  planSpawnProfileRow({
@@ -631,14 +669,24 @@ export function createSpawnAction(h: SpawnActionHost) {
631
669
  const spawned = await spawnPaneInTaskTab(
632
670
  { desiredTab: tab, description: spec.description, zone },
633
671
  cwd,
634
- { PI_HERDR_SUBAGENT: '1', ...roleManifestEnv },
672
+ // pier owns worker state reporting (report_agent with todo/blocked badges); pi 1.1+'s own
673
+ // OSC 7501 program-status emission would become a second, competing source once a terminal
674
+ // or dashboard starts reading it. One reporter per pane.
675
+ { PI_HERDR_SUBAGENT: '1', PI_PROGRAM_STATUS: '0', ...roleManifestEnv, ...delegationEnvPatch },
635
676
  launchLine(null, roleModel, approve),
636
677
  );
637
678
  paneId = spawned.paneId;
679
+ // Phase 2: allocate once per spawn; revives/history keep the row's slug (stability beats beauty).
680
+ const slug = allocateSubSlug(
681
+ spec.description,
682
+ taskId,
683
+ new Set([...subs.values()].map((s) => s.slug ?? '').filter(Boolean)),
684
+ );
638
685
  const entry: SubEntry = {
639
686
  taskId,
640
687
  kind,
641
688
  paneId,
689
+ slug,
642
690
  tabId: spawned.tabId,
643
691
  tabName: spawned.tabName,
644
692
  cwd,
@@ -686,8 +734,8 @@ export function createSpawnAction(h: SpawnActionHost) {
686
734
  if (background) {
687
735
  handToPoller(`prompt-${taskId}`);
688
736
  return {
689
- content: [{ type: 'text', text: `started subagent ${paneId} (task ${taskId})` }],
690
- details: { paneId, taskId, background: true, role: kind },
737
+ content: [{ type: 'text', text: `started subagent ${subRef(entry)} (task ${taskId})${formatIsolateLocation(isolateMeta)}` }],
738
+ details: { paneId, taskId, background: true, role: kind, slug },
691
739
  };
692
740
  }
693
741
 
@@ -705,7 +753,7 @@ export function createSpawnAction(h: SpawnActionHost) {
705
753
  const question = await readAskFlag(paneId);
706
754
  handToPoller(`prompt-${taskId}`);
707
755
  return {
708
- content: [{ type: 'text', text: buildBlockedGateNotice({ paneId, description: spec.description, question }) }],
756
+ content: [{ type: 'text', text: `${buildBlockedGateNotice({ paneId, description: spec.description, question })}${formatIsolateLocation(isolateMeta)}` }],
709
757
  details: { paneId, taskId, background: true, blocked: true, role: kind },
710
758
  };
711
759
  }
@@ -729,7 +777,7 @@ export function createSpawnAction(h: SpawnActionHost) {
729
777
  if (isAlive(probe, Date.now())) {
730
778
  handToPoller(`prompt-${taskId}`);
731
779
  return {
732
- content: [{ type: 'text', text: buildAliveNotice({ paneId, description: spec.description, scenario: 'moved-to-bg', probe }, Date.now()) }],
780
+ content: [{ type: 'text', text: `${buildAliveNotice({ paneId, description: spec.description, scenario: 'moved-to-bg', probe }, Date.now())}${formatIsolateLocation(isolateMeta)}` }],
733
781
  details: { paneId, taskId, background: true, movedToBackground: true, role: kind },
734
782
  };
735
783
  }
@@ -744,7 +792,7 @@ export function createSpawnAction(h: SpawnActionHost) {
744
792
  h.persistSubs();
745
793
  h.writeHistory(entry, { outcome: outcome.kind === 'completed' ? text : null }, 'fg-settle');
746
794
  return {
747
- content: [{ type: 'text', text: formatSubagentResult(outcome, spec.description) }],
795
+ content: [{ type: 'text', text: `${formatSubagentResult(outcome, spec.description)}${formatIsolateLocation(isolateMeta)}` }],
748
796
  details: { paneId, taskId, background, role: kind },
749
797
  };
750
798
  } catch (err) {
@@ -69,14 +69,17 @@ 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;
76
78
  readRevision?: number | null;
77
79
  readLen?: number;
78
80
  readTail?: string;
79
- readEoTail?: string;
81
+ readTextLen?: number;
82
+ readTextHash?: number;
80
83
  initialized?: boolean;
81
84
  }
82
85
 
@@ -123,13 +126,15 @@ export function registerTerminal(
123
126
  label: opts.label ?? (opts.cwd ? basename(opts.cwd.replace(/\\/g, '/')) || opts.cwd : null),
124
127
  createdAt: opts.createdAt,
125
128
  lastActivityAt: opts.createdAt,
129
+ nudges: 0,
126
130
  nudgedAt: null,
127
131
  status: 'open',
128
132
  closedAt: null,
129
133
  readRevision: null,
130
134
  readLen: 0,
131
135
  readTail: '',
132
- readEoTail: '',
136
+ readTextLen: 0,
137
+ readTextHash: 0,
133
138
  };
134
139
  return { ok: true, entries: [...entries, entry], entry };
135
140
  }
@@ -200,12 +205,61 @@ export function validateSignal(key: string): { ok: true; key: SignalKey } | { ok
200
205
  return { ok: true, key: k };
201
206
  }
202
207
 
208
+ /** Compile a wait pattern with line semantics: herdr matches per line, so `^`/`$` anchors must bind
209
+ * to line starts/ends, not only the start/end of the whole pane buffer. */
210
+ export function compilePattern(pattern: string): RegExp {
211
+ return new RegExp(pattern, 'm');
212
+ }
213
+
214
+ /** The literal prefix of a regex source (leading `^` ignored, stops at the first metacharacter).
215
+ * Lets the echo guard notice `DONE_[0-9]` in a command that echoes `DONE_$?`, which the full regex
216
+ * cannot match even though the sentinel is plainly present in the typed text. */
217
+ export function regexLiteralPrefix(pattern: string): string {
218
+ let prefix = '';
219
+ for (let i = 0; i < pattern.length; i += 1) {
220
+ const ch = pattern[i]!;
221
+ if (ch === '^' && prefix.length === 0) continue;
222
+ if (ch === '\\') {
223
+ const next = pattern[i + 1];
224
+ if (next == null || /[dDwWsSbBAZzG]/.test(next)) break;
225
+ prefix += next;
226
+ i += 1;
227
+ continue;
228
+ }
229
+ if ('.*+?^${}()|[]'.includes(ch)) break;
230
+ prefix += ch;
231
+ }
232
+ return prefix;
233
+ }
234
+
235
+ /** A concrete, echo-safe wait pattern derived from the literal prefix: anchored at a line start and
236
+ * expecting the numeric sentinel suffix (`APN_BUILD2_EXIT_$?` → `^APN_BUILD2_EXIT_[0-9]`). Trailing
237
+ * digits after a separator are dropped so a literal `..._0` still derives `..._[0-9]`; digits glued
238
+ * to a name (`step2`) stay, because they are part of the marker. */
239
+ export function echoSafeWaitSuggestion(literal: string): string | null {
240
+ const base = literal.replace(/([^A-Za-z0-9])\d+$/, '$1');
241
+ if (!base) return null;
242
+ return `^${base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[0-9]`;
243
+ }
244
+
245
+ /**
246
+ * A `cmd; echo RUN_EXIT_$? >> /tmp/run.log` sentinel lands in the log file, never in the pane a
247
+ * plain wait watches: one session slept 240-420 s at a time polling such a log with bash. Returns
248
+ * the sentinel's literal prefix and the file it is redirected into, or null.
249
+ */
250
+ export function redirectedSentinel(text: string): { prefix: string; file: string } | null {
251
+ const m = /\becho\s+["']?([A-Za-z][A-Za-z0-9_]*?)\$\?["']?\s*>>?\s*(["']?)([^\s;&|"']+)\2/.exec(text);
252
+ return m ? { prefix: m[1]!, file: m[3]! } : null;
253
+ }
254
+
203
255
  /**
204
256
  * Whether a wait pattern would match the terminal's own echoed input: the shell echoes typed
205
257
  * commands into the pane, so a pattern contained in the last sent text (`…; echo TERM_DONE_$?`
206
258
  * waited on as literal `TERM_DONE_`) matches its own echo and returns "matched" before the job
207
- * has produced anything. Detection is per terminal and best-effort: no send in this process
208
- * lifetime → no hazard known → the wait proceeds unchanged.
259
+ * has produced anything. A regex whose literal prefix occurs in the echo is also a hazard: the
260
+ * sentinel `DONE_$?` becomes `DONE_<status>` only after the command runs.
261
+ * Detection is per terminal and best-effort: no send in this process lifetime → no hazard known →
262
+ * the wait proceeds unchanged.
209
263
  */
210
264
  export function sentinelEchoHazard(opts: {
211
265
  pattern: string;
@@ -216,7 +270,13 @@ export function sentinelEchoHazard(opts: {
216
270
  if (!sent) return false;
217
271
  if (opts.regex) {
218
272
  try {
219
- return new RegExp(opts.pattern).test(sent);
273
+ const re = compilePattern(opts.pattern);
274
+ if (re.test(sent)) return true;
275
+ // An anchored pattern is already safe when the echo cannot start a line with its literal
276
+ // prefix (`^DONE_[0-9]` vs `cmd; echo DONE_$?`); only unanchored patterns need the substring check.
277
+ const prefix = regexLiteralPrefix(opts.pattern);
278
+ if (!opts.pattern.startsWith('^') && prefix.length >= 3 && sent.includes(prefix)) return true;
279
+ return sent.includes('$?') && re.test(sent.replace(/\$\?/g, '0'));
220
280
  } catch {
221
281
  return false; // invalid regex is rejected by the caller before this runs
222
282
  }
@@ -239,11 +299,29 @@ export function stripAnsi(raw: string): string {
239
299
  .replace(/[\x00-\x08\x0b-\x1f\x7f]/g, '');
240
300
  }
241
301
 
302
+ /** Secondary prompts (`dquote>`, `heredoc>`, `cmdand>`, …) mean the shell is still collecting input:
303
+ * the command was incomplete (unbalanced quote, open block, …) and every later line types into it.
304
+ * Only the last non-empty line is inspected, so `>` in ordinary output cannot trip the check. */
305
+ const CONTINUATION_PROMPT_RE = /^(?:quote|dquote|bquote|heredoc|cmdand|cmdor|cmdsubst|subsh|then|else|elif|do|done|for|while|if|fi|case|esac|select|repeat|function|math|brace|foreach|cond)>$/i;
306
+
307
+ export function detectContinuationPrompt(text: string): string | null {
308
+ const lines = text.split('\n');
309
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
310
+ const line = lines[i]!.trim();
311
+ if (!line) continue;
312
+ return CONTINUATION_PROMPT_RE.test(line) ? line : null;
313
+ }
314
+ return null;
315
+ }
316
+
242
317
  export interface ReadCursor {
243
318
  revision: number;
244
319
  len: number;
245
320
  tail: string;
246
- eoTail: string;
321
+ /** Full pane-text length and FNV-1a hash: herdr's screen window can rewrite earlier lines while
322
+ * the last characters stay identical, so a tail comparison alone is not a change detector. */
323
+ textLen: number;
324
+ textHash: number;
247
325
  }
248
326
 
249
327
  export interface ReadIncrement {
@@ -255,24 +333,36 @@ export interface ReadIncrement {
255
333
 
256
334
  const CURSOR_TAIL_CHARS = 64;
257
335
 
336
+ /** FNV-1a 32-bit: change detection for the read cursor, not a security primitive. */
337
+ export function hashPaneText(text: string): number {
338
+ let hash = 0x811c9dc5;
339
+ for (let i = 0; i < text.length; i += 1) {
340
+ hash ^= text.charCodeAt(i);
341
+ hash = Math.imul(hash, 0x01000193);
342
+ }
343
+ return hash >>> 0;
344
+ }
345
+
258
346
  export function computeIncrement(
259
347
  prev: ReadCursor | null,
260
348
  next: { text: string; revision: number },
261
349
  maxChars: number,
262
350
  ): ReadIncrement {
263
351
  const stableLen = next.text.lastIndexOf('\n') + 1;
352
+ const textHash = hashPaneText(next.text);
264
353
  const cursor: ReadCursor = {
265
354
  revision: next.revision,
266
355
  len: stableLen,
267
356
  tail: next.text.slice(Math.max(0, stableLen - CURSOR_TAIL_CHARS), stableLen),
268
- eoTail: next.text.slice(-CURSOR_TAIL_CHARS).trimEnd(),
357
+ textLen: next.text.length,
358
+ textHash,
269
359
  };
270
- if (prev && next.text.slice(-CURSOR_TAIL_CHARS).trimEnd() === prev.eoTail) {
360
+ if (prev && prev.textLen === next.text.length && prev.textHash === textHash) {
271
361
  return { mode: 'none', text: '', cursor: prev, hardCapped: false };
272
362
  }
273
363
  if (
274
364
  prev &&
275
- next.text.length >= prev.len &&
365
+ next.text.length > prev.textLen &&
276
366
  next.text.slice(Math.max(0, prev.len - prev.tail.length), prev.len).trimEnd() === prev.tail.trimEnd()
277
367
  ) {
278
368
  const suffix = next.text.slice(prev.len);
@@ -290,7 +380,7 @@ export function excerptAroundMatch(text: string, pattern: string, regex: boolean
290
380
  let index = -1;
291
381
  let hitLen = pattern.length;
292
382
  if (regex) {
293
- const found = new RegExp(pattern).exec(text);
383
+ const found = compilePattern(pattern).exec(text);
294
384
  index = found?.index ?? -1;
295
385
  hitLen = found?.[0].length ?? pattern.length;
296
386
  } else {
@@ -312,7 +402,7 @@ export function classifyWaitMatch(
312
402
  pattern: string,
313
403
  regex: boolean,
314
404
  ): { kind: 'fresh'; excerpt: string } | { kind: 'stale' } | { kind: 'absent' } {
315
- const match = (text: string) => regex ? new RegExp(pattern).test(text) : text.includes(pattern);
405
+ const match = (text: string) => regex ? compilePattern(pattern).test(text) : text.includes(pattern);
316
406
  const inc = computeIncrement(prev, { text: paneText, revision: prev?.revision ?? 0 }, paneText.length + 1);
317
407
  const unread = inc.mode === 'append' ? inc.text : inc.mode === 'reset' ? paneText : '';
318
408
  if (match(unread)) return { kind: 'fresh', excerpt: excerptAroundMatch(unread, pattern, regex) };
@@ -375,10 +465,15 @@ export interface ShellInitPlan {
375
465
  readonly command: string | null;
376
466
  }
377
467
 
468
+ /** History expansion off in both managed shells: bash honours `set +H`, zsh requires
469
+ * `setopt NO_BANG_HIST` (`set +H` leaves `!` expansion on and an unbalanced `"…$!"` wedges the
470
+ * shell at `dquote>`). Both shells parse this one-liner; only the matching branch runs. */
471
+ export const SHELL_INIT_COMMAND = 'if [ -n "$ZSH_VERSION" ]; then setopt NO_BANG_HIST; else set +H; fi';
472
+
378
473
  /**
379
- * Plan the shell init command: `set +H` disables history expansion in managed bash/zsh shells, where `!`
380
- * in ordinary commands (`!|`, `if [ ! -f ... ]`, `!$`) otherwise triggers `zsh: event not found: \|` and
381
- * wedges the shell. PowerShell rejects the syntax and has no `!` history expansion, so it is skipped.
474
+ * Plan the shell init command: disable history expansion, where `!` in ordinary commands (`!|`,
475
+ * `if [ ! -f ... ]`, `!$`) otherwise triggers `zsh: event not found: \|` and wedges the shell.
476
+ * PowerShell rejects the syntax and has no `!` history expansion, so it is skipped.
382
477
  * Limit: the top-level shell only — a nested `bash -i` re-enables its own defaults; init is only worth
383
478
  * sending once the shell is at its prompt and has not been initialized yet.
384
479
  */
@@ -392,7 +487,7 @@ export function planShellInit(opts: {
392
487
  if (opts.readiness !== undefined && opts.readiness !== 'prompt') {
393
488
  return { shouldInit: false, command: null };
394
489
  }
395
- return { shouldInit: true, command: 'set +H' };
490
+ return { shouldInit: true, command: SHELL_INIT_COMMAND };
396
491
  }
397
492
 
398
493
  export function foldTerminalsRegistry(
@@ -413,30 +508,37 @@ export function foldTerminalsRegistry(
413
508
  label: typeof t.label === 'string' ? t.label : null,
414
509
  createdAt: typeof t.createdAt === 'number' ? t.createdAt : 0,
415
510
  lastActivityAt: typeof t.lastActivityAt === 'number' ? t.lastActivityAt : 0,
511
+ nudges: typeof t.nudges === 'number'
512
+ ? Math.max(0, Math.min(TERM_REMINDERS_MAX, Math.floor(t.nudges)))
513
+ : (typeof t.nudgedAt === 'number' ? 1 : 0),
416
514
  nudgedAt: typeof t.nudgedAt === 'number' ? t.nudgedAt : null,
417
515
  status: t.status === 'closed' ? ('closed' as const) : ('open' as const),
418
516
  closedAt: typeof t.closedAt === 'number' ? t.closedAt : null,
419
517
  readRevision: typeof t.readRevision === 'number' ? t.readRevision : null,
420
518
  readLen: typeof t.readLen === 'number' ? t.readLen : 0,
421
519
  readTail: typeof t.readTail === 'string' ? t.readTail : '',
422
- readEoTail: typeof t.readEoTail === 'string' ? t.readEoTail : '',
520
+ readTextLen: typeof t.readTextLen === 'number' ? t.readTextLen : 0,
521
+ readTextHash: typeof t.readTextHash === 'number' ? t.readTextHash : 0,
423
522
  initialized: t.initialized === true,
424
523
  }));
425
524
  }
426
525
  return found;
427
526
  }
428
527
 
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. */
528
+ /* ── Terminal nudge: turn-end self-cleanup for shells the model forgot to close ──
529
+ * Terminals persist by design, so a finished shell lingers as a dead split. Two notices per terminal:
530
+ * 1. at the end of the turn that used it ("used this turn") — or the first turn that finds it idle
531
+ * past the threshold, for a shell nobody touched recently;
532
+ * 2. on a later settle once it has been idle past PIER_TERM_IDLE_MS.
533
+ * Delivery wiring mirrors the todo stop-reminder in plugins/terminal. */
432
534
 
433
- /** Cap idle-terminal nudges for the lifetime of the process (same shape as TODO_REMINDERS_MAX). */
535
+ /** Per-terminal nudge cap: one after use, one after the idle threshold (goodbye-loop guard). */
434
536
  export const TERM_REMINDERS_MAX = 2;
435
537
 
436
- /** Idle threshold before a nudge is due; the env var is read per call so tests can use small values. */
538
+ /** Idle threshold for the second nudge; the env var is read per call so tests can use small values. */
437
539
  export function terminalIdleMs(): number {
438
540
  // 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;
541
+ return Number(pierOption('PIER_TERM_IDLE_MS') ?? 5 * 60_000) || 5 * 60_000;
440
542
  }
441
543
 
442
544
  /** Grace from agent_settled to injection, letting the user read the answer and intervene; cancel on agent_start. */
@@ -446,55 +548,81 @@ export function terminalReminderGraceMs(): number {
446
548
 
447
549
  export const TERM_REMINDER_CUSTOM_TYPE = 'pi-herdr.term-reminder';
448
550
 
449
- export interface IdleTerminalInput {
551
+ export interface TerminalNudgeInput {
450
552
  terminalId: string;
451
553
  cwd: string;
452
554
  label: string | null;
453
555
  lastActivityAt: number;
454
- /** Set once this terminal has been nudged; a nudged terminal is never nudged again (goodbye-loop guard). */
556
+ /** Nudges already delivered; when absent, legacy `nudgedAt` counts as one. */
557
+ nudges?: number;
455
558
  nudgedAt?: number | null;
559
+ /** A non-shell foreground process owns the pane (dev server / running job); never nudge it. */
560
+ busy?: boolean;
456
561
  }
457
562
 
458
- export interface IdleTerminalReminderPlan {
563
+ export interface TerminalReminderPlan {
459
564
  due: boolean;
460
565
  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. */
566
+ /** Terminals covered by this notice; the caller bumps each one's `nudges` when the notice is
567
+ * actually delivered (a cancelled grace timer must not consume a nudge). */
464
568
  ids: string[];
465
569
  }
466
570
 
467
- function noIdleInject(reminders: number): IdleTerminalReminderPlan {
468
- return { due: false, content: null, nextReminders: reminders, ids: [] };
571
+ function noTerminalInject(): TerminalReminderPlan {
572
+ return { due: false, content: null, ids: [] };
573
+ }
574
+
575
+ function nudgesOf(t: TerminalNudgeInput): number {
576
+ return t.nudges ?? (t.nudgedAt != null ? 1 : 0);
577
+ }
578
+
579
+ /** `72m` / `3h12m`: the reason column reads at a glance without a timestamp. */
580
+ function formatIdleAge(ms: number): string {
581
+ const minutes = Math.max(0, Math.floor(ms / 60_000));
582
+ if (minutes < 60) return `${minutes}m`;
583
+ const hours = Math.floor(minutes / 60);
584
+ return `${hours}h${String(minutes % 60).padStart(2, '0')}m`;
469
585
  }
470
586
 
471
587
  /**
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.
588
+ * First notice: the terminal was used by the agent run that just settled, or it is already idle past
589
+ * the threshold. Second notice: a terminal with one nudge is idle past the threshold again. A shell
590
+ * touched recently is never nudged, so a dev server in active use stays out.
474
591
  */
475
- export function planIdleTerminalReminder(
476
- input: { open: readonly IdleTerminalInput[]; now: number; reminders: number },
477
- ): IdleTerminalReminderPlan {
592
+ export function planTerminalReminder(
593
+ input: { open: readonly TerminalNudgeInput[]; now: number; turnStartedAt?: number | null },
594
+ ): TerminalReminderPlan {
478
595
  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);
596
+ const rows: string[] = [];
597
+ const ids: string[] = [];
598
+ for (const t of input.open) {
599
+ if (t.busy) continue;
600
+ const nudges = nudgesOf(t);
601
+ if (nudges >= TERM_REMINDERS_MAX) continue;
602
+ const idleFor = input.now - t.lastActivityAt;
603
+ const sinceNudge = t.nudgedAt != null ? input.now - t.nudgedAt : Number.POSITIVE_INFINITY;
604
+ const usedThisTurn = input.turnStartedAt != null && t.lastActivityAt >= input.turnStartedAt;
605
+ // Second notice waits another full idle window after the first one, so a pane that was already
606
+ // idle for an hour at nudge 1 is not nudged twice back-to-back on the next settle.
607
+ const reason = nudges === 0 && usedThisTurn
608
+ ? 'used this turn'
609
+ : nudges === 0 && idleFor >= idleMs
610
+ ? `idle ${formatIdleAge(idleFor)}`
611
+ : nudges === 1 && idleFor >= idleMs && sinceNudge >= idleMs
612
+ ? `still idle ${formatIdleAge(idleFor)}`
613
+ : null;
614
+ if (reason == null) continue;
615
+ rows.push(`- ${t.terminalId} (${t.label ?? t.cwd}) — ${reason} · nudge ${nudges + 1}/${TERM_REMINDERS_MAX}`);
616
+ ids.push(t.terminalId);
484
617
  }
485
- const list = idle.map((t) => `- ${t.terminalId} (${t.label ?? t.cwd})`).join('\n');
618
+ if (ids.length === 0) return noTerminalInject();
486
619
  const content = [
487
- `<system-reminder>Open terminal(s) idle for a while (nudge ${input.reminders + 1}/${TERM_REMINDERS_MAX}):`,
488
- list,
620
+ '<system-reminder>Open terminal(s) that may be finished:',
621
+ ...rows,
489
622
  'This is an internal housekeeping notice — do NOT reply to the user and do NOT send any farewell on this notice.',
490
623
  'If the work in them is done, close them now with terminal(action: "close", terminal_id: "…").',
491
624
  'Keep a terminal only if a long-running process still needs that shell.',
492
625
  '</system-reminder>',
493
626
  ].join('\n');
494
- return {
495
- due: true,
496
- content,
497
- nextReminders: input.reminders + 1,
498
- ids: idle.map((t) => t.terminalId),
499
- };
627
+ return { due: true, content, ids };
500
628
  }
package/src/todo-core.ts CHANGED
@@ -9,6 +9,17 @@ const PHASE_MAX_LEN = 30;
9
9
  /** Session entry type for human/automated edits, replayed during branch rebuild (D38). */
10
10
  export const TODO_EDIT_CUSTOM_TYPE = 'pi-herdr.todo-edit';
11
11
 
12
+ /** Session entry type recording that the near-archive rewrite window was already offered for one
13
+ * list; folded on rebuild so a resumed session proceeds to the terminal clear instead of re-warning. */
14
+ export const TODO_ARCHIVE_WINDOW_CUSTOM_TYPE = 'pi-herdr.todo-archive-window';
15
+
16
+ export interface ArchiveWindowMarker {
17
+ version: 1;
18
+ /** Ordered list identity at offer time. The marker only applies after the last snapshot, so any
19
+ * rewrite (new snapshot) or edit (list mutation) invalidates it without a timestamp comparison. */
20
+ itemsHash: string;
21
+ }
22
+
12
23
  export type TodoEditOp = 'done' | 'drop' | 'rm' | 'unblock';
13
24
 
14
25
  export interface TodoEdit {
@@ -115,6 +126,20 @@ export function listsEqual(a: readonly TodoItem[], b: readonly TodoItem[]): bool
115
126
  return true;
116
127
  }
117
128
 
129
+ /** Stable list identity (FNV-1a over content/status/blocker/phase): keys the archive-window marker
130
+ * so it never applies to a list that was edited after the window was offered. */
131
+ export function todoListHash(items: readonly TodoItem[]): string {
132
+ let hash = 0x811c9dc5;
133
+ for (const it of items) {
134
+ const text = `${it.content}\u0000${it.status}\u0000${it.blocker ?? ''}\u0000${it.phase ?? ''}\u0001`;
135
+ for (let i = 0; i < text.length; i++) {
136
+ hash ^= text.charCodeAt(i);
137
+ hash = Math.imul(hash, 0x01000193);
138
+ }
139
+ }
140
+ return (hash >>> 0).toString(16).padStart(8, '0');
141
+ }
142
+
118
143
  /** Items completed by this write (D36: surfaced back to the caller). */
119
144
  export function completionTransitions(prev: readonly TodoItem[], next: readonly TodoItem[]): string[] {
120
145
  const prevMap = new Map(prev.map((it) => [it.content, it.status]));
@@ -123,8 +148,11 @@ export function completionTransitions(prev: readonly TodoItem[], next: readonly
123
148
  .map((it) => it.content);
124
149
  }
125
150
 
126
- /** Completed items pushed back to open by this write (D37: reversible, but warn). */
151
+ /** Completed items pushed back to open by this write (D37: reversible, but warn). A previous list
152
+ * with no open item was a finished plan, and a new plan legitimately reuses generic task names, so
153
+ * content-keyed reversion is meaningless there: warn only while the same plan is still in flight. */
127
154
  export function revertedCompleted(prev: readonly TodoItem[], next: readonly TodoItem[]): string[] {
155
+ if (!prev.some((it) => it.status === 'pending' || it.status === 'in_progress')) return [];
128
156
  const nextMap = new Map(next.map((it) => [it.content, it.status]));
129
157
  return prev
130
158
  .filter((it) => {
@@ -206,6 +234,15 @@ export interface FoldedTodos {
206
234
  items: TodoItem[];
207
235
  /** Timestamp of the last write that shaped these items; null when the entry carried none. */
208
236
  writtenAt: number | null;
237
+ /** True when the anti-freeze rewrite window was already offered for exactly this untouched list. */
238
+ archiveWindowOffered: boolean;
239
+ }
240
+
241
+ function extractArchiveWindowMarker(data: unknown): ArchiveWindowMarker | null {
242
+ if (typeof data !== 'object' || data === null) return null;
243
+ const raw = data as Partial<ArchiveWindowMarker>;
244
+ if (raw.version !== 1 || typeof raw.itemsHash !== 'string') return null;
245
+ return { version: 1, itemsHash: raw.itemsHash };
209
246
  }
210
247
 
211
248
  function entryTimestamp(entry: BranchEntryLike): number | null {
@@ -222,6 +259,9 @@ function entryTimestamp(entry: BranchEntryLike): number | null {
222
259
  export function foldLatestTodosMeta(entries: readonly BranchEntryLike[]): FoldedTodos | null {
223
260
  let found: TodoItem[] | null = null;
224
261
  let writtenAt: number | null = null;
262
+ // The marker applies only when it follows the last snapshot; a later snapshot resets it, and any
263
+ // edit that actually changes the list makes the hash mismatch (a no-op edit leaves it untouched).
264
+ let windowMarker: ArchiveWindowMarker | null = null;
225
265
  for (const entry of entries) {
226
266
  if (entry.type === 'message') {
227
267
  const msg = entry.message as { role?: unknown; toolName?: unknown; details?: unknown } | null | undefined;
@@ -231,6 +271,7 @@ export function foldLatestTodosMeta(entries: readonly BranchEntryLike[]): Folded
231
271
  if (snapshot) {
232
272
  found = snapshot.items.map((it) => ({ ...it }));
233
273
  writtenAt = entryTimestamp(entry);
274
+ windowMarker = null;
234
275
  }
235
276
  } else if (entry.type === 'custom' && entry.customType === TODO_EDIT_CUSTOM_TYPE) {
236
277
  const payload = extractEditPayload(entry.data);
@@ -238,9 +279,13 @@ export function foldLatestTodosMeta(entries: readonly BranchEntryLike[]): Folded
238
279
  found = applyTodoEdits(found, payload.edits);
239
280
  writtenAt = entryTimestamp(entry) ?? (payload.ts > 0 ? payload.ts : writtenAt);
240
281
  }
282
+ } else if (entry.type === 'custom' && entry.customType === TODO_ARCHIVE_WINDOW_CUSTOM_TYPE && found) {
283
+ windowMarker = extractArchiveWindowMarker(entry.data);
241
284
  }
242
285
  }
243
- return found ? { items: found, writtenAt } : null;
286
+ if (!found) return null;
287
+ const archiveWindowOffered = windowMarker != null && windowMarker.itemsHash === todoListHash(found);
288
+ return { items: found, writtenAt, archiveWindowOffered };
244
289
  }
245
290
 
246
291
  function extractSnapshotFromDetails(details: unknown): TodoSnapshot | null {