pi-pier 0.2.4 → 0.2.6
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 +37 -8
- package/package.json +1 -1
- package/skills/pi-herdr-workflow/SKILL.md +96 -15
- package/src/core/subagent.ts +246 -332
- package/src/core/terminal.ts +200 -232
- package/src/core/todo.ts +78 -79
- package/src/index-master.ts +4 -2
- package/src/index-runtime.ts +6 -2
- package/src/index-worker.ts +11 -5
- package/src/index.ts +72 -31
- package/src/notice-buffer.ts +2 -2
- package/src/roles/master.json +4 -4
- package/src/roles/worker-default.json +3 -12
- package/src/slim-frame.ts +120 -11
- package/src/subagent-core.ts +3 -3
- package/src/subagent-poll-loop.ts +1 -1
- package/src/terminal-core.ts +3 -3
- package/src/todo-window.ts +82 -0
package/src/slim-frame.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* D97 slim frame: when a pane is too narrow or short for the minimum usable TUI,
|
|
3
|
-
* cover it with a non-capturing
|
|
3
|
+
* cover it with a non-capturing overlay. Content is three-tier: full TUI (overlay
|
|
4
|
+
* hidden), an activity-anchored todo window when ≥3 rows fit, else the pane title.
|
|
4
5
|
*
|
|
5
6
|
* Why (observed by the user): after the heat view compresses an unfocused pane, streaming worker thinking
|
|
6
7
|
* continuously repaints the entire screen in the narrow pane, causing flicker. The static frame updates only on
|
|
@@ -22,11 +23,21 @@
|
|
|
22
23
|
* flow. PI_HERDR_SLIM_FRAME=0 is the escape hatch.
|
|
23
24
|
*/
|
|
24
25
|
|
|
26
|
+
import { isArchived } from './stale-core.ts';
|
|
27
|
+
import type { TodoItem } from './vocab.ts';
|
|
28
|
+
import { anchorTodoRange, formatTodoSummary, renderTodoGroups } from './todo-window.ts';
|
|
29
|
+
|
|
25
30
|
/** Minimum usable TUI dimensions (pi interactive-mode layout: editor 3 rows + footer 1 + transcript ≥3 + spacing).
|
|
26
|
-
* Below either threshold the pane cannot display a readable TUI, so
|
|
31
|
+
* Below either threshold the pane cannot display a readable TUI, so the overlay covers it. */
|
|
27
32
|
|
|
28
33
|
export const SLIM_MIN_COLS = 24;
|
|
29
34
|
export const SLIM_MIN_ROWS = 12;
|
|
35
|
+
/** Overlay todo window needs this many rows after wrap; below that, fall back to the title frame. */
|
|
36
|
+
export const SLIM_TODO_MIN_ROWS = 3;
|
|
37
|
+
/** Narrower than this, todo lines wrap into a column; keep the title frame instead. */
|
|
38
|
+
export const SLIM_TODO_MIN_COLS = 16;
|
|
39
|
+
/** Empty-list copy: the pane is alive but has no plan yet (replaces a lone ·). */
|
|
40
|
+
export const SLIM_EMPTY_COPY = 'no todos yet';
|
|
30
41
|
|
|
31
42
|
/** Visibility predicate (overlayOptions.visible invokes it on every render frame). */
|
|
32
43
|
export function isSlimFrame(cols: number, rows: number): boolean {
|
|
@@ -115,6 +126,93 @@ export function frameLines(
|
|
|
115
126
|
return out;
|
|
116
127
|
}
|
|
117
128
|
|
|
129
|
+
function padFrame(
|
|
130
|
+
content: readonly string[],
|
|
131
|
+
opts: { width: number; rows: number; colorize: (s: string) => string; vAlign: 'center' | 'top' },
|
|
132
|
+
): string[] {
|
|
133
|
+
const width = Math.max(1, Math.floor(opts.width));
|
|
134
|
+
const rows = Math.max(1, Math.floor(opts.rows));
|
|
135
|
+
const pad = (s: string) => s + ' '.repeat(Math.max(0, width - displayWidth(s)));
|
|
136
|
+
const body = content.slice(0, rows);
|
|
137
|
+
const top = opts.vAlign === 'center' ? Math.max(0, Math.floor((rows - body.length) / 2)) : 0;
|
|
138
|
+
const out: string[] = [];
|
|
139
|
+
for (let i = 0; i < rows; i++) {
|
|
140
|
+
const idx = i - top;
|
|
141
|
+
out.push(idx >= 0 && idx < body.length ? pad(opts.colorize(body[idx])) : ' '.repeat(width));
|
|
142
|
+
}
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function wrapTodoBody(items: readonly TodoItem[], width: number): string[] {
|
|
147
|
+
return renderTodoGroups(items).flatMap((line) => wrapByWidth(line, width));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function itemsEqual(a: readonly TodoItem[], b: readonly TodoItem[]): boolean {
|
|
151
|
+
if (a.length !== b.length) return false;
|
|
152
|
+
for (let i = 0; i < a.length; i++) {
|
|
153
|
+
const x = a[i];
|
|
154
|
+
const y = b[i];
|
|
155
|
+
if (x.content !== y.content || x.status !== y.status || x.blocker !== y.blocker || x.phase !== y.phase) {
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export interface SlimFrameInput {
|
|
163
|
+
title?: string | null;
|
|
164
|
+
items?: readonly TodoItem[];
|
|
165
|
+
lastWriteAt?: number | null;
|
|
166
|
+
now?: number;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Choose overlay content: todo window when the pane can show ≥3 wrapped rows at ≥16 cols;
|
|
171
|
+
* otherwise the pane title (or SLIM_EMPTY_COPY when the list is empty).
|
|
172
|
+
*/
|
|
173
|
+
export function slimContentLines(
|
|
174
|
+
input: SlimFrameInput & { width: number; rows: number; colorize?: (s: string) => string },
|
|
175
|
+
): string[] {
|
|
176
|
+
const width = Math.max(1, Math.floor(input.width));
|
|
177
|
+
const rows = Math.max(1, Math.floor(input.rows));
|
|
178
|
+
const colorize = input.colorize ?? ((s: string) => s);
|
|
179
|
+
const items = input.items ?? [];
|
|
180
|
+
const title = (input.title ?? '').trim();
|
|
181
|
+
const fallback = title || SLIM_EMPTY_COPY;
|
|
182
|
+
const titleFrame = () => frameLines(fallback, { width, rows, colorize });
|
|
183
|
+
|
|
184
|
+
if (rows < SLIM_TODO_MIN_ROWS || width < SLIM_TODO_MIN_COLS) return titleFrame();
|
|
185
|
+
if (items.length === 0) return titleFrame();
|
|
186
|
+
if (isArchived(items, input.lastWriteAt ?? null, input.now ?? Date.now())) return titleFrame();
|
|
187
|
+
|
|
188
|
+
const header = wrapByWidth(formatTodoSummary(items), width);
|
|
189
|
+
const fullBody = wrapTodoBody(items, width);
|
|
190
|
+
if (header.length + fullBody.length <= rows) {
|
|
191
|
+
const packed = [...header, ...fullBody];
|
|
192
|
+
if (packed.length < SLIM_TODO_MIN_ROWS) return titleFrame();
|
|
193
|
+
return padFrame(packed, { width, rows, colorize, vAlign: 'top' });
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const footerProbe = wrapByWidth(' +99 hidden (99✓) · /todos', width);
|
|
197
|
+
const itemBudget = rows - header.length - footerProbe.length;
|
|
198
|
+
if (itemBudget < 1) return titleFrame();
|
|
199
|
+
|
|
200
|
+
const [start, end] = anchorTodoRange(
|
|
201
|
+
items,
|
|
202
|
+
(s, e) => wrapTodoBody(items.slice(s, e), width).length <= itemBudget,
|
|
203
|
+
);
|
|
204
|
+
const kept = items.slice(start, end);
|
|
205
|
+
const body = wrapTodoBody(kept, width);
|
|
206
|
+
if (body.length === 0 || body.length > itemBudget) return titleFrame();
|
|
207
|
+
|
|
208
|
+
const hidden = items.filter((_, i) => i < start || i >= end);
|
|
209
|
+
const hiddenCompleted = hidden.filter((it) => it.status === 'completed').length;
|
|
210
|
+
const footer = wrapByWidth(` +${hidden.length} hidden (${hiddenCompleted}✓) · /todos`, width);
|
|
211
|
+
const packed = [...header, ...body, ...footer];
|
|
212
|
+
if (packed.length < SLIM_TODO_MIN_ROWS) return titleFrame();
|
|
213
|
+
return padFrame(packed, { width, rows, colorize, vAlign: 'top' });
|
|
214
|
+
}
|
|
215
|
+
|
|
118
216
|
/* ── Overlay component plus registration/update (process-local singleton) ─────────────────────────── */
|
|
119
217
|
|
|
120
218
|
interface FrameTui {
|
|
@@ -122,7 +220,9 @@ interface FrameTui {
|
|
|
122
220
|
}
|
|
123
221
|
|
|
124
222
|
class SlimFrameComponent {
|
|
125
|
-
private
|
|
223
|
+
private title = '';
|
|
224
|
+
private items: readonly TodoItem[] = [];
|
|
225
|
+
private lastWriteAt: number | null = null;
|
|
126
226
|
private lastRows = 0;
|
|
127
227
|
private cache: { width: number; lines: string[] } | null = null;
|
|
128
228
|
private readonly tui: FrameTui;
|
|
@@ -137,21 +237,30 @@ class SlimFrameComponent {
|
|
|
137
237
|
onViewport(cols: number, rows: number): boolean {
|
|
138
238
|
if (this.lastRows !== rows) {
|
|
139
239
|
this.lastRows = rows;
|
|
140
|
-
this.cache = null; // A row-count change requires re-
|
|
240
|
+
this.cache = null; // A row-count change requires re-layout.
|
|
141
241
|
}
|
|
142
242
|
return isSlimFrame(cols, rows);
|
|
143
243
|
}
|
|
144
244
|
|
|
145
|
-
|
|
146
|
-
if (this.
|
|
147
|
-
this.
|
|
245
|
+
setSnapshot(title: string, items: readonly TodoItem[], lastWriteAt: number | null): void {
|
|
246
|
+
if (this.title === title && this.lastWriteAt === lastWriteAt && itemsEqual(this.items, items)) return;
|
|
247
|
+
this.title = title;
|
|
248
|
+
this.items = items;
|
|
249
|
+
this.lastWriteAt = lastWriteAt;
|
|
148
250
|
this.cache = null;
|
|
149
251
|
this.tui.requestRender();
|
|
150
252
|
}
|
|
151
253
|
|
|
152
254
|
render(width: number): string[] {
|
|
153
255
|
if (this.cache && this.cache.width === width) return this.cache.lines;
|
|
154
|
-
const lines =
|
|
256
|
+
const lines = slimContentLines({
|
|
257
|
+
title: this.title,
|
|
258
|
+
items: this.items,
|
|
259
|
+
lastWriteAt: this.lastWriteAt,
|
|
260
|
+
width,
|
|
261
|
+
rows: this.lastRows,
|
|
262
|
+
colorize: this.colorize,
|
|
263
|
+
});
|
|
155
264
|
this.cache = { width, lines };
|
|
156
265
|
return lines;
|
|
157
266
|
}
|
|
@@ -214,7 +323,7 @@ export function registerSlimFrame(eventCtx: unknown): void {
|
|
|
214
323
|
}
|
|
215
324
|
}
|
|
216
325
|
|
|
217
|
-
/** Update
|
|
218
|
-
export function updateSlimFrame(
|
|
219
|
-
active?.
|
|
326
|
+
/** Update overlay snapshot. Title remains the fallback; items drive the todo window. */
|
|
327
|
+
export function updateSlimFrame(input: SlimFrameInput): void {
|
|
328
|
+
active?.setSnapshot(input.title ?? '', input.items ?? [], input.lastWriteAt ?? null);
|
|
220
329
|
}
|
package/src/subagent-core.ts
CHANGED
|
@@ -122,13 +122,13 @@ export function buildAliveNotice(
|
|
|
122
122
|
`Subagent "${description}" is still running in pane ${paneId} (agent_status=${status}${activity}).`,
|
|
123
123
|
'The foreground wait has moved it to background so this call returns now.',
|
|
124
124
|
'Do NOT redo its work. When it settles you will receive a notification with its closing output.',
|
|
125
|
-
'Use
|
|
125
|
+
'Use subagent(action: "list") to check its live state; subagent(action: "send") to give it follow-up work.',
|
|
126
126
|
].join(' ');
|
|
127
127
|
}
|
|
128
128
|
return [
|
|
129
129
|
`The subagent "${description}" is ALIVE in pane ${paneId} (agent_status=${status}${activity}) — this "no readable output" result only means its final answer could not be read yet, NOT that the task failed.`,
|
|
130
130
|
'Do NOT redo its work. It is being moved to background; you will be notified when it settles.',
|
|
131
|
-
'Use
|
|
131
|
+
'Use subagent(action: "list") to check its live state.',
|
|
132
132
|
].join(' ');
|
|
133
133
|
}
|
|
134
134
|
|
|
@@ -141,7 +141,7 @@ export function buildBlockedGateNotice(opts: { paneId: string; description: stri
|
|
|
141
141
|
'It keeps running in background; once the human answers it resumes, and you will be notified when it settles.',
|
|
142
142
|
'Do NOT take over its work and do NOT answer on the human\'s behalf.',
|
|
143
143
|
'Tell the user to open that pane in herdr and answer directly.',
|
|
144
|
-
'Only if the user explicitly authorizes you: relay their decision to the subagent via
|
|
144
|
+
'Only if the user explicitly authorizes you: relay their decision to the subagent via subagent(action: "send") — do not redo the work yourself.',
|
|
145
145
|
].join(' ');
|
|
146
146
|
}
|
|
147
147
|
|
|
@@ -245,7 +245,7 @@ export function createPoller(h: PollerHost): Poller {
|
|
|
245
245
|
h.writeHistory(entry, { outcome: 'observation timeout' }, 'poll-timeout');
|
|
246
246
|
const notes = h.reconcileOnSettlement(description, 'failed');
|
|
247
247
|
const notice = h.withReconcileNotes(
|
|
248
|
-
`Background subagent ${paneId} (${description}) has shown no progress for ${Math.round((Date.now() - lastActivityAt) / 1000)}s (observed since ${new Date(startedAt).toISOString()}). Run
|
|
248
|
+
`Background subagent ${paneId} (${description}) has shown no progress for ${Math.round((Date.now() - lastActivityAt) / 1000)}s (observed since ${new Date(startedAt).toISOString()}). Run subagent(action: "list") to check its live state; if it is working, let it run — its settlement notice will arrive automatically. Do not sleep-wait.`,
|
|
249
249
|
notes,
|
|
250
250
|
);
|
|
251
251
|
try {
|
package/src/terminal-core.ts
CHANGED
|
@@ -96,7 +96,7 @@ export function registerTerminal(
|
|
|
96
96
|
if (openCount >= MAX_TERMINALS) {
|
|
97
97
|
return {
|
|
98
98
|
ok: false,
|
|
99
|
-
error: `terminal limit reached (max ${MAX_TERMINALS} open terminals); close one with
|
|
99
|
+
error: `terminal limit reached (max ${MAX_TERMINALS} open terminals); close one with terminal(action: "close") before opening another`,
|
|
100
100
|
};
|
|
101
101
|
}
|
|
102
102
|
const entry: TerminalEntry = {
|
|
@@ -143,7 +143,7 @@ export function validateSendText(text: string): { ok: true; text: string } | { o
|
|
|
143
143
|
if (text.includes('\x1b')) {
|
|
144
144
|
return {
|
|
145
145
|
ok: false,
|
|
146
|
-
error: 'unsupported input: ANSI escape sequences cannot be sent as text; use
|
|
146
|
+
error: 'unsupported input: ANSI escape sequences cannot be sent as text; use terminal(action: "signal") for control keys, or plain commands only',
|
|
147
147
|
};
|
|
148
148
|
}
|
|
149
149
|
// Why: Preserve the established compatibility and safety behavior (C0).
|
|
@@ -152,7 +152,7 @@ export function validateSendText(text: string): { ok: true; text: string } | { o
|
|
|
152
152
|
if (bad) {
|
|
153
153
|
return {
|
|
154
154
|
ok: false,
|
|
155
|
-
error: `unsupported input: control character 0x${bad[0].charCodeAt(0).toString(16)};
|
|
155
|
+
error: `unsupported input: control character 0x${bad[0].charCodeAt(0).toString(16)}; terminal send accepts plain text commands only (use terminal(action: "signal") for ctrl+c etc.)`,
|
|
156
156
|
};
|
|
157
157
|
}
|
|
158
158
|
return { ok: true, text: stripped };
|
|
@@ -0,0 +1,82 @@
|
|
|
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.
|
|
7
|
+
*/
|
|
8
|
+
import { countTodos, type TodoItem, type TodoStatus } from './vocab.ts';
|
|
9
|
+
|
|
10
|
+
export const TODO_MARKS: Record<TodoStatus, string> = {
|
|
11
|
+
pending: '○',
|
|
12
|
+
in_progress: '▶',
|
|
13
|
+
completed: '✓',
|
|
14
|
+
blocked: '■',
|
|
15
|
+
abandoned: '✗',
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function formatTodoSummary(items: readonly TodoItem[]): string {
|
|
19
|
+
const c = countTodos(items as TodoItem[]);
|
|
20
|
+
return `todo: ${c.inProgress}▶ ${c.pending}○ ${c.blocked}■ ${c.completed}✓`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Share phase rendering so widget and overlay order entries identically. */
|
|
24
|
+
export function renderTodoGroups(items: readonly TodoItem[]): string[] {
|
|
25
|
+
const groups = new Map<string, TodoItem[]>();
|
|
26
|
+
for (const it of items) {
|
|
27
|
+
const key = it.phase ?? '';
|
|
28
|
+
const arr = groups.get(key) ?? [];
|
|
29
|
+
arr.push(it);
|
|
30
|
+
groups.set(key, arr);
|
|
31
|
+
}
|
|
32
|
+
const lines: string[] = [];
|
|
33
|
+
for (const [phase, list] of groups) {
|
|
34
|
+
if (phase) lines.push(` [${phase}]`);
|
|
35
|
+
for (const it of list) {
|
|
36
|
+
const suffix = it.status === 'blocked' && it.blocker ? ` — ${it.blocker}` : '';
|
|
37
|
+
lines.push(` ${TODO_MARKS[it.status]} ${it.content}${suffix}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return lines;
|
|
41
|
+
}
|
|
42
|
+
|
|
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).
|
|
47
|
+
*/
|
|
48
|
+
export function anchorTodoRange(
|
|
49
|
+
items: readonly TodoItem[],
|
|
50
|
+
fits: (start: number, end: number) => boolean,
|
|
51
|
+
): [number, number] {
|
|
52
|
+
if (items.length === 0) return [0, 0];
|
|
53
|
+
if (fits(0, items.length)) return [0, items.length];
|
|
54
|
+
const anchor = items.findIndex((it) => it.status === 'in_progress');
|
|
55
|
+
const anchorIdx = anchor >= 0
|
|
56
|
+
? anchor
|
|
57
|
+
: items.reduce((acc, it, i) => (it.status !== 'completed' ? i : acc), -1);
|
|
58
|
+
if (anchorIdx < 0) {
|
|
59
|
+
let end = items.length;
|
|
60
|
+
while (end > 1 && !fits(0, end)) end -= 1;
|
|
61
|
+
return [0, end];
|
|
62
|
+
}
|
|
63
|
+
let start = anchorIdx;
|
|
64
|
+
let end = anchorIdx + 1;
|
|
65
|
+
let growTail = true;
|
|
66
|
+
while ((end < items.length || start > 0) && (end - start) < items.length) {
|
|
67
|
+
if (growTail && end < items.length) {
|
|
68
|
+
end += 1;
|
|
69
|
+
if (!fits(start, end)) { end -= 1; break; }
|
|
70
|
+
} else if (start > 0) {
|
|
71
|
+
start -= 1;
|
|
72
|
+
if (!fits(start, end)) { start += 1; break; }
|
|
73
|
+
} else if (end < items.length) {
|
|
74
|
+
end += 1;
|
|
75
|
+
if (!fits(start, end)) { end -= 1; break; }
|
|
76
|
+
} else {
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
growTail = !growTail;
|
|
80
|
+
}
|
|
81
|
+
return [start, end];
|
|
82
|
+
}
|