pi-pier 0.3.1 → 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.
@@ -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
+ }
@@ -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
+ }
@@ -1,223 +0,0 @@
1
- /**
2
- * D-4: herdr 0.9.1+ delivers `pane.focused`, so default polling is off (0 ms) there — a sampler
3
- * would reflow a second time ~8 s after a click; older servers keep the 1500 ms sampler.
4
- * `PIER_FOCUS_POLL_MS` overrides either default, 0 disables.
5
- */
6
- import { spawn } from 'node:child_process';
7
- import { dirname, join } from 'node:path';
8
- import { fileURLToPath } from 'node:url';
9
-
10
- /** Poll cadence for older Herdr (< 0.9.1). */
11
- export const FOCUS_POLL_DEFAULT_MS = 1500;
12
- /** Herdr 0.9.1+: native pane.focused is authoritative; default poller off to avoid a delayed second reflow. */
13
- export const FOCUS_POLL_HERDR_091_MS = 0;
14
-
15
- export function isHerdr091OrLater(version?: string | null): boolean {
16
- if (!version) return false;
17
- const m = /^(\d+)\.(\d+)\.(\d+)/.exec(version.trim());
18
- if (!m) return false;
19
- const major = Number(m[1]);
20
- const minor = Number(m[2]);
21
- const patch = Number(m[3]);
22
- if (major > 0) return true;
23
- if (minor > 9) return true;
24
- if (minor === 9 && patch >= 1) return true;
25
- return false;
26
- }
27
-
28
- export function resolveDefaultFocusPollMs(herdrVersion?: string | null): number {
29
- return isHerdr091OrLater(herdrVersion) ? FOCUS_POLL_HERDR_091_MS : FOCUS_POLL_DEFAULT_MS;
30
- }
31
- /** Min gap between reflow triggers: re-clicking the same pane must not spawn a process per click (the
32
- * workbench debounces 150 ms per tab on top of this). */
33
- export const FOCUS_FIRE_MIN_INTERVAL_MS = 700;
34
-
35
- export interface FocusSample {
36
- /** Pane focused in the sampled tab (null when the server reported no focus). */
37
- readonly focusedPaneId: string | null;
38
- /** Panes currently in that tab, used to tell a click apart from a spawn auto-focus. */
39
- readonly paneIds: readonly string[];
40
- }
41
-
42
- export interface FocusPollerState {
43
- readonly lastFocusedPaneId: string | null;
44
- readonly lastPaneIds: readonly string[];
45
- readonly lastFireAt: number;
46
- }
47
-
48
- /** Minimal flatten of a herdr layout tree (`{type:'pane',pane_id}` / `{type:'split',first,second}`). */
49
- export function collectPaneIds(root: unknown): string[] {
50
- const out: string[] = [];
51
- const walk = (node: unknown): void => {
52
- if (node === null || typeof node !== 'object') return;
53
- const n = node as { pane_id?: unknown; first?: unknown; second?: unknown };
54
- if (typeof n.pane_id === 'string') {
55
- out.push(n.pane_id);
56
- return;
57
- }
58
- walk(n.first);
59
- walk(n.second);
60
- };
61
- walk(root);
62
- return out;
63
- }
64
-
65
- /** `layout.export` payload → sample. `focusedPaneId` is optional for older clients. */
66
- export function parseFocusSample(
67
- layout: { root: unknown; focusedPaneId?: string | null } | null | undefined,
68
- ): FocusSample | null {
69
- if (!layout || !('root' in layout)) return null;
70
- const paneIds = collectPaneIds(layout.root);
71
- const focused = typeof layout.focusedPaneId === 'string' ? layout.focusedPaneId : null;
72
- return { focusedPaneId: focused, paneIds };
73
- }
74
-
75
- export interface FocusTick {
76
- readonly fire: boolean;
77
- readonly cause: 'user' | null;
78
- readonly state: FocusPollerState;
79
- }
80
-
81
- /**
82
- * Decide whether one sample should trigger a reflow: only on a *transition* into "I am focused",
83
- * and at most once per `minIntervalMs`. `cause` is 'user' when the pane set did not change in the
84
- * same sample (a click moves focus and nothing else, a spawn auto-focus comes with a new pane), so
85
- * the workbench can keep its pane-age whitelist. Baseline sample never fires.
86
- */
87
- export function planFocusTick(opts: {
88
- myPaneId: string;
89
- sample: FocusSample;
90
- prev: FocusPollerState | null;
91
- now: number;
92
- minIntervalMs?: number;
93
- }): FocusTick {
94
- const minInterval = opts.minIntervalMs ?? FOCUS_FIRE_MIN_INTERVAL_MS;
95
- const prev = opts.prev;
96
- const next: FocusPollerState = {
97
- lastFocusedPaneId: opts.sample.focusedPaneId,
98
- lastPaneIds: opts.sample.paneIds,
99
- lastFireAt: prev?.lastFireAt ?? 0,
100
- };
101
- const noFire = (): FocusTick => ({ fire: false, cause: null, state: next });
102
-
103
- if (opts.myPaneId === '') return noFire();
104
- // First sample only records the baseline: a pi that starts in the focused pane must not reflow on
105
- // startup (that is not a click, and it would fight whatever layout the user had).
106
- if (prev === null) return noFire();
107
- if (opts.sample.focusedPaneId !== opts.myPaneId) return noFire();
108
- if (prev.lastFocusedPaneId === opts.myPaneId) return noFire();
109
- if (opts.now - prev.lastFireAt < minInterval) return noFire();
110
-
111
- const setUnchanged =
112
- prev.lastPaneIds.length === opts.sample.paneIds.length &&
113
- prev.lastPaneIds.every((id, i) => id === opts.sample.paneIds[i]);
114
- return {
115
- fire: true,
116
- cause: setUnchanged ? 'user' : null,
117
- state: { ...next, lastFireAt: opts.now },
118
- };
119
- }
120
-
121
- export interface FocusPollerDeps {
122
- /** Layout of the caller's own tab (the client already exposes `layout.export`). */
123
- sample(): Promise<FocusSample | null>;
124
- /** Replay a pane.focused reflow for `paneId`. Must never throw. */
125
- fire(paneId: string, cause: 'user' | null): void;
126
- myPaneId: string;
127
- intervalMs?: number;
128
- minIntervalMs?: number;
129
- now?: () => number;
130
- /** Diagnostics only: polling failures are expected while herdr restarts. */
131
- onError?: (err: unknown) => void;
132
- }
133
-
134
- export interface FocusPoller {
135
- /** One sampling pass (exposed for tests; the interval calls the same function). */
136
- tick(): Promise<void>;
137
- stop(): void;
138
- }
139
-
140
- export function startFocusPoller(deps: FocusPollerDeps): FocusPoller {
141
- const intervalMs = deps.intervalMs ?? FOCUS_POLL_DEFAULT_MS;
142
- const now = deps.now ?? Date.now;
143
- let state: FocusPollerState | null = null;
144
- let inFlight = false;
145
- let stopped = false;
146
- let handle: NodeJS.Timeout | null = null;
147
-
148
- async function tick(): Promise<void> {
149
- if (stopped || inFlight) return;
150
- inFlight = true;
151
- try {
152
- const sample = await deps.sample();
153
- if (sample === null) return;
154
- const decision = planFocusTick({
155
- myPaneId: deps.myPaneId,
156
- sample,
157
- prev: state,
158
- now: now(),
159
- minIntervalMs: deps.minIntervalMs,
160
- });
161
- state = decision.state;
162
- if (decision.fire) deps.fire(deps.myPaneId, decision.cause);
163
- } catch (err) {
164
- // A failed sample must not advance the baseline: the next tick re-reads the layout.
165
- deps.onError?.(err);
166
- } finally {
167
- inFlight = false;
168
- }
169
- }
170
-
171
- if (intervalMs > 0) {
172
- handle = setInterval(() => { void tick(); }, intervalMs);
173
- handle.unref?.(); // never keep pi's event loop alive on our account
174
- }
175
-
176
- return {
177
- tick,
178
- stop(): void {
179
- stopped = true;
180
- if (handle !== null) {
181
- clearInterval(handle);
182
- handle = null;
183
- }
184
- },
185
- };
186
- }
187
-
188
- /** The workbench's reflow entry point; overridable for relocated checkouts. */
189
- export function reflowScriptPath(env: NodeJS.ProcessEnv = process.env): string {
190
- const root = env.PIER_WORKBENCH_ROOT?.trim();
191
- if (root) return join(root, 'scripts', 'heat-reflow.mjs');
192
- // packages/pier-ext/src/focus-poller.ts -> packages/pier-workbench/scripts/heat-reflow.mjs
193
- return join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'pier-workbench', 'scripts', 'heat-reflow.mjs');
194
- }
195
-
196
- export interface SpawnReflowOpts {
197
- paneId: string;
198
- cause: 'user' | null;
199
- env?: NodeJS.ProcessEnv;
200
- /** Injection seam for tests (defaults to child_process.spawn). */
201
- spawnFn?: typeof spawn;
202
- }
203
-
204
- /** Fire-and-forget replay of the `pane.focused` hook as a child process — exactly how herdr runs it,
205
- * so the workbench script keeps its own event parsing, debounce and state file. */
206
- export function spawnReflow(opts: SpawnReflowOpts): void {
207
- const env = opts.env ?? process.env;
208
- const spawnFn = opts.spawnFn ?? spawn;
209
- const payload = JSON.stringify({
210
- event: 'pane_focused',
211
- type: 'pane_focused',
212
- data: { type: 'pane_focused', pane_id: opts.paneId, cause: opts.cause },
213
- });
214
- try {
215
- const child = spawnFn(process.execPath, [reflowScriptPath(env)], {
216
- env: { ...env, HERDR_PLUGIN_EVENT: 'pane.focused', HERDR_PLUGIN_EVENT_JSON: payload },
217
- stdio: 'ignore',
218
- });
219
- child.on?.('error', () => { /* Missing script / dead exec: polling must never break pi. */ });
220
- child.unref?.();
221
- } catch {
222
- }
223
- }