pi-pier 0.2.6 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +20 -7
  2. package/package.json +7 -2
  3. package/schemas/efficiency-config.schema.json +77 -0
  4. package/skills/pi-herdr-workflow/SKILL.md +13 -1
  5. package/src/ansi-text.ts +83 -0
  6. package/src/ask-multi.ts +260 -0
  7. package/src/ask-user.ts +458 -0
  8. package/src/bootstrap.ts +5 -2
  9. package/src/compact-coordinator.ts +454 -0
  10. package/src/compact-economics-core.ts +396 -0
  11. package/src/config-catalog-core.ts +452 -0
  12. package/src/config-command.ts +181 -0
  13. package/src/config-guide.ts +361 -0
  14. package/src/core/grid-shape.ts +4 -2
  15. package/src/core/heat-plan.ts +160 -0
  16. package/src/core/observation.ts +555 -0
  17. package/src/core/subagent.ts +187 -393
  18. package/src/core/terminal.ts +270 -47
  19. package/src/core/todo.ts +49 -14
  20. package/src/dashboard-command.ts +89 -0
  21. package/src/efficiency-config-core.ts +661 -0
  22. package/src/efficiency-config-io.ts +53 -0
  23. package/src/efficiency-store.ts +312 -0
  24. package/src/focus-poller.ts +244 -0
  25. package/src/gc-core.ts +85 -0
  26. package/src/git-adapter.ts +12 -3
  27. package/src/herdr-client.ts +292 -36
  28. package/src/index-locks.ts +27 -9
  29. package/src/index-master.ts +5 -0
  30. package/src/index-notices.ts +13 -2
  31. package/src/index-pipe.ts +5 -0
  32. package/src/index-worker.ts +5 -0
  33. package/src/index.ts +360 -60
  34. package/src/jev-client.ts +146 -0
  35. package/src/jev-core.ts +381 -0
  36. package/src/lock-core.ts +105 -16
  37. package/src/observation-core.ts +248 -0
  38. package/src/pier-options.ts +96 -0
  39. package/src/pipe-channel.ts +52 -7
  40. package/src/reducer-core.ts +358 -0
  41. package/src/reducer-invoker.ts +392 -0
  42. package/src/renderers.ts +198 -0
  43. package/src/role-loader.ts +2 -1
  44. package/src/role-manifest.ts +0 -4
  45. package/src/runtime-policy.ts +5 -8
  46. package/src/session-tail.ts +60 -5
  47. package/src/settle-wake-core.ts +3 -1
  48. package/src/slim-frame.ts +60 -3
  49. package/src/storage-layout.ts +23 -0
  50. package/src/subagent-core.ts +3 -1
  51. package/src/subagent-execute.ts +328 -0
  52. package/src/subagent-gc.ts +33 -13
  53. package/src/subagent-launch.ts +4 -0
  54. package/src/subagent-list-action.ts +69 -0
  55. package/src/subagent-output-action.ts +104 -0
  56. package/src/subagent-output-adapter.ts +27 -0
  57. package/src/subagent-output-core.ts +263 -0
  58. package/src/subagent-poll-loop.ts +59 -39
  59. package/src/subagent-poller.ts +46 -0
  60. package/src/subagent-ready.ts +89 -0
  61. package/src/subagent-registry.ts +70 -0
  62. package/src/subagent-resolution.ts +41 -0
  63. package/src/subagent-scope.ts +3 -1
  64. package/src/subagent-session-io.ts +115 -32
  65. package/src/subagent-spawn.ts +153 -8
  66. package/src/swallow.ts +75 -0
  67. package/src/terminal-core.ts +161 -10
  68. package/src/todo-core.ts +5 -4
  69. package/src/todo-reminder-core.ts +8 -1
  70. package/src/todos-service.ts +18 -6
  71. package/src/tool-error.ts +26 -0
  72. package/src/vocab.ts +7 -4
@@ -1,4 +1,5 @@
1
1
  /** Why: Preserve the established compatibility and safety behavior (D71, M14, T1–T6, T3, T6). */
2
+ import { pierOption } from './pier-options.ts';
2
3
  import { basename } from 'node:path';
3
4
 
4
5
  /** Why: Preserve the established compatibility and safety behavior. */
@@ -10,8 +11,13 @@ export const READ_MAX_CHARS = 8000;
10
11
  export const READ_HARD_CAP_CHARS = 200;
11
12
  export const READINESS_TIMEOUT_MS = 30_000;
12
13
  export const SILENCE_IDLE_MS = 2000;
13
- /** POSIX / PowerShell / Nushell prompt tail. Why: herdr waitForOutput and local classify must share one pattern. */
14
- export const PROMPT_TAIL_RE = /[$>#❯]\s*$/;
14
+ /** POSIX / PowerShell / Nushell / Zsh prompt tail.
15
+ * Why: macOS default zsh prompt ends with `%` (e.g. `user@host ~ % `), while sh/bash use `$`
16
+ * (or `#` for root) and nushell/starship use `❯`. Anchoring to line tail (\s*$) prevents false
17
+ * matches on percentage values in ordinary command output (e.g. `downloaded 50%`), as interactive
18
+ * prompts terminate at the prompt symbol without trailing text.
19
+ */
20
+ export const PROMPT_TAIL_RE = /[$>#❯%]\s*$/;
15
21
 
16
22
  export interface PromptStrategy {
17
23
  /** herdr waitForOutput regex source. */
@@ -20,19 +26,41 @@ export interface PromptStrategy {
20
26
  }
21
27
 
22
28
  export const POSIX_PROMPT: PromptStrategy = {
23
- waitPattern: '[$>#❯]\\s*$',
29
+ waitPattern: '[$>#❯%]\\s*$',
24
30
  tailRe: PROMPT_TAIL_RE,
25
31
  };
26
32
 
33
+ export const BASH_PROMPT: PromptStrategy = POSIX_PROMPT;
34
+ export const ZSH_PROMPT: PromptStrategy = POSIX_PROMPT;
35
+
27
36
  /** cmd.exe / PowerShell: `>` still matches POSIX; this also accepts `PS C:\\>` without a trailing `$`. */
28
37
  export const POWERSHELL_PROMPT: PromptStrategy = {
29
38
  waitPattern: '(PS [^\\n>]+|>)\\s*$',
30
39
  tailRe: /(PS [^\n>]+|>)\s*$/,
31
40
  };
32
41
 
33
- /** `PIER_TERMINAL_PROMPT=powershell` selects the Windows strategy; default stays POSIX so existing panes do not flip. */
42
+ export const PWSH_PROMPT: PromptStrategy = POWERSHELL_PROMPT; // same object: identity checks stay valid
43
+
44
+ /**
45
+ * Strategy selection covering bash, zsh, powershell/pwsh.
46
+ * Explicit `PIER_TERMINAL_PROMPT` env var takes precedence; otherwise `SHELL` is inspected.
47
+ * Default remains POSIX so existing Windows/Linux panes without explicit configuration do not flip.
48
+ */
34
49
  export function promptStrategyFor(env: NodeJS.ProcessEnv = process.env): PromptStrategy {
35
- return env.PIER_TERMINAL_PROMPT === 'powershell' ? POWERSHELL_PROMPT : POSIX_PROMPT;
50
+ // B10: canonical PIER_TERMINAL_PROMPT, legacy PI_HERDR_TERMINAL_PROMPT alias.
51
+ const prompt = pierOption('PIER_TERMINAL_PROMPT', env)?.trim().toLowerCase();
52
+ if (prompt === 'powershell' || prompt === 'pwsh') return POWERSHELL_PROMPT;
53
+ if (prompt === 'bash') return BASH_PROMPT;
54
+ if (prompt === 'zsh') return ZSH_PROMPT;
55
+ if (!prompt && env.SHELL) {
56
+ const sh = basename(env.SHELL.replace(/\\/g, '/')).toLowerCase();
57
+ if (sh === 'pwsh' || sh === 'powershell' || sh === 'powershell.exe' || sh === 'pwsh.exe') {
58
+ return POWERSHELL_PROMPT;
59
+ }
60
+ if (sh === 'bash') return BASH_PROMPT;
61
+ if (sh === 'zsh') return ZSH_PROMPT;
62
+ }
63
+ return POSIX_PROMPT;
36
64
  }
37
65
  /** Why: Preserve the established compatibility and safety behavior (T6). */
38
66
  export const SIGNAL_KEYS = ['ctrl+c', 'ctrl+d', 'ctrl+z', 'esc', 'enter'] as const;
@@ -57,13 +85,15 @@ export interface TerminalEntry {
57
85
  label: string | null;
58
86
  createdAt: number;
59
87
  lastActivityAt: number;
88
+ /** Idle-nudge bookkeeping: set when this terminal has been nudged once (per-entry, persisted). */
89
+ nudgedAt: number | null;
60
90
  status: 'open' | 'closed';
61
91
  closedAt: number | null;
62
- /** Why: Preserve the established compatibility and safety behavior. */
63
- readRevision: number | null;
64
- readLen: number;
65
- readTail: string;
66
- readEoTail: string;
92
+ readRevision?: number | null;
93
+ readLen?: number;
94
+ readTail?: string;
95
+ readEoTail?: string;
96
+ initialized?: boolean;
67
97
  }
68
98
 
69
99
  export interface TerminalsRegistry {
@@ -107,6 +137,7 @@ export function registerTerminal(
107
137
  label: opts.label ?? (opts.cwd ? basename(opts.cwd.replace(/\\/g, '/')) || opts.cwd : null),
108
138
  createdAt: opts.createdAt,
109
139
  lastActivityAt: opts.createdAt,
140
+ nudgedAt: null,
110
141
  status: 'open',
111
142
  closedAt: null,
112
143
  readRevision: null,
@@ -302,6 +333,43 @@ export function makeTerminalsRegistry(terminals: TerminalEntry[] = []): Terminal
302
333
  return { version: 1, terminals };
303
334
  }
304
335
 
336
+ /**
337
+ * Why: In interactive bash and zsh, history expansion (`!`) is active by default. Commands
338
+ * containing `!` (such as `!|` or `if [ ! -f ... ]` or `!$`) trigger history expansion
339
+ * (e.g. `zsh: event not found: \|`), wedging the managed shell and leaving the controller
340
+ * waiting indefinitely (observed in session 01a08f37).
341
+ * We disable history expansion for managed shells right after creation via `set +H` (supported
342
+ * by both bash and zsh; equivalent to `unsetopt BANG_HIST` in zsh).
343
+ * On Windows / PowerShell, `set +H` is unrecognized syntax and PowerShell does not use `!`
344
+ * for history expansion, so init is skipped.
345
+ *
346
+ * Limits: `set +H` only affects the top-level persistent shell process. If a command spawns
347
+ * a nested interactive subshell (e.g. `bash -i`), that subshell would re-enable its default options.
348
+ */
349
+ export function shellInitCommandFor(strategy: PromptStrategy = POSIX_PROMPT): string | null {
350
+ return strategy === POWERSHELL_PROMPT ? null : 'set +H';
351
+ }
352
+
353
+ export interface ShellInitPlan {
354
+ readonly shouldInit: boolean;
355
+ readonly command: string | null;
356
+ }
357
+
358
+ export function planShellInit(opts: {
359
+ strategy?: PromptStrategy;
360
+ readiness?: ReadinessTier;
361
+ initialized?: boolean;
362
+ }): ShellInitPlan {
363
+ if (opts.initialized) return { shouldInit: false, command: null };
364
+ const strategy = opts.strategy ?? POSIX_PROMPT;
365
+ const cmd = shellInitCommandFor(strategy);
366
+ if (!cmd) return { shouldInit: false, command: null };
367
+ if (opts.readiness !== undefined && opts.readiness !== 'prompt') {
368
+ return { shouldInit: false, command: null };
369
+ }
370
+ return { shouldInit: true, command: cmd };
371
+ }
372
+
305
373
  export function foldTerminalsRegistry(entries: readonly BranchEntryLike3[]): TerminalEntry[] {
306
374
  let found: TerminalEntry[] = [];
307
375
  for (const entry of entries) {
@@ -318,13 +386,96 @@ export function foldTerminalsRegistry(entries: readonly BranchEntryLike3[]): Ter
318
386
  label: typeof t.label === 'string' ? t.label : null,
319
387
  createdAt: typeof t.createdAt === 'number' ? t.createdAt : 0,
320
388
  lastActivityAt: typeof t.lastActivityAt === 'number' ? t.lastActivityAt : 0,
389
+ nudgedAt: typeof t.nudgedAt === 'number' ? t.nudgedAt : null,
321
390
  status: t.status === 'closed' ? ('closed' as const) : ('open' as const),
322
391
  closedAt: typeof t.closedAt === 'number' ? t.closedAt : null,
323
392
  readRevision: typeof t.readRevision === 'number' ? t.readRevision : null,
324
393
  readLen: typeof t.readLen === 'number' ? t.readLen : 0,
325
394
  readTail: typeof t.readTail === 'string' ? t.readTail : '',
326
395
  readEoTail: typeof t.readEoTail === 'string' ? t.readEoTail : '',
396
+ initialized: t.initialized === true,
327
397
  }));
328
398
  }
329
399
  return found;
330
400
  }
401
+
402
+ /* ── Idle-terminal nudge: turn-end self-cleanup for shells the model forgot to close ──
403
+ * Observed (wF:p7 orphan): a master opened a resident terminal for a one-shot background
404
+ * compile, the job finished, and nothing ever closed the pane — terminals are persistent by
405
+ * design, so the shell lingered as a dead split in the main tab. Decision core mirrors the
406
+ * todo stop-reminder: pure plan here, delivery wiring in core/terminal. */
407
+
408
+ /** Cap idle-terminal nudges for the lifetime of the process (same shape as TODO_REMINDERS_MAX). */
409
+ export const TERM_REMINDERS_MAX = 2;
410
+
411
+ /** Idle threshold before a nudge is due; PI_HERDR_TERM_IDLE_MS is read per call so tests can use small values. */
412
+ export function terminalIdleMs(): number {
413
+ // B10: canonical PIER_TERM_IDLE_MS, legacy PI_HERDR_TERM_IDLE_MS.
414
+ return Number(pierOption('PIER_TERM_IDLE_MS') ?? 30 * 60_000) || 30 * 60_000;
415
+ }
416
+
417
+ /** Grace from agent_settled to injection, letting the user read the answer and intervene; cancel on agent_start. */
418
+ export function terminalReminderGraceMs(): number {
419
+ return Number(pierOption('PIER_TERM_GRACE_MS') ?? 30_000) || 30_000;
420
+ }
421
+
422
+ /** Custom message type for the idle-terminal nudge (registerMessageRenderer may restyle it). */
423
+ export const TERM_REMINDER_CUSTOM_TYPE = 'pi-herdr.term-reminder';
424
+
425
+ export interface IdleTerminalInput {
426
+ terminalId: string;
427
+ cwd: string;
428
+ label: string | null;
429
+ lastActivityAt: number;
430
+ /** Set once this terminal has been nudged; a nudged terminal is never nudged again (01a06ae3 goodbye-loop guard). */
431
+ nudgedAt?: number | null;
432
+ }
433
+
434
+ export interface IdleTerminalReminderPlan {
435
+ /** Whether a nudge should be injected. */
436
+ due: boolean;
437
+ /** Complete injected content, or null when due is false. */
438
+ content: string | null;
439
+ /** Nudge count after successful injection, or the original count when due is false. */
440
+ nextReminders: number;
441
+ /** Terminal ids this nudge covers; the caller marks them nudged at schedule time. */
442
+ ids: string[];
443
+ }
444
+
445
+ function noIdleInject(reminders: number): IdleTerminalReminderPlan {
446
+ return { due: false, content: null, nextReminders: reminders, ids: [] };
447
+ }
448
+
449
+ /**
450
+ * Due only when an OPEN terminal is idle past the threshold AND has never been nudged
451
+ * (01a06ae3: a per-process counter alone let nudges resume after restarts and looped a
452
+ * farewell exchange 52 times — a nudged terminal must stay nudged, persisted in the ledger).
453
+ * Shells still hosting long-running work were touched recently, so the idle filter is what
454
+ * keeps legitimate dev-server terminals out of the nudge.
455
+ */
456
+ export function planIdleTerminalReminder(
457
+ input: { open: readonly IdleTerminalInput[]; now: number; reminders: number },
458
+ ): IdleTerminalReminderPlan {
459
+ const idleMs = terminalIdleMs();
460
+ const idle = input.open.filter(
461
+ (t) => !t.nudgedAt && input.now - t.lastActivityAt >= idleMs,
462
+ );
463
+ if (idle.length === 0 || input.reminders >= TERM_REMINDERS_MAX) {
464
+ return noIdleInject(input.reminders);
465
+ }
466
+ const list = idle.map((t) => `- ${t.terminalId} (${t.label ?? t.cwd})`).join('\n');
467
+ const content = [
468
+ `<system-reminder>Open terminal(s) idle for a while (nudge ${input.reminders + 1}/${TERM_REMINDERS_MAX}):`,
469
+ list,
470
+ 'This is an internal housekeeping notice — do NOT reply to the user and do NOT send any farewell on this notice.',
471
+ 'If the work in them is done, close them now with terminal(action: "close", terminal_id: "…").',
472
+ 'Keep a terminal only if a long-running process still needs that shell.',
473
+ '</system-reminder>',
474
+ ].join('\n');
475
+ return {
476
+ due: true,
477
+ content,
478
+ nextReminders: input.reminders + 1,
479
+ ids: idle.map((t) => t.terminalId),
480
+ };
481
+ }
package/src/todo-core.ts CHANGED
@@ -171,8 +171,9 @@ export function revertedCompleted(prev: readonly TodoItem[], next: readonly Todo
171
171
  }
172
172
 
173
173
  /** Why: Preserve the established compatibility and safety behavior. */
174
- export function makeSnapshot(items: TodoItem[]): TodoSnapshot {
175
- return { version: 1, items };
174
+ export function makeSnapshot(items: readonly TodoItem[]): TodoSnapshot {
175
+ // Copy: the snapshot is persisted, the service list stays owned by TodosService.
176
+ return { version: 1, items: [...items] };
176
177
  }
177
178
 
178
179
  export interface BranchEntryLike {
@@ -263,7 +264,7 @@ export function foldLatestTodosMeta(entries: readonly BranchEntryLike[]): Folded
263
264
  let writtenAt: number | null = null;
264
265
  for (const entry of entries) {
265
266
  if (entry.type === 'message') {
266
- const msg = entry.message;
267
+ const msg = entry.message as { role?: unknown; toolName?: unknown; details?: unknown } | null | undefined;
267
268
  if (typeof msg !== 'object' || msg === null) continue;
268
269
  if (msg.role !== 'toolResult' || msg.toolName !== TODO_TOOL_NAME) continue;
269
270
  const snapshot = extractSnapshotFromDetails(msg.details);
@@ -338,7 +339,7 @@ export function boundedView(items: readonly TodoItem[], budget: number): Bounded
338
339
  }
339
340
 
340
341
  /** Why: Preserve the established compatibility and safety behavior. */
341
- export function currentActivity(items: TodoItem[]): string | null {
342
+ export function currentActivity(items: readonly TodoItem[]): string | null {
342
343
  return items.find((it) => it.status === 'in_progress')?.content ?? null;
343
344
  }
344
345
 
@@ -17,6 +17,7 @@
17
17
  * running, a human gate is blocked, or no open items remain.
18
18
  */
19
19
  import { ABORT_STOP_REASON } from './settle-wake-core.ts';
20
+ import { pierOption } from './pier-options.ts';
20
21
 
21
22
  /** Cap reminders for the lifetime of the process, matching the previous behavior. */
22
23
  export const TODO_REMINDERS_MAX = 3;
@@ -31,12 +32,17 @@ export const TODO_REMINDER_CUSTOM_TYPE = 'pi-herdr.todo-reminder';
31
32
  * can use a small value rather than freezing configuration at module load.
32
33
  */
33
34
  export function todoReminderGraceMs(): number {
34
- return Number(process.env.PI_HERDR_TODO_GRACE_MS ?? 30_000) || 30_000;
35
+ // B10: canonical PIER_TODO_GRACE_MS, legacy PI_HERDR_TODO_GRACE_MS.
36
+ return Number(pierOption('PIER_TODO_GRACE_MS') ?? 30_000) || 30_000;
35
37
  }
36
38
 
37
39
  export interface TodoReminderInput {
38
40
  /** Stop reason of the last assistant turn before this settlement (null means treat it as natural completion). */
39
41
  lastStopReason: string | null;
42
+ /** True when abort was triggered intentionally by internal mechanisms like OCC. */
43
+ intentionalAbort?: boolean;
44
+ /** True when compaction is currently in-flight. */
45
+ compactionInFlight?: boolean;
40
46
  /** Number of reminders already injected. */
41
47
  reminders: number;
42
48
  /** Number of running background subagents; a positive value means the master is already waiting. */
@@ -67,6 +73,7 @@ function noInject(reminders: number): TodoReminderPlan {
67
73
  * (the missing guard in the 01a040cc incident).
68
74
  */
69
75
  export function planStopTodoReminder(input: TodoReminderInput): TodoReminderPlan {
76
+ if (input.intentionalAbort || input.compactionInFlight) return noInject(input.reminders);
70
77
  if (input.lastStopReason === ABORT_STOP_REASON) return noInject(input.reminders);
71
78
  if (input.reminders >= TODO_REMINDERS_MAX) return noInject(input.reminders);
72
79
  if (input.runningSubs > 0) return noInject(input.reminders);
@@ -15,6 +15,14 @@ export interface TodosConfig {
15
15
  allowParallelInProgress: boolean;
16
16
  }
17
17
 
18
+ export type TodoCompletionSource = 'tool' | 'reconcile' | 'human' | 'archive';
19
+
20
+ export interface TodoCompletedEvent {
21
+ count: number;
22
+ at: number;
23
+ source: TodoCompletionSource;
24
+ }
25
+
18
26
  export class TodosService extends EventEmitter {
19
27
  private _items: TodoItem[] = [];
20
28
  /**
@@ -45,33 +53,37 @@ export class TodosService extends EventEmitter {
45
53
  return this._items.map((it) => ({ ...it }));
46
54
  }
47
55
 
48
- replace(items: readonly TodoItem[]): { changed: boolean } {
56
+ replace(items: readonly TodoItem[], opts?: { source?: TodoCompletionSource }): { changed: boolean } {
49
57
  const next = items.map((it) => ({ ...it }));
50
58
  if (listsEqual(this._items, next)) return { changed: false };
51
59
  const before = this._items;
52
60
  this._items = next;
53
61
  this.lastWriteAt = Date.now();
54
- this.emitCompletedTransitions(before, this._items);
62
+ this.emitCompletedTransitions(before, this._items, opts?.source ?? 'tool');
55
63
  this.emit('todo.updated', { items: this._items });
56
64
  return { changed: true };
57
65
  }
58
66
 
59
- applyEdits(edits: readonly TodoEdit[]): { changed: boolean } {
67
+ applyEdits(edits: readonly TodoEdit[], opts?: { source?: TodoCompletionSource }): { changed: boolean } {
60
68
  const before = this._items;
61
69
  const next = applyTodoEdits(this._items, edits);
62
70
  if (listsEqual(before, next)) return { changed: false };
63
71
  this._items = next;
64
72
  this.lastWriteAt = Date.now();
65
- this.emitCompletedTransitions(before, this._items);
73
+ this.emitCompletedTransitions(before, this._items, opts?.source ?? 'reconcile');
66
74
  this.emit('todo.edited', { edits, items: this._items });
67
75
  this.emit('todo.updated', { items: this._items });
68
76
  return { changed: true };
69
77
  }
70
78
 
71
79
  /** Why: Preserve the established compatibility and safety behavior (M16). */
72
- private emitCompletedTransitions(before: readonly TodoItem[], after: readonly TodoItem[]): void {
80
+ private emitCompletedTransitions(
81
+ before: readonly TodoItem[],
82
+ after: readonly TodoItem[],
83
+ source: TodoCompletionSource = 'tool',
84
+ ): void {
73
85
  const count = countCompletedTransitions(before, after);
74
- if (count > 0) this.emit('todo.completed', { count, at: Date.now() });
86
+ if (count > 0) this.emit('todo.completed', { count, at: Date.now(), source });
75
87
  }
76
88
 
77
89
  rebuild(entries: readonly unknown[]): void {
@@ -0,0 +1,26 @@
1
+ /**
2
+ * A1: pi only marks a tool result as failed when `execute()` throws — "Returning a value never sets
3
+ * the error flag regardless of what properties you include in the return object"
4
+ * (pi docs/extensions.md, "Signaling errors").
5
+ *
6
+ * The round-2 audit found 20+ real failures in pier's tools returned as plain text, so neither the
7
+ * model nor pi's own hooks keyed on `event.isError` could see them (pier's B1 "subagent alive but
8
+ * reported no output" self-heal was dead code for exactly that reason). Every hard failure now goes
9
+ * through `toolError()`; deliberate non-failures (empty result set, "no match within Xms", a pane
10
+ * that simply has not produced output yet) stay normal results.
11
+ *
12
+ * The "Error: " prefix the old text returns carried is dropped: the provider's tool-result error
13
+ * channel and pi's red rendering already mark it, and keeping it would double the prefix once pi
14
+ * formats a thrown error.
15
+ */
16
+ export class ToolError extends Error {
17
+ constructor(message: string) {
18
+ super(String(message).replace(/^Error:\s*/, ''));
19
+ this.name = 'ToolError';
20
+ }
21
+ }
22
+
23
+ /** Throw a tool failure (never returns). */
24
+ export function toolError(message: string): never {
25
+ throw new ToolError(message);
26
+ }
package/src/vocab.ts CHANGED
@@ -48,13 +48,16 @@ export const TODO_TOOL_NAME = 'todo_write';
48
48
  /** Snapshot key in tool-result details (persisted in session JSONL). */
49
49
  export const TODO_DETAILS_KEY = 'pi-herdr.todo';
50
50
 
51
- /** Confirmation copy (byte-aligned with DSH so model vocab transfers). */
52
- export function formatTodoConfirmation(items: TodoItem[]): string {
51
+ /** Confirmation copy (byte-aligned with DSH so model vocab transfers; blocked is appended only when present). */
52
+ export function formatTodoConfirmation(items: readonly TodoItem[]): string {
53
53
  const c = countTodos(items);
54
- return `Updated todo list: ${c.pending} pending, ${c.inProgress} in progress, ${c.completed} completed.`;
54
+ const base = `Updated todo list: ${c.pending} pending, ${c.inProgress} in progress, ${c.completed} completed.`;
55
+ // A4: blocked items used to be invisible here (the model read "nothing pending" while items sat
56
+ // blocked waiting on a human). Appending only when non-zero keeps the DSH-aligned wording intact.
57
+ return c.blocked > 0 ? `${base.slice(0, -1)}, ${c.blocked} blocked.` : base;
55
58
  }
56
59
 
57
- export function countTodos(items: TodoItem[]): TodoCounts {
60
+ export function countTodos(items: readonly TodoItem[]): TodoCounts {
58
61
  let pending = 0;
59
62
  let inProgress = 0;
60
63
  let completed = 0;