pi-pier 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/README.md +16 -12
  2. package/package.json +3 -3
  3. package/schemas/role-manifest.schema.json +7 -1
  4. package/skills/pi-herdr-workflow/SKILL.md +4 -5
  5. package/src/ansi-text.ts +62 -11
  6. package/src/ask-dialog.ts +308 -0
  7. package/src/ask-user.ts +113 -112
  8. package/src/bootstrap.ts +10 -13
  9. package/src/compact-coordinator.ts +279 -261
  10. package/src/compact-economics-core.ts +77 -145
  11. package/src/config-catalog-core.ts +77 -197
  12. package/src/config-command.ts +271 -64
  13. package/src/cordis-services.ts +64 -0
  14. package/src/dashboard-command.ts +77 -33
  15. package/src/delegation-core.ts +115 -0
  16. package/src/efficiency-config-core.ts +286 -486
  17. package/src/efficiency-store.ts +16 -29
  18. package/src/gc-core.ts +10 -36
  19. package/src/guards.ts +6 -0
  20. package/src/herdr-client.ts +202 -233
  21. package/src/history-store.ts +38 -23
  22. package/src/index-gates.ts +118 -0
  23. package/src/index-locks.ts +2 -7
  24. package/src/index-master.ts +31 -29
  25. package/src/index-notices.ts +28 -15
  26. package/src/index-pipe.ts +31 -12
  27. package/src/index-roles.ts +306 -0
  28. package/src/index-runtime.ts +16 -6
  29. package/src/index-worker.ts +8 -18
  30. package/src/index.ts +561 -570
  31. package/src/jev-client.ts +12 -17
  32. package/src/jev-core.ts +138 -90
  33. package/src/ledger.ts +13 -21
  34. package/src/lock-core.ts +28 -56
  35. package/src/manifest-compose.ts +35 -20
  36. package/src/observation-core.ts +52 -100
  37. package/src/pane-title.ts +19 -38
  38. package/src/panel-open.ts +47 -0
  39. package/src/pi-surface.ts +34 -22
  40. package/src/pier-options.ts +53 -50
  41. package/src/pipe-channel.ts +44 -59
  42. package/src/platform-paths.ts +19 -52
  43. package/src/{core → plugins}/grid-shape.ts +8 -10
  44. package/src/{core → plugins}/heat-plan.ts +20 -22
  45. package/src/plugins/observation.ts +475 -0
  46. package/src/plugins/subagent.ts +766 -0
  47. package/src/{core → plugins}/terminal.ts +198 -139
  48. package/src/{core → plugins}/todo.ts +73 -134
  49. package/src/progress-core.ts +16 -53
  50. package/src/reconcile-core.ts +24 -33
  51. package/src/reducer-core.ts +26 -64
  52. package/src/reducer-invoker.ts +266 -239
  53. package/src/renderers.ts +108 -84
  54. package/src/role-loader.ts +33 -28
  55. package/src/role-manifest.ts +25 -31
  56. package/src/role-state.ts +119 -0
  57. package/src/roles/worker-default.json +3 -3
  58. package/src/routing-telemetry.ts +136 -0
  59. package/src/runtime-policy.ts +25 -40
  60. package/src/session-tail.ts +65 -41
  61. package/src/settle-wake-core.ts +61 -22
  62. package/src/slim-frame.ts +25 -122
  63. package/src/stale-core.ts +17 -24
  64. package/src/storage-layout.ts +14 -43
  65. package/src/subagent-core.ts +423 -120
  66. package/src/subagent-gc.ts +109 -67
  67. package/src/subagent-poller.ts +383 -24
  68. package/src/subagent-session.ts +516 -0
  69. package/src/subagent-spawn.ts +577 -52
  70. package/src/swallow.ts +7 -17
  71. package/src/terminal-core.ts +187 -134
  72. package/src/todo-core.ts +57 -109
  73. package/src/todo-read-hook.ts +46 -39
  74. package/src/todo-reminder-core.ts +26 -51
  75. package/src/todo-window.ts +11 -12
  76. package/src/todos-service.ts +15 -35
  77. package/src/tool-error.ts +6 -13
  78. package/src/tool-gate.ts +38 -21
  79. package/src/tree-view.ts +162 -0
  80. package/src/vocab.ts +52 -25
  81. package/src/worker-marker.ts +76 -0
  82. package/src/ask-multi.ts +0 -260
  83. package/src/config-guide.ts +0 -361
  84. package/src/core/observation.ts +0 -555
  85. package/src/core/subagent.ts +0 -633
  86. package/src/efficiency-config-io.ts +0 -53
  87. package/src/focus-poller.ts +0 -244
  88. package/src/git-adapter.ts +0 -113
  89. package/src/notice-buffer.ts +0 -23
  90. package/src/subagent-execute.ts +0 -328
  91. package/src/subagent-git-io.ts +0 -80
  92. package/src/subagent-launch.ts +0 -114
  93. package/src/subagent-list-action.ts +0 -69
  94. package/src/subagent-output-action.ts +0 -104
  95. package/src/subagent-output-adapter.ts +0 -27
  96. package/src/subagent-output-core.ts +0 -263
  97. package/src/subagent-poll-loop.ts +0 -305
  98. package/src/subagent-port.ts +0 -27
  99. package/src/subagent-ready.ts +0 -89
  100. package/src/subagent-registry.ts +0 -70
  101. package/src/subagent-resolution.ts +0 -41
  102. package/src/subagent-scope.ts +0 -41
  103. package/src/subagent-session-io.ts +0 -230
@@ -1,11 +1,8 @@
1
1
  /**
2
- * Activity-anchored todo window used by the pi widget and the slim overlay.
3
- *
4
- * Why: both surfaces must keep the in-progress row visible instead of a fixed
5
- * head/tail slice. The widget budgets unwrapped render lines; the overlay
6
- * supplies its own `fits` so wrapping can consume extra rows.
2
+ * Activity-anchored todo window for the pi widget and the slim overlay: the widget passes a budget of
3
+ * unwrapped render lines, the overlay supplies its own `fits`; the in-progress row stays visible.
7
4
  */
8
- import { countTodos, type TodoItem, type TodoStatus } from './vocab.ts';
5
+ import { countTodos, isTodoStatus, type TodoItem, type TodoStatus } from './vocab.ts';
9
6
 
10
7
  export const TODO_MARKS: Record<TodoStatus, string> = {
11
8
  pending: '○',
@@ -15,12 +12,15 @@ export const TODO_MARKS: Record<TodoStatus, string> = {
15
12
  abandoned: '✗',
16
13
  };
17
14
 
15
+ /** Mark for a snapshot's status, tolerating corrupt data (`?` instead of a prototype member). */
16
+ export const todoMark = (status: unknown): string => (isTodoStatus(status) ? TODO_MARKS[status] : '?');
17
+
18
18
  export function formatTodoSummary(items: readonly TodoItem[]): string {
19
- const c = countTodos(items as TodoItem[]);
19
+ const c = countTodos(items);
20
20
  return `todo: ${c.inProgress}▶ ${c.pending}○ ${c.blocked}■ ${c.completed}✓`;
21
21
  }
22
22
 
23
- /** Share phase rendering so widget and overlay order entries identically. */
23
+ /** Shared phase rendering so widget and overlay order entries identically. */
24
24
  export function renderTodoGroups(items: readonly TodoItem[]): string[] {
25
25
  const groups = new Map<string, TodoItem[]>();
26
26
  for (const it of items) {
@@ -34,16 +34,15 @@ export function renderTodoGroups(items: readonly TodoItem[]): string[] {
34
34
  if (phase) lines.push(` [${phase}]`);
35
35
  for (const it of list) {
36
36
  const suffix = it.status === 'blocked' && it.blocker ? ` — ${it.blocker}` : '';
37
- lines.push(` ${TODO_MARKS[it.status]} ${it.content}${suffix}`);
37
+ lines.push(` ${todoMark(it.status)} ${it.content}${suffix}`);
38
38
  }
39
39
  }
40
40
  return lines;
41
41
  }
42
42
 
43
43
  /**
44
- * Expand around the active anchor while `fits(start, end)` remains true.
45
- * Prefers upcoming work; the anchor stays visible. All-completed lists keep
46
- * the original head-shrink (widget-compatible).
44
+ * Expand around the active anchor while `fits(start, end)` holds: the first in_progress item, else the
45
+ * last open one, growing toward the tail first. All-completed lists shrink from the head instead.
47
46
  */
48
47
  export function anchorTodoRange(
49
48
  items: readonly TodoItem[],
@@ -1,13 +1,6 @@
1
- /** Why: Preserve the established compatibility and safety behavior (D65, D75). */
1
+ /** Single owner of todo state; every mutation path (tool, human edit, M17 reconcile, archive) lands here (D65, D75). */
2
2
  import { EventEmitter } from 'node:events';
3
- import {
4
- applyTodoEdits,
5
- foldLatestTodosMeta,
6
- listsEqual,
7
- type TodoEdit,
8
- type TodoItem,
9
- } from './todo-core.ts';
10
- import { countCompletedTransitions } from './progress-core.ts';
3
+ import { applyTodoEdits, completionTransitions, foldLatestTodosMeta, listsEqual, type TodoEdit, type TodoItem } from './todo-core.ts';
11
4
  import type { RuntimeRoleManifest } from './tool-gate.ts';
12
5
 
13
6
  export interface TodosConfig {
@@ -17,21 +10,13 @@ export interface TodosConfig {
17
10
 
18
11
  export type TodoCompletionSource = 'tool' | 'reconcile' | 'human' | 'archive';
19
12
 
20
- export interface TodoCompletedEvent {
21
- count: number;
22
- at: number;
23
- source: TodoCompletionSource;
24
- }
25
-
26
13
  export class TodosService extends EventEmitter {
27
14
  private _items: TodoItem[] = [];
28
- /**
29
- * Live list (read-only). Mutations must go through replace/applyEdits/rebuild.
30
- */
15
+ /** Live list (read-only). Mutations must go through replace/applyEdits/rebuild. */
31
16
  get items(): readonly TodoItem[] {
32
17
  return this._items;
33
18
  }
34
- /** Why: Preserve the established compatibility and safety behavior. */
19
+ /** Clock anchor for staleness/archiving; null until the first write. */
35
20
  lastWriteAt: number | null = null;
36
21
  readonly config: TodosConfig;
37
22
 
@@ -40,17 +25,14 @@ export class TodosService extends EventEmitter {
40
25
  this.config = config;
41
26
  }
42
27
 
43
- /** Why: Preserve the established compatibility and safety behavior (D75). */
28
+ /** D75: subagents are strict (one in_progress); the role manifest may force parallel mode. */
44
29
  static configFromRuntime(manifest: RuntimeRoleManifest | null, isSubagent: boolean): TodosConfig {
45
30
  const strict = isSubagent;
46
31
  const mode = manifest?.services?.todos?.mode;
47
- const allowParallelInProgress = mode === 'parallel' || mode === 'serial' ? mode === 'parallel' : !strict;
48
- return { strict, allowParallelInProgress };
49
- }
50
-
51
- /** Defensive copy for callers that must not mutate service state. */
52
- getSnapshot(): TodoItem[] {
53
- return this._items.map((it) => ({ ...it }));
32
+ return {
33
+ strict,
34
+ allowParallelInProgress: mode === 'parallel' || mode === 'serial' ? mode === 'parallel' : !strict,
35
+ };
54
36
  }
55
37
 
56
38
  replace(items: readonly TodoItem[], opts?: { source?: TodoCompletionSource }): { changed: boolean } {
@@ -76,24 +58,22 @@ export class TodosService extends EventEmitter {
76
58
  return { changed: true };
77
59
  }
78
60
 
79
- /** Why: Preserve the established compatibility and safety behavior (M16). */
61
+ /** M16: ETA and compaction boundaries feed off this single completion diff (same rule as the tool's D36 report). */
80
62
  private emitCompletedTransitions(
81
63
  before: readonly TodoItem[],
82
64
  after: readonly TodoItem[],
83
65
  source: TodoCompletionSource = 'tool',
84
66
  ): void {
85
- const count = countCompletedTransitions(before, after);
67
+ const count = completionTransitions(before, after).length;
86
68
  if (count > 0) this.emit('todo.completed', { count, at: Date.now(), source });
87
69
  }
88
70
 
71
+ /** Branch replay: fold the JSONL to the authoritative list. A branch with no snapshot (/new, or /tree
72
+ * back before the first todo_write) has no list, so the previous session's items must not survive. */
89
73
  rebuild(entries: readonly unknown[]): void {
90
- // Why: Preserve the established compatibility and safety behavior.
91
- // Why: Preserve the established compatibility and safety behavior.
92
74
  const folded = foldLatestTodosMeta(entries as Parameters<typeof foldLatestTodosMeta>[0]);
93
- if (folded) {
94
- this._items = folded.items;
95
- this.lastWriteAt = folded.writtenAt;
96
- }
75
+ this._items = folded ? folded.items : [];
76
+ this.lastWriteAt = folded ? folded.writtenAt : null;
97
77
  this.emit('todo.updated', { items: this._items, reason: 'rebuild' });
98
78
  }
99
79
  }
package/src/tool-error.ts CHANGED
@@ -1,17 +1,11 @@
1
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").
2
+ * pi only marks a tool result as failed when `execute()` throws; returning a value never sets the
3
+ * error flag (pi docs/extensions.md, "Signaling errors"). Every hard failure goes through
4
+ * `toolError()`, while deliberate non-failures (empty result set, "no match within Xms", a pane that
5
+ * has not produced output yet) stay normal results.
5
6
  *
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.
7
+ * The leading "Error: " text is stripped: the provider's tool-result error channel and pi's red
8
+ * rendering already mark it, and keeping it would double the prefix once pi formats a thrown error.
15
9
  */
16
10
  export class ToolError extends Error {
17
11
  constructor(message: string) {
@@ -20,7 +14,6 @@ export class ToolError extends Error {
20
14
  }
21
15
  }
22
16
 
23
- /** Throw a tool failure (never returns). */
24
17
  export function toolError(message: string): never {
25
18
  throw new ToolError(message);
26
19
  }
package/src/tool-gate.ts CHANGED
@@ -1,31 +1,40 @@
1
- /** Why: Preserve the established compatibility and safety behavior (C7, D76, D77, D79, WS-D6). */
1
+ /**
2
+ * Worker execution enforcement (C7, D76/D77/D82) — pure. RuntimeRoleManifest is THE runtime manifest
3
+ * shape; the file format lives in role-manifest.ts and the composed/persisted shapes derive from it
4
+ * (manifest-compose.ts, role-state.ts).
5
+ */
2
6
  import type { PermissionAction, UnknownToolStance } from './role-manifest.ts';
3
7
 
4
- /** Why: Preserve the established compatibility and safety behavior. */
5
8
  export interface RuntimeRoleManifest {
6
9
  role: string;
7
10
  version?: string;
8
11
  tools: string[];
9
12
  permissions: Record<string, PermissionAction>;
10
- /** Why: Preserve the established compatibility and safety behavior (D82). */
13
+ /** Stance for tools outside `tools`; 'allow' keeps user-installed extensions visible (D82). */
11
14
  unknownTools?: UnknownToolStance;
12
15
  services?: {
13
16
  todos?: {
14
17
  mode?: 'serial' | 'parallel';
15
18
  };
16
19
  };
20
+ /** P0 per-role guidelines (RFC §4.6): injected as a `pier-role` prompt section each turn. */
21
+ guidelines?: string[];
17
22
  }
18
23
 
19
- /** Why: Preserve the established compatibility and safety behavior. */
24
+ /** Parse the env-carried manifest; malformed input is null (fail-open: no gate) rather than a crash. */
20
25
  export function parseRuntimeManifest(envValue: string | undefined): RuntimeRoleManifest | null {
21
26
  if (!envValue) return null;
22
27
  try {
23
28
  const m = JSON.parse(envValue) as RuntimeRoleManifest;
24
29
  if (!m || typeof m.role !== 'string' || !Array.isArray(m.tools)) return null;
25
- // Why: Preserve the established compatibility and safety behavior (D82).
26
30
  if (m.unknownTools !== undefined && m.unknownTools !== 'allow' && m.unknownTools !== 'deny') {
27
31
  m.unknownTools = 'deny';
28
32
  }
33
+ if (m.guidelines !== undefined) {
34
+ m.guidelines = Array.isArray(m.guidelines)
35
+ ? m.guidelines.filter((g) => typeof g === 'string' && g.trim() !== '')
36
+ : undefined;
37
+ }
29
38
  return m;
30
39
  } catch {
31
40
  return null;
@@ -43,7 +52,6 @@ export function planToolGate(toolName: string, manifest: RuntimeRoleManifest | n
43
52
  const perm = manifest.permissions[toolName] ?? manifest.permissions['*'] ?? 'allow';
44
53
  const known = manifest.tools.includes(toolName);
45
54
  const stance = manifest.unknownTools ?? 'deny';
46
- // Why: Preserve the established compatibility and safety behavior (D82).
47
55
  if (perm === 'deny' || (!known && stance === 'deny')) {
48
56
  return {
49
57
  kind: 'deny',
@@ -59,27 +67,36 @@ export function planToolGate(toolName: string, manifest: RuntimeRoleManifest | n
59
67
  return { kind: 'allow' };
60
68
  }
61
69
 
62
- /** Why: Preserve the established compatibility and safety behavior (D77, D82). */
63
- export function planActiveTools(
64
- manifestTools: string[],
65
- currentActive: string[],
70
+ /**
71
+ * 'allow' keeps every tool but the explicit deny rules (D82 user-installed-extension axis); 'deny'
72
+ * intersects with the manifest. Shared by planActiveTools (visibility) and role-state's switch
73
+ * planning (all-tools universe).
74
+ */
75
+ export function filterToolsByStance(
76
+ manifestTools: readonly string[],
77
+ universe: readonly string[],
66
78
  opts?: { unknownTools?: UnknownToolStance; permissions?: Record<string, PermissionAction> },
67
- ): { next: string[]; changed: boolean } | null {
68
- if (manifestTools.length === 0 || currentActive.length === 0) return null;
79
+ ): string[] {
69
80
  if ((opts?.unknownTools ?? 'deny') === 'allow') {
70
81
  const denied = new Set(
71
82
  Object.entries(opts?.permissions ?? {})
72
- .filter(([, v]) => v === 'deny')
73
- .map(([k]) => k),
83
+ .filter(([, action]) => action === 'deny')
84
+ .map(([name]) => name),
74
85
  );
75
- const next = currentActive.filter((t) => !denied.has(t));
76
- if (next.length === 0) return null;
77
- return { next, changed: next.length !== currentActive.length };
86
+ return universe.filter((name) => !denied.has(name));
78
87
  }
79
88
  const wanted = new Set(manifestTools);
80
- const next = currentActive.filter((t) => wanted.has(t));
81
- if (next.length === 0) return null;
82
- const changed = next.length !== currentActive.length;
83
- return { next, changed };
89
+ return universe.filter((name) => wanted.has(name));
84
90
  }
85
91
 
92
+ /** Null means "keep the current active set": an empty result must never clear every tool (D77). */
93
+ export function planActiveTools(
94
+ manifestTools: readonly string[],
95
+ currentActive: readonly string[],
96
+ opts?: { unknownTools?: UnknownToolStance; permissions?: Record<string, PermissionAction> },
97
+ ): { next: string[]; changed: boolean } | null {
98
+ if (manifestTools.length === 0 || currentActive.length === 0) return null;
99
+ const next = filterToolsByStance(manifestTools, currentActive, opts);
100
+ if (next.length === 0) return null;
101
+ return { next, changed: next.length !== currentActive.length };
102
+ }
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Unified TUI status surface (RFC docs/rfc-unified-widget.md, D113): the resident widget shows one
3
+ * line per agent — self plus every running subagent — each reduced to its CURRENT item and a
4
+ * done/total suffix; the full todo × subagent tree lives behind /pier-tree. Pure builders only.
5
+ */
6
+ import { formatAge } from './stale-core.ts';
7
+ import { formatTodoSummary, renderTodoGroups } from './todo-window.ts';
8
+ import type { TodoItem } from './vocab.ts';
9
+
10
+ /** Minimal todo shape carried over the pipe (todo_sync); status matches TodoStatus strings. */
11
+ export interface TodoLikeItem {
12
+ content: string;
13
+ status: string;
14
+ blocker?: string;
15
+ }
16
+
17
+ /** What the master caches per worker: the pushed full list, or a summary fallback after a resume. */
18
+ export interface WorkerTodoSnapshot {
19
+ items?: readonly TodoLikeItem[];
20
+ summary?: string;
21
+ ts?: number;
22
+ }
23
+
24
+ /** One running worker row as the widget/tree consumes it. */
25
+ export interface WorkerWidgetRow {
26
+ paneId: string;
27
+ slug?: string;
28
+ description: string;
29
+ kind?: string;
30
+ todos?: WorkerTodoSnapshot | null;
31
+ }
32
+
33
+ /** A settled (delivered) worker row for the tree view only — never in the resident widget. */
34
+ export interface SettledWidgetRow {
35
+ paneId: string;
36
+ slug?: string;
37
+ description: string;
38
+ settledAt?: number | null;
39
+ }
40
+
41
+ const CONTENT_CLIP = 60;
42
+
43
+ function clip(text: string, max = CONTENT_CLIP): string {
44
+ const flat = text.replace(/\s+/g, ' ').trim();
45
+ return flat.length <= max ? flat : `${flat.slice(0, max - 1)}…`;
46
+ }
47
+
48
+ function countsOf(items: readonly TodoLikeItem[]): string {
49
+ const done = items.filter((it) => it.status === 'completed').length;
50
+ return `✓${done}/${items.length}`;
51
+ }
52
+
53
+ function currentOf(items: readonly TodoLikeItem[]): TodoLikeItem | undefined {
54
+ return items.find((it) => it.status === 'in_progress')
55
+ ?? items.find((it) => it.status === 'blocked')
56
+ ?? items.find((it) => it.status === 'pending');
57
+ }
58
+
59
+ /** `↳ <ref> ▶ <current> ✓d/n` — blocked names the waiting TASK (the actionable identity) with the
60
+ * blocker as a parenthetical reason; a cold cache falls back to the summary token. */
61
+ export function workerLine(row: WorkerWidgetRow): string {
62
+ const ref = row.slug ?? `${row.paneId} · ${clip(row.description, 30)}`;
63
+ const items = row.todos?.items;
64
+ if (items && items.length > 0) {
65
+ const cur = currentOf(items);
66
+ const counts = countsOf(items);
67
+ if (!cur) return `↳ ${ref} ${counts}`;
68
+ if (cur.status === 'blocked') {
69
+ const why = cur.blocker && cur.blocker !== cur.content ? `(${clip(cur.blocker, 24)})` : '';
70
+ return `↳ ${ref} ■ ${clip(cur.content, 40)}${why} ${counts}`;
71
+ }
72
+ return `↳ ${ref} ▶ ${clip(cur.content)} ${counts}`;
73
+ }
74
+ const summary = row.todos?.summary;
75
+ if (summary) return `↳ ${ref} · ${clip(summary, 50)}`;
76
+ return `↳ ${ref}`;
77
+ }
78
+
79
+
80
+ /** Self line: the current item with a done/total suffix — the anchored multi-line window is retired.
81
+ * A finished plan (no in_progress / blocked / pending item) has no actionable row, so it renders
82
+ * nothing at all: a bare `✓8/8` is noise that the human cannot act on. */
83
+ export function selfLine(items: readonly TodoItem[]): string | null {
84
+ if (items.length === 0) return null;
85
+ const cur = currentOf(items);
86
+ if (!cur) return null;
87
+ const counts = countsOf(items);
88
+ if (cur.status === 'blocked') return `■ 等人: ${clip(cur.blocker || cur.content, 40)} ${counts}`;
89
+ return `▶ ${clip(cur.content)} ${counts}`;
90
+ }
91
+
92
+ /** pi caps the resident widget at ten lines; overflow workers fold into a count tail. */
93
+ export const WIDGET_MAX_LINES = 10;
94
+
95
+ export function unifiedWidgetLines(
96
+ items: readonly TodoItem[],
97
+ workers: readonly WorkerWidgetRow[],
98
+ opts?: { archivedAgeMs?: number | null; blockedDepth?: number | null },
99
+ ): string[] {
100
+ // Human gate open: the question owns the fixed area (unchanged discipline from the old widget).
101
+ if ((opts?.blockedDepth ?? 0) > 0) {
102
+ return workers.length === 0 || items.length === 0
103
+ ? [`${formatTodoSummary(items)} · /todos 全量`]
104
+ : [`${formatTodoSummary(items)} · ↳×${workers.length} · /todos 全量`];
105
+ }
106
+ if (items.length === 0 && workers.length === 0) return [];
107
+ // Archived plans keep their degraded two-line form; workers stay visible underneath.
108
+ const self = items.length > 0 ? selfLine(items) : null;
109
+ const head = opts?.archivedAgeMs != null && items.length > 0
110
+ ? [`${formatTodoSummary(items)} · archived ${formatAge(opts.archivedAgeMs)}`]
111
+ : self ? [self] : [];
112
+ if (head.length === 0 && workers.length === 0) return [];
113
+ const rows = workers.map(workerLine);
114
+ const budget = WIDGET_MAX_LINES - head.length - 1;
115
+ const shown = rows.slice(0, Math.max(budget, 0));
116
+ const hidden = rows.length - shown.length;
117
+ return [...head, ...shown, ...(hidden > 0 ? [`…另 ${hidden} 个子代理 · /pier-tree`] : [])];
118
+ }
119
+
120
+ /** Full delegation tree for /pier-tree: self todos in full + per-worker full lists + settled nodes. */
121
+ export function formatDelegationTree(opts: {
122
+ selfItems: readonly TodoItem[];
123
+ workers: readonly WorkerWidgetRow[];
124
+ settled?: readonly SettledWidgetRow[];
125
+ now?: number;
126
+ }): string[] {
127
+ const now = opts.now ?? Date.now();
128
+ const lines: string[] = ['==================== PIER DELEGATION TREE ===================='];
129
+ lines.push(`● master(本 pane)· ${opts.selfItems.length} 项`);
130
+ lines.push(...renderTodoGroups(opts.selfItems).map((l) => ` ${l}`));
131
+ if (opts.workers.length > 0 || (opts.settled?.length ?? 0) > 0) lines.push(' │');
132
+ for (const w of opts.workers) {
133
+ const ref = w.slug ?? w.paneId;
134
+ const items = w.todos?.items;
135
+ const headSuffix = items && items.length > 0
136
+ ? `· ${items.length} 项`
137
+ : w.todos?.summary ? `· ${clip(w.todos.summary, 40)}` : '· todo 未同步';
138
+ lines.push(` ├─ ↳ ${ref} (${w.paneId} · running${w.kind && w.kind !== 'task' ? ` · ${w.kind}` : ''}) ${headSuffix}`);
139
+ if (items && items.length > 0) {
140
+ lines.push(...renderTodoGroups(toTodoItems(items)).map((l) => ` │ ${l}`));
141
+ }
142
+ }
143
+ for (const s of opts.settled ?? []) {
144
+ const ref = s.slug ?? s.paneId;
145
+ const age = s.settledAt ? ` · settled ${formatAge(Math.max(now - s.settledAt, 0))} ago` : ' · settled';
146
+ lines.push(` └─ ✓ ${ref} (${s.paneId}${age}) — 结果已注入结算通知`);
147
+ }
148
+ lines.push('----------------------------------------------------------------');
149
+ lines.push('提示: /todos 编辑自己的清单 · subagent list 交互子代理 · /dashboard ops 面板');
150
+ return lines;
151
+ }
152
+
153
+ /** Narrow a pipe-pushed item list back to TodoItem for the shared group renderer. */
154
+ function toTodoItems(items: readonly TodoLikeItem[]): TodoItem[] {
155
+ return items.map((it) => ({
156
+ content: it.content,
157
+ status: (['pending', 'in_progress', 'blocked', 'completed', 'abandoned'].includes(it.status)
158
+ ? it.status
159
+ : 'pending') as TodoItem['status'],
160
+ ...(it.blocker ? { blocker: it.blocker } : {}),
161
+ }));
162
+ }
package/src/vocab.ts CHANGED
@@ -1,20 +1,17 @@
1
1
  /**
2
- * pi-herdr bridge vocabulary (M22: title is the board).
3
- *
4
- * DESIGN.md §4.2: no new transport. Wire = herdr NDJSON; todo authority = pi
5
- * session JSONL; display = pane.report_metadata title / state_labels.
2
+ * pi-herdr bridge vocabulary. DESIGN.md §4.2: no new transport — wire = herdr NDJSON, todo authority
3
+ * = pi session JSONL, display = pane.report_metadata title / state_labels.
6
4
  */
7
5
 
8
- /** Todo five-state (D34: three-state + blocked/abandoned, aligned with OMP). */
6
+ /** Five states (D34): three-state + blocked/abandoned, aligned with OMP. */
9
7
  export type TodoStatus = 'pending' | 'in_progress' | 'completed' | 'blocked' | 'abandoned';
10
8
 
11
9
  export const TODO_STATUSES: readonly TodoStatus[] = ['pending', 'in_progress', 'completed', 'blocked', 'abandoned'];
12
10
 
13
- /** Todo item: content + status; optional blocker (blocked only) and phase (D43). No stable id (last-write-wins). */
11
+ /** Todo item: content + status; blocker only when blocked; optional phase groups (D43). No id (last-write-wins). */
14
12
  export interface TodoItem {
15
13
  content: string;
16
14
  status: TodoStatus;
17
- /** Non-empty string only when status==='blocked' (D34). */
18
15
  blocker?: string;
19
16
  /** Optional group name (D43); non-empty, ≤30 chars. */
20
17
  phase?: string;
@@ -27,58 +24,88 @@ export interface TodoSnapshot {
27
24
  items: TodoItem[];
28
25
  }
29
26
 
30
- /** Snapshot counts (confirmation copy and title projection). */
31
27
  export interface TodoCounts {
32
28
  pending: number;
33
29
  inProgress: number;
34
30
  completed: number;
35
- /** D91: blocked as its own column (title quartet ▶○■✓). */
31
+ /** D91: blocked is its own column (title quartet ▶○■✓). */
36
32
  blocked: number;
37
33
  }
38
34
 
39
35
  /** Source name for pane.report-agent. */
40
36
  export const REPORT_AGENT_SOURCE = 'pi-herdr';
41
37
 
42
- /** First-upgrade key used to clear the old 16-key chunked tokens (M13b leftover). */
38
+ /** First-upgrade key used to clear the old 16-key chunked tokens (M13b). */
43
39
  export const PI_HERDR_META_KEY = 'pi-herdr';
44
40
 
45
- /** Model-facing todo tool name (DSH-compatible). */
46
41
  export const TODO_TOOL_NAME = 'todo_write';
47
42
 
48
43
  /** Snapshot key in tool-result details (persisted in session JSONL). */
49
44
  export const TODO_DETAILS_KEY = 'pi-herdr.todo';
50
45
 
51
- /** Confirmation copy (byte-aligned with DSH so model vocab transfers; blocked is appended only when present). */
46
+ /** Confirmation copy (byte-aligned with DSH so model vocab transfers). */
52
47
  export function formatTodoConfirmation(items: readonly TodoItem[]): string {
53
48
  const c = countTodos(items);
54
49
  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.
50
+ // Append blocked only when non-zero — the model must not read "nothing pending" while items sit
51
+ // blocked on a human. Zero keeps the DSH-aligned wording intact.
57
52
  return c.blocked > 0 ? `${base.slice(0, -1)}, ${c.blocked} blocked.` : base;
58
53
  }
59
54
 
55
+ /** Every status the todo model knows; anything else in a snapshot is corrupt data (D34). */
56
+ const STATUS_LOOKUP: Readonly<Record<TodoStatus, true>> = {
57
+ pending: true,
58
+ in_progress: true,
59
+ completed: true,
60
+ blocked: true,
61
+ abandoned: true,
62
+ };
63
+
64
+ /**
65
+ * Corrupt snapshots carry arbitrary JSON as `status` (`extractSnapshotFromDetails` does not validate),
66
+ * so every table lookup keyed by status must pass through this first — `TODO_MARKS['constructor']`
67
+ * would otherwise render a function, and `groups['constructor'].push` would throw.
68
+ */
69
+ export const isTodoStatus = (value: unknown): value is TodoStatus =>
70
+ typeof value === 'string' && Object.hasOwn(STATUS_LOOKUP, value);
71
+
72
+ const COUNTED_STATUSES = {
73
+ pending: 'pending',
74
+ in_progress: 'inProgress',
75
+ completed: 'completed',
76
+ blocked: 'blocked',
77
+ } as const satisfies Partial<Record<TodoStatus, keyof TodoCounts>>;
78
+
79
+ /** The four states that occupy a count column (`abandoned` is terminal and deliberately uncounted). */
80
+ const isCountedStatus = (status: TodoStatus): status is keyof typeof COUNTED_STATUSES =>
81
+ Object.hasOwn(COUNTED_STATUSES, status);
82
+
60
83
  export function countTodos(items: readonly TodoItem[]): TodoCounts {
61
- let pending = 0;
62
- let inProgress = 0;
63
- let completed = 0;
64
- let blocked = 0;
84
+ const counts: TodoCounts = { pending: 0, inProgress: 0, completed: 0, blocked: 0 };
65
85
  for (const it of items) {
66
- if (it.status === 'pending') pending++;
67
- else if (it.status === 'in_progress') inProgress++;
68
- else if (it.status === 'completed') completed++;
69
- else if (it.status === 'blocked') blocked++;
70
- // abandoned is omitted (D34); blocked is its own column (D91)
86
+ const status = it?.status;
87
+ if (isTodoStatus(status) && isCountedStatus(status)) counts[COUNTED_STATUSES[status]] += 1;
71
88
  }
72
- return { pending, inProgress, completed, blocked };
89
+ return counts;
73
90
  }
74
91
 
92
+ /** Why a settlement carried no closing text (p24-class mis-attribution vs a truly silent worker). */
93
+ export type SettlementNullReason = 'silent' | 'attribution-suspect' | 'extraction-failed';
94
+
95
+ const NULL_CLOSING_SENTENCE: Record<SettlementNullReason, string> = {
96
+ 'attribution-suspect': 'Its closing message could NOT be read — the session attribution is suspect (a wrong transcript may have been read). Check `subagent action output` / the ledger sessionFile before assuming it produced nothing; its work may be complete.',
97
+ 'extraction-failed': 'It appears to have ended with a final report, but the closing text could not be extracted — read it from the session transcript.',
98
+ silent: 'It left no closing message.',
99
+ };
100
+
75
101
  /** Subagent settlement notice (aligned with DSH). */
76
102
  export function formatSettlementNotice(
77
103
  agentId: string,
78
104
  closingMessage: string | null,
105
+ nullReason: SettlementNullReason = 'silent',
79
106
  ): string {
80
107
  const head = `Background subagent ${agentId} finished and will do no further work unless you send it more.`;
81
108
  return closingMessage
82
109
  ? `${head} Its closing message: ${closingMessage}`
83
- : `${head} It left no closing message.`;
110
+ : `${head} ${NULL_CLOSING_SENTENCE[nullReason]}`;
84
111
  }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Worker identity marker: a herdr 0.9.2 server restart reopens a worker pane by running pier's
3
+ * self-reported resume_argv — WITHOUT the spawn env (`PI_HERDR_SUBAGENT`, role manifest env). The
4
+ * marker file lets the restored boot re-derive worker mode from the session itself, so it mounts
5
+ * todo-only instead of the master composition (no subagent pipe, no stop reminders) and replays its
6
+ * role from the transcript as usual. Masters never write a marker; a stale marker for a dead session
7
+ * is inert (it is only consulted when pi boots with that exact session file).
8
+ */
9
+ import type { DelegationContext } from './delegation-core.ts';
10
+ import { delegationFromRecord } from './delegation-core.ts';
11
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
12
+ import { dirname, join } from 'node:path';
13
+ import { bareSessionId } from './session-tail.ts';
14
+ import { isValidSessionId } from './efficiency-store.ts';
15
+
16
+ export interface WorkerMarker {
17
+ readonly v: 1;
18
+ readonly ts: number;
19
+ readonly role?: string;
20
+ readonly delegation?: DelegationContext;
21
+ }
22
+
23
+ /** `<sessionDir>/herdr-pi/<sessionId>/worker.json` — same per-session root as the efficiency store. */
24
+ export function workerMarkerPath(sessionFile: string | null | undefined): string | null {
25
+ if (!sessionFile || typeof sessionFile !== 'string') return null;
26
+ const id = bareSessionId(sessionFile);
27
+ if (!isValidSessionId(id)) return null;
28
+ return join(dirname(sessionFile), 'herdr-pi', id, 'worker.json');
29
+ }
30
+
31
+ export function readWorkerMarker(sessionFile: string | null | undefined): WorkerMarker | null {
32
+ const file = workerMarkerPath(sessionFile);
33
+ if (!file) return null;
34
+ try {
35
+ const parsed = JSON.parse(readFileSync(file, 'utf8')) as { v?: unknown; ts?: unknown; role?: unknown; delegation?: unknown };
36
+ if (parsed && parsed.v === 1 && typeof parsed.ts === 'number') {
37
+ const delegation = delegationFromRecord(parsed.delegation);
38
+ return {
39
+ v: 1,
40
+ ts: parsed.ts,
41
+ ...(typeof parsed.role === 'string' ? { role: parsed.role } : {}),
42
+ ...(delegation ? { delegation } : {}),
43
+ };
44
+ }
45
+ } catch {
46
+ /* missing or torn write: no marker → boot stays env-driven */
47
+ }
48
+ return null;
49
+ }
50
+
51
+ /** Idempotent; best-effort — a failed write only loses server-restart worker detection. */
52
+ export function writeWorkerMarker(
53
+ sessionFile: string | null | undefined,
54
+ role?: string,
55
+ delegation?: DelegationContext,
56
+ ): void {
57
+ const file = workerMarkerPath(sessionFile);
58
+ if (!file) return;
59
+ try {
60
+ mkdirSync(dirname(file), { recursive: true });
61
+ writeFileSync(file, `${JSON.stringify({
62
+ v: 1,
63
+ ts: Date.now(),
64
+ ...(role ? { role } : {}),
65
+ ...(delegation ? { delegation } : {}),
66
+ })}\n`);
67
+ } catch {
68
+ /* read-only checkout or full disk: the live session is unaffected */
69
+ }
70
+ }
71
+
72
+ /** Boot-time probe over env.PI_SESSION_FILE; false negatives simply keep today's behavior. */
73
+ export function hasWorkerMarker(sessionFile: string | null | undefined): boolean {
74
+ const file = workerMarkerPath(sessionFile);
75
+ return file !== null && existsSync(file);
76
+ }