pi-pier 0.4.1 → 0.4.3

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 (45) hide show
  1. package/README.md +1 -2
  2. package/package.json +1 -1
  3. package/schemas/efficiency-config.schema.json +25 -19
  4. package/schemas/role-manifest.schema.json +9 -9
  5. package/src/ask-dialog.ts +9 -2
  6. package/src/ask-user.ts +142 -74
  7. package/src/compact-coordinator.ts +204 -33
  8. package/src/compact-economics-core.ts +62 -29
  9. package/src/config-catalog-core.ts +1 -0
  10. package/src/delegation-core.ts +1 -1
  11. package/src/efficiency-config-core.ts +34 -20
  12. package/src/efficiency-store.ts +42 -5
  13. package/src/herdr-client.ts +15 -5
  14. package/src/index-notices.ts +1 -1
  15. package/src/index-pipe.ts +0 -8
  16. package/src/index.ts +71 -87
  17. package/src/jev-client.ts +32 -7
  18. package/src/jev-core.ts +70 -33
  19. package/src/manifest-compose.ts +4 -4
  20. package/src/observation-core.ts +13 -1
  21. package/src/pipe-channel.ts +0 -8
  22. package/src/plugins/heat-plan.ts +18 -3
  23. package/src/plugins/observation.ts +118 -46
  24. package/src/plugins/subagent.ts +53 -40
  25. package/src/plugins/terminal.ts +192 -47
  26. package/src/plugins/todo.ts +44 -22
  27. package/src/reducer-invoker.ts +28 -14
  28. package/src/renderers.ts +1 -13
  29. package/src/role-loader.ts +8 -8
  30. package/src/role-manifest.ts +22 -22
  31. package/src/roles/master.json +1 -1
  32. package/src/roles/worker-default.json +3 -3
  33. package/src/settle-wake-core.ts +8 -2
  34. package/src/subagent-core.ts +41 -3
  35. package/src/subagent-poller.ts +52 -18
  36. package/src/subagent-session.ts +67 -4
  37. package/src/subagent-spawn.ts +6 -5
  38. package/src/terminal-core.ts +111 -17
  39. package/src/todo-core.ts +47 -2
  40. package/src/todo-read-hook.ts +31 -17
  41. package/src/todo-reminder-core.ts +9 -5
  42. package/src/todo-window.ts +8 -4
  43. package/src/todos-service.ts +12 -0
  44. package/src/tree-view.ts +10 -10
  45. package/src/vocab.ts +7 -1
@@ -27,6 +27,7 @@ import {
27
27
  buildLaunchLine,
28
28
  buildLaunchParts,
29
29
  classifyWorktreeZone,
30
+ formatIsolateLocation,
30
31
  formatSubagentResult,
31
32
  formatWorktreeStat,
32
33
  isAlive,
@@ -592,7 +593,7 @@ export function createSpawnAction(h: SpawnActionHost) {
592
593
  delegationEnvPatch = delegationEnv({
593
594
  parentPaneId: env?.paneId ?? '',
594
595
  parentSessionId: bareSessionId(h.getSessionId()),
595
- // Phase 2: the master's own pipe so the worker's report_progress can dial home (D49).
596
+ // The master's own pipe so the worker's todo_sync push can dial home (D49).
596
597
  parentPipe: pipeNameFor(masterCwd, env?.paneId ?? ''),
597
598
  taskId,
598
599
  description: spec.description,
@@ -733,7 +734,7 @@ export function createSpawnAction(h: SpawnActionHost) {
733
734
  if (background) {
734
735
  handToPoller(`prompt-${taskId}`);
735
736
  return {
736
- content: [{ type: 'text', text: `started subagent ${subRef(entry)} (task ${taskId})` }],
737
+ content: [{ type: 'text', text: `started subagent ${subRef(entry)} (task ${taskId})${formatIsolateLocation(isolateMeta)}` }],
737
738
  details: { paneId, taskId, background: true, role: kind, slug },
738
739
  };
739
740
  }
@@ -752,7 +753,7 @@ export function createSpawnAction(h: SpawnActionHost) {
752
753
  const question = await readAskFlag(paneId);
753
754
  handToPoller(`prompt-${taskId}`);
754
755
  return {
755
- content: [{ type: 'text', text: buildBlockedGateNotice({ paneId, description: spec.description, question }) }],
756
+ content: [{ type: 'text', text: `${buildBlockedGateNotice({ paneId, description: spec.description, question })}${formatIsolateLocation(isolateMeta)}` }],
756
757
  details: { paneId, taskId, background: true, blocked: true, role: kind },
757
758
  };
758
759
  }
@@ -776,7 +777,7 @@ export function createSpawnAction(h: SpawnActionHost) {
776
777
  if (isAlive(probe, Date.now())) {
777
778
  handToPoller(`prompt-${taskId}`);
778
779
  return {
779
- 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)}` }],
780
781
  details: { paneId, taskId, background: true, movedToBackground: true, role: kind },
781
782
  };
782
783
  }
@@ -791,7 +792,7 @@ export function createSpawnAction(h: SpawnActionHost) {
791
792
  h.persistSubs();
792
793
  h.writeHistory(entry, { outcome: outcome.kind === 'completed' ? text : null }, 'fg-settle');
793
794
  return {
794
- content: [{ type: 'text', text: formatSubagentResult(outcome, spec.description) }],
795
+ content: [{ type: 'text', text: `${formatSubagentResult(outcome, spec.description)}${formatIsolateLocation(isolateMeta)}` }],
795
796
  details: { paneId, taskId, background, role: kind },
796
797
  };
797
798
  } catch (err) {
@@ -78,7 +78,8 @@ export interface TerminalEntry {
78
78
  readRevision?: number | null;
79
79
  readLen?: number;
80
80
  readTail?: string;
81
- readEoTail?: string;
81
+ readTextLen?: number;
82
+ readTextHash?: number;
82
83
  initialized?: boolean;
83
84
  }
84
85
 
@@ -132,7 +133,8 @@ export function registerTerminal(
132
133
  readRevision: null,
133
134
  readLen: 0,
134
135
  readTail: '',
135
- readEoTail: '',
136
+ readTextLen: 0,
137
+ readTextHash: 0,
136
138
  };
137
139
  return { ok: true, entries: [...entries, entry], entry };
138
140
  }
@@ -203,12 +205,61 @@ export function validateSignal(key: string): { ok: true; key: SignalKey } | { ok
203
205
  return { ok: true, key: k };
204
206
  }
205
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
+
206
255
  /**
207
256
  * Whether a wait pattern would match the terminal's own echoed input: the shell echoes typed
208
257
  * commands into the pane, so a pattern contained in the last sent text (`…; echo TERM_DONE_$?`
209
258
  * waited on as literal `TERM_DONE_`) matches its own echo and returns "matched" before the job
210
- * has produced anything. Detection is per terminal and best-effort: no send in this process
211
- * 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.
212
263
  */
213
264
  export function sentinelEchoHazard(opts: {
214
265
  pattern: string;
@@ -219,7 +270,13 @@ export function sentinelEchoHazard(opts: {
219
270
  if (!sent) return false;
220
271
  if (opts.regex) {
221
272
  try {
222
- 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'));
223
280
  } catch {
224
281
  return false; // invalid regex is rejected by the caller before this runs
225
282
  }
@@ -242,11 +299,29 @@ export function stripAnsi(raw: string): string {
242
299
  .replace(/[\x00-\x08\x0b-\x1f\x7f]/g, '');
243
300
  }
244
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
+
245
317
  export interface ReadCursor {
246
318
  revision: number;
247
319
  len: number;
248
320
  tail: string;
249
- 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;
250
325
  }
251
326
 
252
327
  export interface ReadIncrement {
@@ -258,24 +333,36 @@ export interface ReadIncrement {
258
333
 
259
334
  const CURSOR_TAIL_CHARS = 64;
260
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
+
261
346
  export function computeIncrement(
262
347
  prev: ReadCursor | null,
263
348
  next: { text: string; revision: number },
264
349
  maxChars: number,
265
350
  ): ReadIncrement {
266
351
  const stableLen = next.text.lastIndexOf('\n') + 1;
352
+ const textHash = hashPaneText(next.text);
267
353
  const cursor: ReadCursor = {
268
354
  revision: next.revision,
269
355
  len: stableLen,
270
356
  tail: next.text.slice(Math.max(0, stableLen - CURSOR_TAIL_CHARS), stableLen),
271
- eoTail: next.text.slice(-CURSOR_TAIL_CHARS).trimEnd(),
357
+ textLen: next.text.length,
358
+ textHash,
272
359
  };
273
- if (prev && next.text.slice(-CURSOR_TAIL_CHARS).trimEnd() === prev.eoTail) {
360
+ if (prev && prev.textLen === next.text.length && prev.textHash === textHash) {
274
361
  return { mode: 'none', text: '', cursor: prev, hardCapped: false };
275
362
  }
276
363
  if (
277
364
  prev &&
278
- next.text.length >= prev.len &&
365
+ next.text.length > prev.textLen &&
279
366
  next.text.slice(Math.max(0, prev.len - prev.tail.length), prev.len).trimEnd() === prev.tail.trimEnd()
280
367
  ) {
281
368
  const suffix = next.text.slice(prev.len);
@@ -293,7 +380,7 @@ export function excerptAroundMatch(text: string, pattern: string, regex: boolean
293
380
  let index = -1;
294
381
  let hitLen = pattern.length;
295
382
  if (regex) {
296
- const found = new RegExp(pattern).exec(text);
383
+ const found = compilePattern(pattern).exec(text);
297
384
  index = found?.index ?? -1;
298
385
  hitLen = found?.[0].length ?? pattern.length;
299
386
  } else {
@@ -315,7 +402,7 @@ export function classifyWaitMatch(
315
402
  pattern: string,
316
403
  regex: boolean,
317
404
  ): { kind: 'fresh'; excerpt: string } | { kind: 'stale' } | { kind: 'absent' } {
318
- 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);
319
406
  const inc = computeIncrement(prev, { text: paneText, revision: prev?.revision ?? 0 }, paneText.length + 1);
320
407
  const unread = inc.mode === 'append' ? inc.text : inc.mode === 'reset' ? paneText : '';
321
408
  if (match(unread)) return { kind: 'fresh', excerpt: excerptAroundMatch(unread, pattern, regex) };
@@ -378,10 +465,15 @@ export interface ShellInitPlan {
378
465
  readonly command: string | null;
379
466
  }
380
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
+
381
473
  /**
382
- * Plan the shell init command: `set +H` disables history expansion in managed bash/zsh shells, where `!`
383
- * in ordinary commands (`!|`, `if [ ! -f ... ]`, `!$`) otherwise triggers `zsh: event not found: \|` and
384
- * 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.
385
477
  * Limit: the top-level shell only — a nested `bash -i` re-enables its own defaults; init is only worth
386
478
  * sending once the shell is at its prompt and has not been initialized yet.
387
479
  */
@@ -395,7 +487,7 @@ export function planShellInit(opts: {
395
487
  if (opts.readiness !== undefined && opts.readiness !== 'prompt') {
396
488
  return { shouldInit: false, command: null };
397
489
  }
398
- return { shouldInit: true, command: 'set +H' };
490
+ return { shouldInit: true, command: SHELL_INIT_COMMAND };
399
491
  }
400
492
 
401
493
  export function foldTerminalsRegistry(
@@ -425,7 +517,8 @@ export function foldTerminalsRegistry(
425
517
  readRevision: typeof t.readRevision === 'number' ? t.readRevision : null,
426
518
  readLen: typeof t.readLen === 'number' ? t.readLen : 0,
427
519
  readTail: typeof t.readTail === 'string' ? t.readTail : '',
428
- 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,
429
522
  initialized: t.initialized === true,
430
523
  }));
431
524
  }
@@ -470,7 +563,8 @@ export interface TerminalNudgeInput {
470
563
  export interface TerminalReminderPlan {
471
564
  due: boolean;
472
565
  content: string | null;
473
- /** Terminals covered by this notice; the caller bumps each one's `nudges` 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). */
474
568
  ids: string[];
475
569
  }
476
570
 
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 {
@@ -1,15 +1,15 @@
1
1
  /**
2
2
  * Plan the todo read hook (before_agent_start + display:false). Stale-core keeps a fully completed list
3
3
  * from being recited forever: stale (turn-based) warns with the old entries as rewrite references,
4
- * rate-limited and capped; archived (wall-clock) treats the list as absent, sharing the guard cadence
5
- * with empty lists.
4
+ * rate-limited and capped; archived (wall-clock) treats the list as absent — one rewrite window (its
5
+ * offer persisted per list identity so a resume skips it), then the terminal clear on the next run.
6
6
  */
7
7
  import { boundedView, currentActivity, type TodoItem } from './todo-core.ts';
8
8
  import { countTodos } from './vocab.ts';
9
9
  import { STALE_NOTICE_MAX, evaluateStaleness, formatAge } from './stale-core.ts';
10
- import { todoMark } from './todo-window.ts';
10
+ import { todoLine } from './todo-window.ts';
11
11
 
12
- /** Cadence shared by empty-list guards, stale warnings, and archive notices. */
12
+ /** Cadence shared by empty-list guards, stale warnings, and the archive rewrite window. */
13
13
  const EMPTY_GUARD_EVERY_N = 4;
14
14
  const TODO_READ_CUSTOM_TYPE = 'pi-herdr.todo-read';
15
15
 
@@ -51,6 +51,8 @@ interface TodoReadPlan {
51
51
  /** Clear the in-memory list when injecting the archive notice (the caller persists the empty JSONL);
52
52
  * a kept dead list would suppress the empty guard that resumes tracking after an idle gap. */
53
53
  clearArchived: boolean;
54
+ /** This injection is the one-window rewrite offer; the caller persists the marker so a resume skips it. */
55
+ offerArchiveWindow: boolean;
54
56
  message: {
55
57
  customType: string;
56
58
  content: string;
@@ -68,6 +70,9 @@ export function planTodoReadHook(opts: {
68
70
  now: number;
69
71
  staleNotices: number;
70
72
  lastStaleGuardTurn: number | null;
73
+ /** Whether the near-archive rewrite window was already offered for this exact list (in-process or
74
+ * folded from the persisted marker). The terminal clear no longer waits for the shared cadence. */
75
+ archiveWindowOffered: boolean;
71
76
  }): TodoReadPlan {
72
77
  const msg = (content: string) => ({
73
78
  customType: TODO_READ_CUSTOM_TYPE,
@@ -83,6 +88,7 @@ export function planTodoReadHook(opts: {
83
88
  effect: 'empty-guard',
84
89
  clearArchived: false,
85
90
  archived: false,
91
+ offerArchiveWindow: false,
86
92
  message: msg(
87
93
  guardDue
88
94
  ? 'Your todo list is empty. If the current work needs tracking, call todo_write before stopping.'
@@ -102,30 +108,36 @@ export function planTodoReadHook(opts: {
102
108
  const c = countTodos(opts.items);
103
109
  const age = st.ageMs == null ? '' : ` ${formatAge(st.ageMs)} ago`;
104
110
  // Idle periods do not consume turns, so give one rewrite window with the old entries as reference
105
- // before the terminal notice.
106
- if (opts.staleNotices === 0 && guardDue) {
107
- const lines = opts.items.map((it) => ` ${todoMark(it.status)} ${it.content}`);
111
+ // before the terminal notice. The offer is keyed to the list identity and persisted (in-process it
112
+ // rides TodosService.archiveWindowOffered), so a resumed session goes straight to the clear.
113
+ if (!opts.archiveWindowOffered) {
114
+ if (!guardDue) {
115
+ return { inject: false, effect: 'none', archived: true, clearArchived: false, offerArchiveWindow: false, message: msg('') };
116
+ }
117
+ const lines = opts.items.map((it) => ` ${todoLine(it)}`);
108
118
  const head = `todos ✓${c.completed} (all completed, last updated${age}) — about to be archived`;
109
- const warn = 'One rewrite window before archiving: if the work you are doing now is multi-step, rewrite the full list with todo_write to track it (old entries below as reference — reuse what still applies). Single-step Q&A may skip tracking. If the list stays untouched, it will be archived and cleared on the next reminder.';
119
+ const warn = 'One rewrite window before archiving: if the work you are doing now is multi-step, rewrite the full list with todo_write to track it (old entries below as reference — reuse what still applies). Single-step Q&A may skip tracking. If the list stays untouched, it will be archived and cleared on your next message.';
110
120
  return {
111
121
  inject: true,
112
122
  effect: 'stale-notice',
113
123
  archived: true,
114
124
  clearArchived: false,
125
+ offerArchiveWindow: true,
115
126
  message: msg([head, ...lines, warn].join('\n')),
116
127
  };
117
128
  }
118
129
  // The terminal notice carries no details and no "skip tracking" escape hatch — multi-step work
119
- // must get a fresh list, single-step Q&A may proceed untracked.
130
+ // must get a fresh list, single-step Q&A may proceed untracked. It fires on the very next run:
131
+ // the near-archive already consumed the shared cadence tick, and waiting 4 more runs leaves the
132
+ // dead list authoritative with no todo guidance at all.
120
133
  return {
121
- inject: guardDue,
134
+ inject: true,
122
135
  effect: 'archive-notice',
123
136
  archived: true,
124
- clearArchived: guardDue,
137
+ clearArchived: true,
138
+ offerArchiveWindow: false,
125
139
  message: msg(
126
- guardDue
127
- ? `Your previous todo list (${c.completed} completed entries, last updated${age}) has been archived and cleared from tracking; the list is now empty. If the current work is multi-step, call todo_write with a fresh list now — multi-step work must be tracked. Single-step answers may proceed without a list.`
128
- : '',
140
+ `Your previous todo list (${c.completed} completed entries, last updated${age}) has been archived and cleared from tracking; the list is now empty. If the current work is multi-step, call todo_write with a fresh list now — multi-step work must be tracked. Single-step answers may proceed without a list.`,
129
141
  ),
130
142
  };
131
143
  }
@@ -134,18 +146,19 @@ export function planTodoReadHook(opts: {
134
146
  const staleDue = opts.lastStaleGuardTurn == null
135
147
  || opts.turn - opts.lastStaleGuardTurn >= EMPTY_GUARD_EVERY_N;
136
148
  if (!staleDue || opts.staleNotices >= STALE_NOTICE_MAX) {
137
- return { inject: false, effect: 'none', archived: false, clearArchived: false, message: msg('') };
149
+ return { inject: false, effect: 'none', archived: false, clearArchived: false, offerArchiveWindow: false, message: msg('') };
138
150
  }
139
151
  const c = countTodos(opts.items);
140
152
  const head = `todos ▶${c.inProgress} ○${c.pending} ■${c.blocked} ✓${c.completed}`
141
153
  + ` · unchanged for ${opts.turnsSinceWrite} turns, nothing open`;
142
- const lines = opts.items.map((it) => ` ${todoMark(it.status)} ${it.content}`);
154
+ const lines = opts.items.map((it) => ` ${todoLine(it)}`);
143
155
  const warn = 'This list no longer reflects the work you are doing. Rewrite the full list with todo_write to match current work, or send [] to clear it.';
144
156
  return {
145
157
  inject: true,
146
158
  effect: 'stale-notice',
147
159
  clearArchived: false,
148
160
  archived: false,
161
+ offerArchiveWindow: false,
149
162
  message: msg([head, ...lines, warn].join('\n')),
150
163
  };
151
164
  }
@@ -153,7 +166,7 @@ export function planTodoReadHook(opts: {
153
166
  // Fresh lists are recited every turn to keep current work visible.
154
167
  const c = countTodos(opts.items);
155
168
  const view = boundedView(opts.items, 6);
156
- const lines = view.visible.map((it) => `${todoMark(it.status)} ${it.content}`);
169
+ const lines = view.visible.map((it) => todoLine(it));
157
170
  const activity = currentActivity(opts.items);
158
171
  const head = `todos ▶${c.inProgress} ○${c.pending} ■${c.blocked} ✓${c.completed}`
159
172
  + (activity ? ` · ${activity}` : '');
@@ -162,6 +175,7 @@ export function planTodoReadHook(opts: {
162
175
  effect: 'recite',
163
176
  clearArchived: false,
164
177
  archived: false,
178
+ offerArchiveWindow: false,
165
179
  message: msg([head, ...lines].join('\n')),
166
180
  };
167
181
  }
@@ -1,10 +1,10 @@
1
1
  /**
2
2
  * Reminders about unfinished todos when stopping go out as sendMessage(custom) and ask for
3
3
  * reconciliation rather than continuation, so human-blocked items are a first-class outcome. Guards:
4
- * suppress on abort, cap reminders, skip while a subagent runs, a human gate is blocked, or no open
5
- * item remains.
4
+ * suppress on abort or provider error, cap reminders, skip while a subagent runs, a human gate is
5
+ * blocked, or no open item remains.
6
6
  */
7
- import { ABORT_STOP_REASON } from './settle-wake-core.ts';
7
+ import { ABORT_STOP_REASON, ERROR_STOP_REASON } from './settle-wake-core.ts';
8
8
  import { pierOption } from './pier-options.ts';
9
9
 
10
10
  /** Cap reminders for the lifetime of the process. */
@@ -25,6 +25,8 @@ export function todoReminderGraceMs(): number {
25
25
  interface TodoReminderInput {
26
26
  /** Stop reason of the last assistant turn (null = natural completion). */
27
27
  lastStopReason: string | null;
28
+ /** agent_settled.aborted: authoritative for ESC mid-tool, where no final assistant message set the reason. */
29
+ aborted?: boolean;
28
30
  intentionalAbort?: boolean;
29
31
  compactionInFlight?: boolean;
30
32
  reminders: number;
@@ -48,8 +50,10 @@ interface TodoReminderPlan {
48
50
  */
49
51
  export function planStopTodoReminder(input: TodoReminderInput): TodoReminderPlan {
50
52
  const noInject: TodoReminderPlan = { due: false, content: null, nextReminders: input.reminders };
51
- if (input.intentionalAbort || input.compactionInFlight) return noInject;
52
- if (input.lastStopReason === ABORT_STOP_REASON) return noInject;
53
+ if (input.intentionalAbort || input.compactionInFlight || input.aborted) return noInject;
54
+ // An error stop is an upstream/provider failure that pi retries; the reminder's triggerTurn would
55
+ // fire another request into the failing window (observed: 3 reminders during one outage).
56
+ if (input.lastStopReason === ABORT_STOP_REASON || input.lastStopReason === ERROR_STOP_REASON) return noInject;
53
57
  if (input.reminders >= REMINDERS_MAX) return noInject;
54
58
  if (input.runningSubs > 0) return noInject;
55
59
  if (input.blockedDepth > 0) return noInject;
@@ -20,6 +20,13 @@ export function formatTodoSummary(items: readonly TodoItem[]): string {
20
20
  return `todo: ${c.inProgress}▶ ${c.pending}○ ${c.blocked}■ ${c.completed}✓`;
21
21
  }
22
22
 
23
+ /** One item line shared by the widget/tree renderer and the read-hook recitation: the blocker text is
24
+ * part of the item (D43), not a widget-only decoration, so a resumed model keeps the reason. */
25
+ export function todoLine(it: TodoItem): string {
26
+ const suffix = it.status === 'blocked' && it.blocker ? ` — ${it.blocker}` : '';
27
+ return `${todoMark(it.status)} ${it.content}${suffix}`;
28
+ }
29
+
23
30
  /** Shared phase rendering so widget and overlay order entries identically. */
24
31
  export function renderTodoGroups(items: readonly TodoItem[]): string[] {
25
32
  const groups = new Map<string, TodoItem[]>();
@@ -32,10 +39,7 @@ export function renderTodoGroups(items: readonly TodoItem[]): string[] {
32
39
  const lines: string[] = [];
33
40
  for (const [phase, list] of groups) {
34
41
  if (phase) lines.push(` [${phase}]`);
35
- for (const it of list) {
36
- const suffix = it.status === 'blocked' && it.blocker ? ` — ${it.blocker}` : '';
37
- lines.push(` ${todoMark(it.status)} ${it.content}${suffix}`);
38
- }
42
+ for (const it of list) lines.push(` ${todoLine(it)}`);
39
43
  }
40
44
  return lines;
41
45
  }
@@ -18,6 +18,15 @@ export class TodosService extends EventEmitter {
18
18
  }
19
19
  /** Clock anchor for staleness/archiving; null until the first write. */
20
20
  lastWriteAt: number | null = null;
21
+ /** True when the anti-freeze rewrite window was offered for the current list (reached through the
22
+ * persisted marker on rebuild, so a resumed session skips straight to the terminal clear). */
23
+ private _archiveWindowOffered = false;
24
+ get archiveWindowOffered(): boolean {
25
+ return this._archiveWindowOffered;
26
+ }
27
+ markArchiveWindowOffered(): void {
28
+ this._archiveWindowOffered = true;
29
+ }
21
30
  readonly config: TodosConfig;
22
31
 
23
32
  constructor(config: TodosConfig) {
@@ -40,6 +49,7 @@ export class TodosService extends EventEmitter {
40
49
  if (listsEqual(this._items, next)) return { changed: false };
41
50
  const before = this._items;
42
51
  this._items = next;
52
+ this._archiveWindowOffered = false;
43
53
  this.lastWriteAt = Date.now();
44
54
  this.emitCompletedTransitions(before, this._items, opts?.source ?? 'tool');
45
55
  this.emit('todo.updated', { items: this._items });
@@ -51,6 +61,7 @@ export class TodosService extends EventEmitter {
51
61
  const next = applyTodoEdits(this._items, edits);
52
62
  if (listsEqual(before, next)) return { changed: false };
53
63
  this._items = next;
64
+ this._archiveWindowOffered = false;
54
65
  this.lastWriteAt = Date.now();
55
66
  this.emitCompletedTransitions(before, this._items, opts?.source ?? 'reconcile');
56
67
  this.emit('todo.edited', { edits, items: this._items });
@@ -74,6 +85,7 @@ export class TodosService extends EventEmitter {
74
85
  const folded = foldLatestTodosMeta(entries as Parameters<typeof foldLatestTodosMeta>[0]);
75
86
  this._items = folded ? folded.items : [];
76
87
  this.lastWriteAt = folded ? folded.writtenAt : null;
88
+ this._archiveWindowOffered = folded ? folded.archiveWindowOffered : false;
77
89
  this.emit('todo.updated', { items: this._items, reason: 'rebuild' });
78
90
  }
79
91
  }