pi-pier 0.3.1 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +7 -5
  2. package/package.json +3 -3
  3. package/schemas/efficiency-config.schema.json +6 -0
  4. package/src/ask-dialog.ts +9 -2
  5. package/src/ask-user.ts +142 -74
  6. package/src/bootstrap.ts +2 -1
  7. package/src/compact-coordinator.ts +207 -35
  8. package/src/compact-economics-core.ts +62 -29
  9. package/src/config-catalog-core.ts +1 -0
  10. package/src/cordis-services.ts +64 -0
  11. package/src/dashboard-command.ts +40 -15
  12. package/src/delegation-core.ts +115 -0
  13. package/src/efficiency-config-core.ts +17 -6
  14. package/src/efficiency-store.ts +43 -6
  15. package/src/guards.ts +6 -0
  16. package/src/herdr-client.ts +141 -36
  17. package/src/history-store.ts +2 -0
  18. package/src/index-gates.ts +6 -12
  19. package/src/index-master.ts +12 -16
  20. package/src/index-pipe.ts +6 -0
  21. package/src/index-runtime.ts +14 -0
  22. package/src/index-worker.ts +4 -10
  23. package/src/index.ts +310 -76
  24. package/src/jev-client.ts +32 -7
  25. package/src/jev-core.ts +71 -36
  26. package/src/observation-core.ts +13 -1
  27. package/src/panel-open.ts +47 -0
  28. package/src/pier-options.ts +1 -2
  29. package/src/pipe-channel.ts +9 -0
  30. package/src/plugins/heat-plan.ts +18 -3
  31. package/src/plugins/observation.ts +118 -46
  32. package/src/plugins/subagent.ts +119 -68
  33. package/src/plugins/terminal.ts +221 -57
  34. package/src/plugins/todo.ts +63 -63
  35. package/src/reducer-core.ts +3 -5
  36. package/src/reducer-invoker.ts +27 -13
  37. package/src/renderers.ts +25 -2
  38. package/src/role-manifest.ts +2 -3
  39. package/src/roles/worker-default.json +2 -2
  40. package/src/settle-wake-core.ts +17 -3
  41. package/src/subagent-core.ts +209 -3
  42. package/src/subagent-poller.ts +69 -19
  43. package/src/subagent-session.ts +67 -4
  44. package/src/subagent-spawn.ts +55 -7
  45. package/src/terminal-core.ts +178 -50
  46. package/src/todo-core.ts +47 -2
  47. package/src/todo-read-hook.ts +54 -17
  48. package/src/todo-reminder-core.ts +9 -5
  49. package/src/todo-window.ts +8 -4
  50. package/src/todos-service.ts +12 -0
  51. package/src/tree-view.ts +162 -0
  52. package/src/vocab.ts +7 -1
  53. package/src/worker-marker.ts +76 -0
  54. package/src/focus-poller.ts +0 -223
@@ -1,18 +1,41 @@
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
 
16
+ /**
17
+ * pi 1.2 fires `before_agent_start` for runs started by `sendMessage(..., { triggerTurn: true })`
18
+ * too. The todo hook wants the plan in every *user* run and after a compaction continuation (the
19
+ * model needs the plan to resume), but not in its own stop reminder or the config guide: those
20
+ * already carry their own text, and counting them as turns would advance the guard cadence.
21
+ * Keep this list in sync with COMPACTION_CONTINUE_TYPE in compact-coordinator.ts.
22
+ */
23
+ export const TODO_READ_CUSTOM_TYPES: readonly string[] = ['pi-herdr.compaction-continue'];
24
+
25
+ /** Whether this run should receive the todo read hook. Missing `messageType` is pi < 1.2, where
26
+ * only user runs fired `before_agent_start`; treat it as a user run for compatibility. */
27
+ export function shouldRunTodoRead(event: unknown): boolean {
28
+ if (event === null || typeof event !== 'object' || !('messageType' in event)) return true;
29
+ const messageType = (event as { messageType?: unknown }).messageType;
30
+ if (messageType === 'user') return true;
31
+ if (messageType !== 'custom') return false;
32
+ const message = (event as { message?: unknown }).message;
33
+ const customType = message !== null && typeof message === 'object' && 'customType' in message
34
+ ? (message as { customType?: unknown }).customType
35
+ : undefined;
36
+ return typeof customType === 'string' && TODO_READ_CUSTOM_TYPES.includes(customType);
37
+ }
38
+
16
39
  type TodoReadEffect =
17
40
  | 'recite' // Recite normally while the list is fresh.
18
41
  | 'empty-guard' // Guard against an empty list.
@@ -28,6 +51,8 @@ interface TodoReadPlan {
28
51
  /** Clear the in-memory list when injecting the archive notice (the caller persists the empty JSONL);
29
52
  * a kept dead list would suppress the empty guard that resumes tracking after an idle gap. */
30
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;
31
56
  message: {
32
57
  customType: string;
33
58
  content: string;
@@ -45,6 +70,9 @@ export function planTodoReadHook(opts: {
45
70
  now: number;
46
71
  staleNotices: number;
47
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;
48
76
  }): TodoReadPlan {
49
77
  const msg = (content: string) => ({
50
78
  customType: TODO_READ_CUSTOM_TYPE,
@@ -60,6 +88,7 @@ export function planTodoReadHook(opts: {
60
88
  effect: 'empty-guard',
61
89
  clearArchived: false,
62
90
  archived: false,
91
+ offerArchiveWindow: false,
63
92
  message: msg(
64
93
  guardDue
65
94
  ? 'Your todo list is empty. If the current work needs tracking, call todo_write before stopping.'
@@ -79,30 +108,36 @@ export function planTodoReadHook(opts: {
79
108
  const c = countTodos(opts.items);
80
109
  const age = st.ageMs == null ? '' : ` ${formatAge(st.ageMs)} ago`;
81
110
  // Idle periods do not consume turns, so give one rewrite window with the old entries as reference
82
- // before the terminal notice.
83
- if (opts.staleNotices === 0 && guardDue) {
84
- 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)}`);
85
118
  const head = `todos ✓${c.completed} (all completed, last updated${age}) — about to be archived`;
86
- 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.';
87
120
  return {
88
121
  inject: true,
89
122
  effect: 'stale-notice',
90
123
  archived: true,
91
124
  clearArchived: false,
125
+ offerArchiveWindow: true,
92
126
  message: msg([head, ...lines, warn].join('\n')),
93
127
  };
94
128
  }
95
129
  // The terminal notice carries no details and no "skip tracking" escape hatch — multi-step work
96
- // 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.
97
133
  return {
98
- inject: guardDue,
134
+ inject: true,
99
135
  effect: 'archive-notice',
100
136
  archived: true,
101
- clearArchived: guardDue,
137
+ clearArchived: true,
138
+ offerArchiveWindow: false,
102
139
  message: msg(
103
- guardDue
104
- ? `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.`
105
- : '',
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.`,
106
141
  ),
107
142
  };
108
143
  }
@@ -111,18 +146,19 @@ export function planTodoReadHook(opts: {
111
146
  const staleDue = opts.lastStaleGuardTurn == null
112
147
  || opts.turn - opts.lastStaleGuardTurn >= EMPTY_GUARD_EVERY_N;
113
148
  if (!staleDue || opts.staleNotices >= STALE_NOTICE_MAX) {
114
- 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('') };
115
150
  }
116
151
  const c = countTodos(opts.items);
117
152
  const head = `todos ▶${c.inProgress} ○${c.pending} ■${c.blocked} ✓${c.completed}`
118
153
  + ` · unchanged for ${opts.turnsSinceWrite} turns, nothing open`;
119
- const lines = opts.items.map((it) => ` ${todoMark(it.status)} ${it.content}`);
154
+ const lines = opts.items.map((it) => ` ${todoLine(it)}`);
120
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.';
121
156
  return {
122
157
  inject: true,
123
158
  effect: 'stale-notice',
124
159
  clearArchived: false,
125
160
  archived: false,
161
+ offerArchiveWindow: false,
126
162
  message: msg([head, ...lines, warn].join('\n')),
127
163
  };
128
164
  }
@@ -130,7 +166,7 @@ export function planTodoReadHook(opts: {
130
166
  // Fresh lists are recited every turn to keep current work visible.
131
167
  const c = countTodos(opts.items);
132
168
  const view = boundedView(opts.items, 6);
133
- const lines = view.visible.map((it) => `${todoMark(it.status)} ${it.content}`);
169
+ const lines = view.visible.map((it) => todoLine(it));
134
170
  const activity = currentActivity(opts.items);
135
171
  const head = `todos ▶${c.inProgress} ○${c.pending} ■${c.blocked} ✓${c.completed}`
136
172
  + (activity ? ` · ${activity}` : '');
@@ -139,6 +175,7 @@ export function planTodoReadHook(opts: {
139
175
  effect: 'recite',
140
176
  clearArchived: false,
141
177
  archived: false,
178
+ offerArchiveWindow: false,
142
179
  message: msg([head, ...lines].join('\n')),
143
180
  };
144
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
  }
@@ -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
@@ -98,13 +98,19 @@ const NULL_CLOSING_SENTENCE: Record<SettlementNullReason, string> = {
98
98
  silent: 'It left no closing message.',
99
99
  };
100
100
 
101
- /** Subagent settlement notice (aligned with DSH). */
101
+ /** Subagent settlement notice (aligned with DSH). `reportDeliveredAt`: the master already received
102
+ * this exact closing message through `subagent output`, so the notice points there instead. */
102
103
  export function formatSettlementNotice(
103
104
  agentId: string,
104
105
  closingMessage: string | null,
105
106
  nullReason: SettlementNullReason = 'silent',
107
+ reportDeliveredAt: number | null = null,
106
108
  ): string {
107
109
  const head = `Background subagent ${agentId} finished and will do no further work unless you send it more.`;
110
+ if (closingMessage && reportDeliveredAt !== null) {
111
+ const at = new Date(reportDeliveredAt).toTimeString().slice(0, 8);
112
+ return `${head} Its closing message is the report subagent(action:"output") already returned at ${at}; it is not repeated here.`;
113
+ }
108
114
  return closingMessage
109
115
  ? `${head} Its closing message: ${closingMessage}`
110
116
  : `${head} ${NULL_CLOSING_SENTENCE[nullReason]}`;
@@ -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
+ }