pi-pier 0.2.3 → 0.2.5
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 +3 -4
- package/package.json +1 -1
- package/skills/pi-herdr-workflow/SKILL.md +149 -77
- package/src/core/subagent.ts +245 -261
- package/src/core/terminal.ts +200 -232
- package/src/core/todo.ts +10 -75
- package/src/index.ts +49 -22
- 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/subagent-session-io.ts +5 -1
- package/src/terminal-core.ts +3 -3
- package/src/todo-window.ts +82 -0
package/src/index.ts
CHANGED
|
@@ -7,10 +7,10 @@
|
|
|
7
7
|
* Capabilities:
|
|
8
8
|
* - `todo_write`: replace the complete todo list; the current pi session JSONL is authoritative,
|
|
9
9
|
* and branches automatically roll back in DSH-aligned semantics.
|
|
10
|
-
* - `subagent`: foreground/background delegation
|
|
11
|
-
* with an independent session/context that humans
|
|
12
|
-
* herdr for state-gated injection (idle only),
|
|
13
|
-
*
|
|
10
|
+
* - `subagent`: foreground/background delegation plus resume/list/send/interrupt actions.
|
|
11
|
+
* Each child pane is an interactive pi TUI with an independent session/context that humans
|
|
12
|
+
* can enter directly; the controller uses herdr for state-gated injection (idle only),
|
|
13
|
+
* waiting (agent.wait), and JSONL result reads.
|
|
14
14
|
* - `/todos`, TUI widget, and herdr title projection with graceful degradation without herdr.
|
|
15
15
|
*
|
|
16
16
|
* pi 0.84.2 contract (validated):
|
|
@@ -111,9 +111,14 @@ export default async function (pi: ExtensionAPI) {
|
|
|
111
111
|
|
|
112
112
|
/** Tier 2: worker role badge parsed from the env manifest and kept visible while idle. */
|
|
113
113
|
let roleBadge: string | null = null;
|
|
114
|
+
/** Human-gate depth; ask_user_question and external herdr:blocked events share it. */
|
|
115
|
+
let blockedDepth = 0;
|
|
114
116
|
|
|
115
117
|
function reportAgent(state: 'working' | 'idle' | 'blocked', activity: string | null): void {
|
|
116
118
|
if (!client.available) return;
|
|
119
|
+
// While a human gate is open, never let tool-badge / turn_start / settle overwrite blocked.
|
|
120
|
+
// Needed when herdr:pi is absent (pi-herdr is the authority); no-op under full-lifecycle herdr:pi.
|
|
121
|
+
if (blockedDepth > 0 && state !== 'blocked') return;
|
|
117
122
|
const message = activity ?? (state === 'idle' ? roleBadge : null);
|
|
118
123
|
client.reportAgent(state, message).catch(() => {});
|
|
119
124
|
}
|
|
@@ -156,8 +161,8 @@ export default async function (pi: ExtensionAPI) {
|
|
|
156
161
|
progressSuffix: suffix,
|
|
157
162
|
lastWriteAt: todos.lastWriteAt,
|
|
158
163
|
});
|
|
159
|
-
// D97:
|
|
160
|
-
updateSlimFrame(title);
|
|
164
|
+
// D97: overlay content shares the todo snapshot; thinking tokens cannot reach it.
|
|
165
|
+
updateSlimFrame({ title, items: todos.items, lastWriteAt: todos.lastWriteAt });
|
|
161
166
|
client.reportMetadata({
|
|
162
167
|
session: label,
|
|
163
168
|
items: todos.items,
|
|
@@ -369,15 +374,16 @@ export default async function (pi: ExtensionAPI) {
|
|
|
369
374
|
if (plan.notice && !isSubagent) {
|
|
370
375
|
const running = subagentPort.current?.listRunningSubs() ?? [];
|
|
371
376
|
const brief = running.map((s) => `${s.paneId} (${s.description})`).join('、');
|
|
372
|
-
const notice = `注意:仍有 ${running.length} 个后台 subagent 在运行:${brief}。若你的任务依赖它们,请等待其结算(
|
|
377
|
+
const notice = `注意:仍有 ${running.length} 个后台 subagent 在运行:${brief}。若你的任务依赖它们,请等待其结算(subagent list 查看状态);若不等待,请说明放弃原因。`;
|
|
373
378
|
void sendUserMessageIn(notice);
|
|
374
379
|
}
|
|
375
380
|
});
|
|
376
381
|
|
|
377
|
-
//
|
|
378
|
-
//
|
|
379
|
-
//
|
|
380
|
-
|
|
382
|
+
// Official herdr:pi is full-lifecycle authority when installed: it reports blocked
|
|
383
|
+
// only on the herdr:blocked event (pi core never emits it). Custom source=pi-herdr
|
|
384
|
+
// pane.report_agent is ignored while that authority is live
|
|
385
|
+
// (screen_detection_skip_reason=full_lifecycle_hook_authority). Emit the event so
|
|
386
|
+
// herdr:pi publishes blocked; keep report_agent as fallback when it is absent.
|
|
381
387
|
function enterBlocked(label: string | null): void {
|
|
382
388
|
blockedDepth += 1;
|
|
383
389
|
reportAgent('blocked', label);
|
|
@@ -392,14 +398,34 @@ export default async function (pi: ExtensionAPI) {
|
|
|
392
398
|
void client.reportAskFlag(null).catch(() => {});
|
|
393
399
|
}
|
|
394
400
|
}
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
401
|
+
|
|
402
|
+
// emit() is synchronous; this flag stops our own listener from double-counting depth.
|
|
403
|
+
let publishingHerdrBlocked = false;
|
|
404
|
+
function publishHerdrBlocked(active: boolean, label: string | null): void {
|
|
405
|
+
publishingHerdrBlocked = true;
|
|
406
|
+
try {
|
|
407
|
+
if (active) enterBlocked(label);
|
|
400
408
|
else exitBlocked();
|
|
401
|
-
|
|
402
|
-
|
|
409
|
+
pi.events.emit(
|
|
410
|
+
'herdr:blocked',
|
|
411
|
+
active ? { active: true, ...(label ? { label } : {}) } : { active: false },
|
|
412
|
+
);
|
|
413
|
+
} finally {
|
|
414
|
+
publishingHerdrBlocked = false;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
pi.events.on('herdr:blocked', (data) => {
|
|
418
|
+
if (publishingHerdrBlocked) return;
|
|
419
|
+
if (!data || typeof data !== 'object') {
|
|
420
|
+
exitBlocked();
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
if (!('active' in data) || data.active !== true) {
|
|
424
|
+
exitBlocked();
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
enterBlocked('label' in data && typeof data.label === 'string' ? data.label : null);
|
|
428
|
+
});
|
|
403
429
|
|
|
404
430
|
/* ── Tool: ask_user_question (v1.3 M8 human gate, available to master and subagents) ── */
|
|
405
431
|
|
|
@@ -423,9 +449,10 @@ export default async function (pi: ExtensionAPI) {
|
|
|
423
449
|
const ui = (ctx as {
|
|
424
450
|
ui?: { input?: (title: string, placeholder?: string) => Promise<string | undefined> };
|
|
425
451
|
}).ui;
|
|
426
|
-
|
|
427
|
-
//
|
|
428
|
-
//
|
|
452
|
+
publishHerdrBlocked(true, question);
|
|
453
|
+
// Fallback when herdr:pi is absent: a one-shot pi-herdr blocked report can lose
|
|
454
|
+
// to later working reports; refresh while the gate is open. Do not re-emit
|
|
455
|
+
// herdr:blocked here — official blockedCount is edge-triggered.
|
|
429
456
|
const hb = setInterval(() => { reportAgent('blocked', question); }, 5000);
|
|
430
457
|
try {
|
|
431
458
|
const answer = await ui?.input?.(question, 'your answer');
|
|
@@ -435,7 +462,7 @@ export default async function (pi: ExtensionAPI) {
|
|
|
435
462
|
};
|
|
436
463
|
} finally {
|
|
437
464
|
clearInterval(hb);
|
|
438
|
-
|
|
465
|
+
publishHerdrBlocked(false, null);
|
|
439
466
|
}
|
|
440
467
|
},
|
|
441
468
|
});
|
package/src/notice-buffer.ts
CHANGED
|
@@ -12,12 +12,12 @@ export const NOTICE_MAX_SHOWN = 3;
|
|
|
12
12
|
* Collapse a batch into one message body.
|
|
13
13
|
* - empty → null (do not inject)
|
|
14
14
|
* - ≤max → originals joined with `\n\n` (single-item byte-identical to the old format)
|
|
15
|
-
* - >max → first max originals + a tail pointing at history /
|
|
15
|
+
* - >max → first max originals + a tail pointing at history / subagent list
|
|
16
16
|
*/
|
|
17
17
|
export function collapseNotices(contents: readonly string[], max = NOTICE_MAX_SHOWN): string | null {
|
|
18
18
|
if (contents.length === 0) return null;
|
|
19
19
|
if (contents.length <= max) return contents.join('\n\n');
|
|
20
20
|
const hidden = contents.length - max;
|
|
21
|
-
const tail = `…另有 ${hidden} 条结算未逐条展示。全量结果看 history 台账(路径公式见
|
|
21
|
+
const tail = `…另有 ${hidden} 条结算未逐条展示。全量结果看 history 台账(路径公式见 subagent resume 工具描述);在跑代理用 subagent list 查看。`;
|
|
22
22
|
return [...contents.slice(0, max), tail].join('\n\n');
|
|
23
23
|
}
|
package/src/roles/master.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"role": "master",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "主控角色(WS-D7 自应用;D83
|
|
3
|
+
"version": "1.4.0",
|
|
4
|
+
"description": "主控角色(WS-D7 自应用;D83 镜像源):核心工具(todos/协调/subagent/terminal)+ pi 内建 + web_search;D82 unknownTools=allow。v1.4.0: 工具合并 terminal 6→1, subagent 5→1",
|
|
5
5
|
"manifest": {
|
|
6
6
|
"unknownTools": "allow",
|
|
7
7
|
"tools": [
|
|
8
8
|
"todo_write", "ask_user_question",
|
|
9
|
-
"subagent",
|
|
10
|
-
"
|
|
9
|
+
"subagent",
|
|
10
|
+
"terminal",
|
|
11
11
|
"read", "grep", "glob", "bash", "pwsh", "edit", "write",
|
|
12
12
|
"web_search"
|
|
13
13
|
],
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"role": "worker-default",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "默认worker角色(D83 继承制):镜像 master.tools 减 subagent
|
|
3
|
+
"version": "1.4.0",
|
|
4
|
+
"description": "默认worker角色(D83 继承制):镜像 master.tools 减 subagent/terminal(禁嵌套、资源归属);排除族显式 deny——在 D82 unknownTools=allow 姿态下仍持禁令;todos serial。v1.4.0: 工具合并后更新 deny 列表",
|
|
5
5
|
"manifest": {
|
|
6
6
|
"unknownTools": "allow",
|
|
7
7
|
"tools": [
|
|
@@ -11,16 +11,7 @@
|
|
|
11
11
|
],
|
|
12
12
|
"rules": {
|
|
13
13
|
"subagent": "deny",
|
|
14
|
-
"
|
|
15
|
-
"list_agents": "deny",
|
|
16
|
-
"send_message": "deny",
|
|
17
|
-
"interrupt_agent": "deny",
|
|
18
|
-
"terminal_open": "deny",
|
|
19
|
-
"terminal_send": "deny",
|
|
20
|
-
"terminal_read": "deny",
|
|
21
|
-
"terminal_signal": "deny",
|
|
22
|
-
"terminal_close": "deny",
|
|
23
|
-
"terminal_list": "deny",
|
|
14
|
+
"terminal": "deny",
|
|
24
15
|
"*": "allow"
|
|
25
16
|
}
|
|
26
17
|
},
|
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 {
|
|
@@ -122,7 +122,11 @@ export function createSessionIo(h: SessionIoHost): SessionIo {
|
|
|
122
122
|
): Promise<{ text: string | null; pendingTool: boolean; activity: boolean }> {
|
|
123
123
|
for (const file of await resolveSessionFileCandidates(paneId, cwd)) {
|
|
124
124
|
const entries = readSessionFile(file);
|
|
125
|
-
|
|
125
|
+
// readSessionFile returns null when the path is missing or unreadable.
|
|
126
|
+
// herdr often reports a .jsonl path before the worker creates the file;
|
|
127
|
+
// waitAgent(idle) then returns immediately and this path used to throw
|
|
128
|
+
// `Cannot read properties of null (reading 'length')` (session 01a055c5).
|
|
129
|
+
if (!entries?.length) continue;
|
|
126
130
|
const r = lastAssistantText(entries, { sinceTs });
|
|
127
131
|
if (r?.text) return { text: r.text, pendingTool: false, activity: true };
|
|
128
132
|
if (hasPendingToolCall(entries, sinceTs)) return { text: null, pendingTool: true, activity: true };
|
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
|
+
}
|