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.
- package/README.md +7 -5
- package/package.json +3 -3
- package/schemas/efficiency-config.schema.json +6 -0
- package/src/ask-dialog.ts +9 -2
- package/src/ask-user.ts +142 -74
- package/src/bootstrap.ts +2 -1
- package/src/compact-coordinator.ts +207 -35
- package/src/compact-economics-core.ts +62 -29
- package/src/config-catalog-core.ts +1 -0
- package/src/cordis-services.ts +64 -0
- package/src/dashboard-command.ts +40 -15
- package/src/delegation-core.ts +115 -0
- package/src/efficiency-config-core.ts +17 -6
- package/src/efficiency-store.ts +43 -6
- package/src/guards.ts +6 -0
- package/src/herdr-client.ts +141 -36
- package/src/history-store.ts +2 -0
- package/src/index-gates.ts +6 -12
- package/src/index-master.ts +12 -16
- package/src/index-pipe.ts +6 -0
- package/src/index-runtime.ts +14 -0
- package/src/index-worker.ts +4 -10
- package/src/index.ts +310 -76
- package/src/jev-client.ts +32 -7
- package/src/jev-core.ts +71 -36
- package/src/observation-core.ts +13 -1
- package/src/panel-open.ts +47 -0
- package/src/pier-options.ts +1 -2
- package/src/pipe-channel.ts +9 -0
- package/src/plugins/heat-plan.ts +18 -3
- package/src/plugins/observation.ts +118 -46
- package/src/plugins/subagent.ts +119 -68
- package/src/plugins/terminal.ts +221 -57
- package/src/plugins/todo.ts +63 -63
- package/src/reducer-core.ts +3 -5
- package/src/reducer-invoker.ts +27 -13
- package/src/renderers.ts +25 -2
- package/src/role-manifest.ts +2 -3
- package/src/roles/worker-default.json +2 -2
- package/src/settle-wake-core.ts +17 -3
- package/src/subagent-core.ts +209 -3
- package/src/subagent-poller.ts +69 -19
- package/src/subagent-session.ts +67 -4
- package/src/subagent-spawn.ts +55 -7
- package/src/terminal-core.ts +178 -50
- package/src/todo-core.ts +47 -2
- package/src/todo-read-hook.ts +54 -17
- package/src/todo-reminder-core.ts +9 -5
- package/src/todo-window.ts +8 -4
- package/src/todos-service.ts +12 -0
- package/src/tree-view.ts +162 -0
- package/src/vocab.ts +7 -1
- package/src/worker-marker.ts +76 -0
- package/src/focus-poller.ts +0 -223
package/src/todo-read-hook.ts
CHANGED
|
@@ -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
|
|
5
|
-
*
|
|
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 {
|
|
10
|
+
import { todoLine } from './todo-window.ts';
|
|
11
11
|
|
|
12
|
-
/** Cadence shared by empty-list guards, stale warnings, and archive
|
|
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
|
-
|
|
84
|
-
|
|
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
|
|
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:
|
|
134
|
+
inject: true,
|
|
99
135
|
effect: 'archive-notice',
|
|
100
136
|
archived: true,
|
|
101
|
-
clearArchived:
|
|
137
|
+
clearArchived: true,
|
|
138
|
+
offerArchiveWindow: false,
|
|
102
139
|
message: msg(
|
|
103
|
-
|
|
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) => ` ${
|
|
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) =>
|
|
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
|
|
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
|
-
|
|
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;
|
package/src/todo-window.ts
CHANGED
|
@@ -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
|
}
|
package/src/todos-service.ts
CHANGED
|
@@ -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
|
}
|
package/src/tree-view.ts
ADDED
|
@@ -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
|
+
}
|