pi-pier 0.1.2 → 0.1.3
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 +23 -5
- package/package.json +1 -1
- package/src/core/subagent.ts +157 -60
- package/src/core/todo.ts +49 -22
- package/src/subagent-core.ts +70 -0
- package/src/todo-core.ts +25 -11
package/README.md
CHANGED
|
@@ -1,22 +1,40 @@
|
|
|
1
1
|
# pi-pier
|
|
2
2
|
|
|
3
|
+
> [!IMPORTANT]
|
|
4
|
+
> **This npm package is only the pi-extension half of pier.** pier is a two-half system — this extension plus a **herdr plugin** (`pier.workbench`). Without the herdr side you still get `todo_write` / `ask_user_question`, but subagents, pane integration, notifications, and the workspace bootstrap all require herdr. See [Install the herdr half](#install-the-herdr-half-companion-plugin) below.
|
|
5
|
+
|
|
3
6
|
**pier** = **pi** × h**erdr** — a [pi](https://pi.dev/) extension that fuses coding agent sessions with the [herdr](https://herdr.dev/) pane/tab orchestrator.
|
|
4
7
|
|
|
5
|
-
`pi-pier`
|
|
8
|
+
`pi-pier` fills two gaps pi deliberately leaves open — the **todo list loop** and **interactive subagents** — using herdr panes/tabs as the visual and interaction substrate. It degrades gracefully outside herdr.
|
|
6
9
|
|
|
7
|
-
## Install
|
|
10
|
+
## Install (this pi half)
|
|
8
11
|
|
|
9
12
|
```sh
|
|
10
13
|
pi install npm:pi-pier
|
|
11
14
|
```
|
|
12
15
|
|
|
13
|
-
Or from git:
|
|
16
|
+
Or from git (monorepo root, same content):
|
|
14
17
|
|
|
15
18
|
```sh
|
|
16
19
|
pi install git:github.com/July24/pier
|
|
17
20
|
```
|
|
18
21
|
|
|
19
|
-
|
|
22
|
+
## Install the herdr half (companion plugin)
|
|
23
|
+
|
|
24
|
+
The herdr plugin `pier.workbench` provides the other half: main-tab bootstrap (auto-start a pi master session per workspace), blocked-gate notifications (system notifications when a subagent waits for a human), and the focus-heat layout (focused pane grows in place). Install it from the same repo:
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
herdr plugin install July24/pier/packages/pier-workbench --yes
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Or install **both halves at once** with the one-shot installer from the monorepo:
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
git clone https://github.com/July24/pier && cd pier
|
|
34
|
+
node install.mjs # user mode: installs both halves + generates local bootstrap config
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
The installer also verifies node / pi / herdr versions, probes local paths, and writes the workbench bootstrap config (`boot-config.json`) into herdr's plugin config dir.
|
|
20
38
|
|
|
21
39
|
## What you get
|
|
22
40
|
|
|
@@ -45,7 +63,7 @@ Outside a herdr environment the extension auto-degrades: herdr reporting costs n
|
|
|
45
63
|
|
|
46
64
|
- Node ≥ 22
|
|
47
65
|
- pi ≥ 0.84 (`@earendil-works/pi-coding-agent`)
|
|
48
|
-
- herdr ≥ 0.8.0 for
|
|
66
|
+
- **herdr ≥ 0.8.0** — required for subagents / pane integration / notifications (see the IMPORTANT note above); without it only `todo_write` / `ask_user_question` work
|
|
49
67
|
|
|
50
68
|
## Development
|
|
51
69
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-pier",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "pier: pi extension that fuses pi agent sessions with the herdr pane/tab orchestrator — todo_write loop closure + interactive subagents in isolated panes",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/src/core/subagent.ts
CHANGED
|
@@ -21,7 +21,7 @@ import type { PiSurface } from '../pi-surface.ts';
|
|
|
21
21
|
import type { HerdrClientLike, HerdrAgentState } from '../herdr-client.ts';
|
|
22
22
|
import type { TodosService } from '../todos-service.ts';
|
|
23
23
|
import { mountSubagentScope } from '../subagent-scope.ts';
|
|
24
|
-
import { Semaphore, SUBS_CUSTOM_TYPE, buildLaunchLine, classifyWorktreeZone, foldSubsRegistry, formatSubagentResult, isPathUnder, makeProgressUpdate, makeRegistry, planTabPlacement, tabNameForTask, type SubEntry, type SubagentSpec, type TabPlacementPlan, type WorktreeZone } from '../subagent-core.ts';
|
|
24
|
+
import { Semaphore, SUBS_CUSTOM_TYPE, agoText, buildAliveNotice, buildBlockedGateNotice, buildLaunchLine, classifyWorktreeZone, foldSubsRegistry, formatSubagentResult, isAlive, isPathUnder, makeProgressUpdate, makeRegistry, planTabPlacement, psQuote, tabNameForTask, type AliveProbe, type SubEntry, type SubagentSpec, type TabPlacementPlan, type WorktreeZone } from '../subagent-core.ts';
|
|
25
25
|
import { hasAssistantAfter, hasPendingToolCall, lastAssistantText, listSessionFiles, readSessionFile, sessionFileById } from '../session-tail.ts';
|
|
26
26
|
import { appendHistory, applyReportedSessionFile, historyFilePath, latestGeneration, normalizeEntryKind, readHistory, type HistoryEntry } from '../history-store.ts';
|
|
27
27
|
import { execFile } from 'node:child_process';
|
|
@@ -130,6 +130,9 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
130
130
|
}
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
+
/** E2:已发过闸门通知的 pane(blocked 期间去重;解除后移除,二次 ask 可再通知)。 */
|
|
134
|
+
const blockedGateNotified = new Set<string>();
|
|
135
|
+
|
|
133
136
|
// slots 回填:common 段 pipe reply 消费
|
|
134
137
|
slots.applyReplySession = (paneId, sessionFile) => {
|
|
135
138
|
const entry = subs.get(paneId);
|
|
@@ -169,6 +172,35 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
169
172
|
rebuildSubs(eventCtx);
|
|
170
173
|
});
|
|
171
174
|
|
|
175
|
+
/* ── B1(用户实证修复):subagent isError 结果的探活改写 ──────────────
|
|
176
|
+
* 实测事故:前台误判 no-output → 模型读裸错误文案 → "failed, 我自己干" 抢活。
|
|
177
|
+
* pi 的 tool_result 事件官方语义 "Can modify result"——在文本进模型上下文前,
|
|
178
|
+
* 当场探活(agent.list + 会话 mtime);判活则改写为统一存活通知(buildAliveNotice
|
|
179
|
+
* 与 A2 转后台同一出口)。hook 层硬保障,纯 prompt 防不住的自作主张在此物理拦截。 */
|
|
180
|
+
scoped.on('tool_result', async (event: { toolName?: string; toolCallId?: string; isError?: boolean; content?: Array<{ type: string; text?: string }> }) => {
|
|
181
|
+
if (event?.toolName !== 'subagent' || !event.isError) return;
|
|
182
|
+
// 从错误文本里取 pane id(我们自己的错误文案都带 pane);取不到则不改写
|
|
183
|
+
const errText = (event.content ?? []).map((c) => c.text ?? '').join(' ');
|
|
184
|
+
const paneId = [...subs.keys()].find((id) => errText.includes(id))
|
|
185
|
+
?? (errText.match(/\bw[A-Za-z0-9]+:p\d+\b/) ?? [])[0];
|
|
186
|
+
if (!paneId) return;
|
|
187
|
+
const entry = subs.get(paneId);
|
|
188
|
+
if (!entry || entry.status === 'settled' || entry.status === 'consumed') return;
|
|
189
|
+
const probe = await probeAlive(paneId, entry.cwd);
|
|
190
|
+
if (!isAlive(probe, Date.now())) return; // 真死了 → 保留原错误,模型可重做
|
|
191
|
+
// 活着 → 转 poller(若尚未在跑)并改写结果文本
|
|
192
|
+
if (!pollers.has(paneId)) {
|
|
193
|
+
entry.background = true;
|
|
194
|
+
startPoller(paneId, entry.cwd, entry.createdAt, entry.createdAt, entry.description, lastRequestIdByPane.get(paneId) ?? `probe-${paneId}`);
|
|
195
|
+
persistSubs();
|
|
196
|
+
}
|
|
197
|
+
const notice = buildAliveNotice(
|
|
198
|
+
{ paneId, description: entry.description, scenario: 'error-alive', probe },
|
|
199
|
+
Date.now(),
|
|
200
|
+
);
|
|
201
|
+
return { content: [{ type: 'text', text: notice }] };
|
|
202
|
+
});
|
|
203
|
+
|
|
172
204
|
/* ── D41:stop 未完成提醒(仅主控;子代理不注册本块) ── */
|
|
173
205
|
|
|
174
206
|
let todoReminders = 0;
|
|
@@ -253,6 +285,39 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
253
285
|
return null;
|
|
254
286
|
}
|
|
255
287
|
|
|
288
|
+
/** E1/E2:读子 pane 的人类闸门问题(tokens['pi-ask'];子扩展 reportAskFlag 上报)。 */
|
|
289
|
+
async function readAskFlag(paneId: string): Promise<string | null> {
|
|
290
|
+
try {
|
|
291
|
+
const a = (await client.listAgents()).find((x) => x.paneId === paneId);
|
|
292
|
+
const v = a?.tokens?.['pi-ask'];
|
|
293
|
+
return typeof v === 'string' && v ? v : null;
|
|
294
|
+
} catch {
|
|
295
|
+
return null;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** A2/B1 探活:pane 实时状态 + 会话候选最新 mtime(毫秒级,失败字段为 null)。 */
|
|
300
|
+
async function probeAlive(paneId: string, cwd: string): Promise<AliveProbe> {
|
|
301
|
+
const probe: AliveProbe = { paneExists: false, agentStatus: null, lastActivityMs: null };
|
|
302
|
+
try {
|
|
303
|
+
const agents = await client.listAgents();
|
|
304
|
+
const a = agents.find((x) => x.paneId === paneId);
|
|
305
|
+
probe.paneExists = a != null;
|
|
306
|
+
probe.agentStatus = a?.status ?? null;
|
|
307
|
+
} catch {
|
|
308
|
+
/* agent.list 失败 → 状态未知,靠会话活动判定 */
|
|
309
|
+
}
|
|
310
|
+
for (const file of await resolveSessionFileCandidates(paneId, cwd)) {
|
|
311
|
+
try {
|
|
312
|
+
const mtime = (await statAsync(file)).mtimeMs;
|
|
313
|
+
if (probe.lastActivityMs == null || mtime > probe.lastActivityMs) probe.lastActivityMs = mtime;
|
|
314
|
+
} catch {
|
|
315
|
+
/* 文件消失跳过 */
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return probe;
|
|
319
|
+
}
|
|
320
|
+
|
|
256
321
|
/**
|
|
257
322
|
* 子会话结算状态(v1.3 M8 结算竞态修复):
|
|
258
323
|
* herdr 的 idle/done 在"注入瞬间"也是真,不能当结算信号;
|
|
@@ -275,21 +340,6 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
275
340
|
return { text: null, pendingTool: false, activity: false };
|
|
276
341
|
}
|
|
277
342
|
|
|
278
|
-
/** 等待结算:idle/done=settled、blocked=人类闸门、超时切片循环。 */
|
|
279
|
-
async function waitSubSettled(
|
|
280
|
-
paneId: string,
|
|
281
|
-
timeoutMs: number,
|
|
282
|
-
): Promise<'settled' | 'blocked' | 'timeout'> {
|
|
283
|
-
const deadline = Date.now() + timeoutMs;
|
|
284
|
-
while (Date.now() < deadline) {
|
|
285
|
-
const state = await client.waitAgent(paneId, ['idle', 'done', 'blocked'], 30000);
|
|
286
|
-
if (state === 'idle' || state === 'done') return 'settled';
|
|
287
|
-
if (state === 'blocked') return 'blocked';
|
|
288
|
-
// null = 30s 切片超时(working 中或异常),继续
|
|
289
|
-
}
|
|
290
|
-
return 'timeout';
|
|
291
|
-
}
|
|
292
|
-
|
|
293
343
|
/** D92:结算通知统一出口——经 deps 缓冲(忙时 turn_end 折叠注入),缺省回退旧 followUp 直投。 */
|
|
294
344
|
const injectNotice = (content: string): Promise<void> =>
|
|
295
345
|
d.deliverNotice ? d.deliverNotice(content)
|
|
@@ -360,7 +410,22 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
360
410
|
} catch {
|
|
361
411
|
state = null;
|
|
362
412
|
}
|
|
363
|
-
if (state === 'blocked')
|
|
413
|
+
if (state === 'blocked') {
|
|
414
|
+
// E2(人类闸门):blocked 时 master 对话流里也要知道(此前只有 herdr 系统
|
|
415
|
+
// 通知发给人类,master 毫不知情 → 可能自作主张揽活)。一次性 gate 通知注入;
|
|
416
|
+
// 解除(idle)后重置,二次 ask 新问题可再通知。
|
|
417
|
+
if (!blockedGateNotified.has(paneId)) {
|
|
418
|
+
blockedGateNotified.add(paneId);
|
|
419
|
+
const question = await readAskFlag(paneId);
|
|
420
|
+
try {
|
|
421
|
+
await injectNotice(buildBlockedGateNotice({ paneId, description, question }));
|
|
422
|
+
} catch {
|
|
423
|
+
/* 注入失败静默(下次 turn 可 list_agents 自查) */
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
continue; // 人类闸门:保持 running
|
|
427
|
+
}
|
|
428
|
+
if (state !== null) blockedGateNotified.delete(paneId); // 闸门解除(working/idle)
|
|
364
429
|
if (state === 'idle' || state === 'done') {
|
|
365
430
|
const s = await subSessionState(paneId, cwd, injectTs);
|
|
366
431
|
if (s.text || (!s.pendingTool && s.activity)) {
|
|
@@ -961,52 +1026,69 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
961
1026
|
entry.sessionFile = await resolveSessionFile(paneId, cwd);
|
|
962
1027
|
subs.set(paneId, entry);
|
|
963
1028
|
persistSubs();
|
|
964
|
-
writeHistory(entry);
|
|
965
|
-
onUpdate?.(makeProgressUpdate(`subagent ready in pane ${paneId}; injecting prompt via pipe…`));
|
|
966
1029
|
// M11(D45/D46):注入走扩展管道 → 子扩展 sendUserMessage(followUp);
|
|
967
|
-
//
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
settled = 'blocked';
|
|
999
|
-
break;
|
|
1000
|
-
}
|
|
1001
|
-
if (st === 'idle' || st === 'done') {
|
|
1002
|
-
text = await collectFinalText(paneId, cwd, injectTs);
|
|
1003
|
-
if (text) break;
|
|
1030
|
+
// A1+A2(用户实证修复):前台等待 = 内容闸 + 耐心阈值转后台。
|
|
1031
|
+
// 旧实现的 idle 即结算 + 90s 硬窗口在真实任务(working 期 4-6 分钟)上必然误判
|
|
1032
|
+
// no-output(实测:3 个健康子代理 101s 时被同时 consumed,成果 4 分钟后才产出)。
|
|
1033
|
+
const PATIENCE_MS = Number(process.env.PI_HERDR_FRONTIER_PATIENCE_MS ?? 300000) || 300000;
|
|
1034
|
+
const patienceDeadline = Date.now() + PATIENCE_MS;
|
|
1035
|
+
let text: string | null = null;
|
|
1036
|
+
let settledKind: 'settled' | 'timeout' = 'timeout';
|
|
1037
|
+
while (Date.now() < Math.min(patienceDeadline, spawnedAt + SUBAGENT_TIMEOUT_MS)) {
|
|
1038
|
+
const state = await client.waitAgent(paneId, ['idle', 'done', 'blocked'], 30_000);
|
|
1039
|
+
if (state === 'blocked') {
|
|
1040
|
+
// E1(人类闸门):blocked 不是终态——转后台 poller(人类答后续跑、结算自动通知),
|
|
1041
|
+
// 返回 gate 通知:不揽活、提醒用户去 pane 答题、授权例外经 send_message 转达。
|
|
1042
|
+
const question = await readAskFlag(paneId);
|
|
1043
|
+
entry.background = true;
|
|
1044
|
+
lastRequestIdByPane.set(paneId, `prompt-${taskId}`);
|
|
1045
|
+
startPoller(paneId, cwd, spawnedAt, injectTs, spec.description, `prompt-${taskId}`);
|
|
1046
|
+
persistSubs();
|
|
1047
|
+
writeHistory(entry);
|
|
1048
|
+
return {
|
|
1049
|
+
content: [{ type: 'text', text: buildBlockedGateNotice({ paneId, description: spec.description, question }) }],
|
|
1050
|
+
details: { paneId, taskId, background: true, blocked: true, role: kind },
|
|
1051
|
+
};
|
|
1052
|
+
}
|
|
1053
|
+
if (state === 'idle' || state === 'done') {
|
|
1054
|
+
// A1 内容闸:herdr idle 只是"可能是结算"——以会话内容定夺
|
|
1055
|
+
const s = await subSessionState(paneId, cwd, injectTs);
|
|
1056
|
+
if (s.text) { text = s.text; settledKind = 'settled'; break; }
|
|
1057
|
+
if (s.pendingTool || (!s.text && !s.activity)) {
|
|
1058
|
+
// 挂起 toolCall(等人类)或注入前瞬间的 idle → 不当结算,继续观察
|
|
1059
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
1060
|
+
continue;
|
|
1004
1061
|
}
|
|
1062
|
+
// 有活动无文本(活动已停止)→ 再给 collectFinalText 的重试窗口
|
|
1063
|
+
text = await collectFinalText(paneId, cwd, injectTs, 6);
|
|
1064
|
+
if (text) { settledKind = 'settled'; break; }
|
|
1065
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
1066
|
+
continue;
|
|
1005
1067
|
}
|
|
1068
|
+
// null = 30s 切片超时(working 中)→ 继续循环
|
|
1069
|
+
}
|
|
1070
|
+
// A2 耐心阈值到点仍在跑 → 探活;活着 → 转后台并返回统一存活通知
|
|
1071
|
+
if (!text && settledKind !== 'blocked') {
|
|
1072
|
+
const probe = await probeAlive(paneId, cwd);
|
|
1073
|
+
if (isAlive(probe, Date.now())) {
|
|
1074
|
+
entry.background = true;
|
|
1075
|
+
lastRequestIdByPane.set(paneId, `prompt-${taskId}`);
|
|
1076
|
+
startPoller(paneId, cwd, spawnedAt, injectTs, spec.description, `prompt-${taskId}`);
|
|
1077
|
+
persistSubs();
|
|
1078
|
+
const notice = buildAliveNotice(
|
|
1079
|
+
{ paneId, description: spec.description, scenario: 'moved-to-bg', probe },
|
|
1080
|
+
Date.now(),
|
|
1081
|
+
);
|
|
1082
|
+
return {
|
|
1083
|
+
content: [{ type: 'text', text: notice }],
|
|
1084
|
+
details: { paneId, taskId, background: true, movedToBackground: true, role: kind },
|
|
1085
|
+
};
|
|
1086
|
+
}
|
|
1087
|
+
// 探活失败(pane 消失且会话沉寂)→ 保留原 timeout 语义
|
|
1088
|
+
settledKind = 'timeout';
|
|
1006
1089
|
}
|
|
1007
1090
|
const outcome =
|
|
1008
|
-
|
|
1009
|
-
: settled === 'timeout' ? { kind: 'timeout' as const, text: text ?? '' }
|
|
1091
|
+
settledKind === 'timeout' ? { kind: 'timeout' as const, text: text ?? '' }
|
|
1010
1092
|
: text ? { kind: 'completed' as const, text }
|
|
1011
1093
|
: { kind: 'no-output' as const, text: '' };
|
|
1012
1094
|
entry.status = 'consumed';
|
|
@@ -1138,7 +1220,7 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
1138
1220
|
name: 'list_agents',
|
|
1139
1221
|
label: 'List Subagents',
|
|
1140
1222
|
description:
|
|
1141
|
-
'List the background subagents you started, with their live state: running (working or blocked), idle (settled), plus pane ids, role (task or a role label), and descriptions. Foreground one-shot panes are transient and not listed. Covers only this session branch — for tasks from earlier sessions or collected panes, read the delegation ledger (path and format in resume_subagent) and resume by taskId.',
|
|
1223
|
+
'List the background subagents you started, with their live state: running (working or blocked), idle (settled), plus pane ids, live agent status, last session activity time, role (task or a role label), and descriptions. Foreground one-shot panes are transient and not listed. Covers only this session branch — for tasks from earlier sessions or collected panes, read the delegation ledger (path and format in resume_subagent) and resume by taskId.',
|
|
1142
1224
|
parameters: Type.Object({}),
|
|
1143
1225
|
async execute() {
|
|
1144
1226
|
const listed = [...subs.values()].filter((s) => s.background);
|
|
@@ -1151,6 +1233,12 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
1151
1233
|
const prev = byTask.get(sub.taskId);
|
|
1152
1234
|
if (!prev || sub.createdAt >= prev.createdAt) byTask.set(sub.taskId, sub);
|
|
1153
1235
|
}
|
|
1236
|
+
// C1:实时探活(pane 状态 + 会话活动时间)——master 能区分"在干活"vs"真卡死"
|
|
1237
|
+
const probes = new Map<string, AliveProbe>();
|
|
1238
|
+
await Promise.all([...byTask.values()].map(async (sub) => {
|
|
1239
|
+
if (sub.status === 'closed') return;
|
|
1240
|
+
try { probes.set(sub.paneId, await probeAlive(sub.paneId, sub.cwd)); } catch { /* 显示层尽力 */ }
|
|
1241
|
+
}));
|
|
1154
1242
|
const lines: string[] = [];
|
|
1155
1243
|
for (const sub of byTask.values()) {
|
|
1156
1244
|
const tabTag = sub.tabName ? ` [tab: ${sub.tabName}]` : '';
|
|
@@ -1162,7 +1250,16 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
1162
1250
|
const state = sub.status === 'settled' ? 'idle' : 'running';
|
|
1163
1251
|
// D94:用户接管标记
|
|
1164
1252
|
const takeoverMark = sub.userTakeover ? ', user-controlled' : '';
|
|
1165
|
-
|
|
1253
|
+
const probe = probes.get(sub.paneId);
|
|
1254
|
+
const statusTag = probe?.agentStatus ? ` ${probe.agentStatus}` : '';
|
|
1255
|
+
const activityTag = probe?.lastActivityMs != null ? `, active ${agoText(probe.lastActivityMs, Date.now())}` : '';
|
|
1256
|
+
// E:blocked 时附上闸门问题摘要(提示 master 该叫用户去 pane,不是自己接手)
|
|
1257
|
+
let gateTag = '';
|
|
1258
|
+
if (probe?.agentStatus === 'blocked') {
|
|
1259
|
+
const q = await readAskFlag(sub.paneId);
|
|
1260
|
+
gateTag = q ? ` — AWAITING HUMAN: "${q}"` : ' — AWAITING HUMAN decision';
|
|
1261
|
+
}
|
|
1262
|
+
lines.push(`${sub.paneId} [${state}${takeoverMark}${statusTag}${activityTag}${gateTag}] (${sub.kind})${tabTag} ${sub.description}`);
|
|
1166
1263
|
}
|
|
1167
1264
|
return { content: [{ type: 'text', text: lines.join('\n') }], details: {} };
|
|
1168
1265
|
},
|
package/src/core/todo.ts
CHANGED
|
@@ -22,8 +22,8 @@ import { makeProgressUpdate } from '../subagent-core.ts';
|
|
|
22
22
|
import { TODO_DETAILS_KEY, TODO_TOOL_NAME, formatTodoConfirmation, type TodoItem, type TodoStatus } from '../vocab.ts';
|
|
23
23
|
import {
|
|
24
24
|
TODO_EDIT_CUSTOM_TYPE,
|
|
25
|
-
boundedView,
|
|
26
25
|
completionTransitions,
|
|
26
|
+
countTodos,
|
|
27
27
|
fuzzyFind,
|
|
28
28
|
listsEqual,
|
|
29
29
|
makeSnapshot,
|
|
@@ -65,17 +65,11 @@ const TODO_MARKS: Record<TodoStatus, string> = {
|
|
|
65
65
|
abandoned: '✗',
|
|
66
66
|
};
|
|
67
67
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
else if (it.status === 'in_progress') counts.inProgress++;
|
|
74
|
-
else if (it.status === 'completed') counts.completed++;
|
|
75
|
-
else if (it.status === 'blocked') counts.blocked++;
|
|
76
|
-
}
|
|
77
|
-
const lines = [`todo: ${counts.inProgress}▶ ${counts.pending}○ ${counts.blocked}■ ${counts.completed}✓`];
|
|
78
|
-
// D43 phase 分组 + D39 有界(每组预算 10 行)
|
|
68
|
+
/** pi TUI widget 硬上限(interactive-mode MAX_WIDGET_LINES=10,头部截断;我们自己控制在限内)。 */
|
|
69
|
+
export const WIDGET_MAX_LINES = 10;
|
|
70
|
+
|
|
71
|
+
/** 分组渲染(phase 头 + 条目行;不设预算,widget 与 /todos 共用)。 */
|
|
72
|
+
function renderGroups(items: readonly TodoItem[]): string[] {
|
|
79
73
|
const groups = new Map<string, TodoItem[]>();
|
|
80
74
|
for (const it of items) {
|
|
81
75
|
const key = it.phase ?? '';
|
|
@@ -83,16 +77,44 @@ function widgetLines(items: TodoItem[]): string[] {
|
|
|
83
77
|
arr.push(it);
|
|
84
78
|
groups.set(key, arr);
|
|
85
79
|
}
|
|
80
|
+
const lines: string[] = [];
|
|
86
81
|
for (const [phase, list] of groups) {
|
|
87
82
|
if (phase) lines.push(` [${phase}]`);
|
|
88
|
-
const
|
|
89
|
-
for (const it of view.visible) {
|
|
83
|
+
for (const it of list) {
|
|
90
84
|
const suffix = it.status === 'blocked' && it.blocker ? ` — ${it.blocker}` : '';
|
|
91
85
|
lines.push(` ${TODO_MARKS[it.status]} ${it.content}${suffix}`);
|
|
92
86
|
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
87
|
+
}
|
|
88
|
+
return lines;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* widget 行(全局预算版,用户实证修复):
|
|
93
|
+
* 旧实现按分组插入序逐组渲染(每组预算 10),pi 头部截 10 行 → 老 phase 吃光窗口、
|
|
94
|
+
* 最新任务组(in_progress 所在)整组不可见。新规则:
|
|
95
|
+
* - 全局预算 = WIDGET_MAX_LINES(含摘要行与 +N 行);
|
|
96
|
+
* - 超额丢弃顺序 = 最老 completed → 最老 open(尾部/当前工作永远存活);
|
|
97
|
+
* - +N 行指路 /todos(全量视图)。
|
|
98
|
+
*/
|
|
99
|
+
export function widgetLines(items: readonly TodoItem[]): string[] {
|
|
100
|
+
if (items.length === 0) return [];
|
|
101
|
+
const c = countTodos(items);
|
|
102
|
+
const summary = `todo: ${c.inProgress}▶ ${c.pending}○ ${c.blocked}■ ${c.completed}✓`;
|
|
103
|
+
|
|
104
|
+
const kept = [...items];
|
|
105
|
+
while (
|
|
106
|
+
kept.length > 0
|
|
107
|
+
&& 1 + renderGroups(kept).length + (items.length > kept.length ? 1 : 0) > WIDGET_MAX_LINES
|
|
108
|
+
) {
|
|
109
|
+
const oldestCompleted = kept.findIndex((it) => it.status === 'completed');
|
|
110
|
+
kept.splice(oldestCompleted === -1 ? 0 : oldestCompleted, 1);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const hidden = items.filter((it) => !kept.includes(it));
|
|
114
|
+
const lines = [summary, ...renderGroups(kept)];
|
|
115
|
+
if (hidden.length > 0) {
|
|
116
|
+
const hiddenCompleted = hidden.filter((it) => it.status === 'completed').length;
|
|
117
|
+
lines.push(` +${hidden.length} hidden (${hiddenCompleted}✓) · /todos 全量`);
|
|
96
118
|
}
|
|
97
119
|
return lines;
|
|
98
120
|
}
|
|
@@ -181,8 +203,8 @@ export default function todoPlugin(ctx: Context): void {
|
|
|
181
203
|
Type.Literal('blocked'),
|
|
182
204
|
Type.Literal('abandoned'),
|
|
183
205
|
], { description: 'pending | in_progress | completed | blocked | abandoned' }),
|
|
184
|
-
blocker: Type.Optional(Type.String({ description: 'Only when status is "blocked": what it is waiting for' })),
|
|
185
|
-
phase: Type.Optional(Type.String({ description: 'Optional group name (≤30 chars); omit for a flat list' })),
|
|
206
|
+
blocker: Type.Optional(Type.String({ description: 'Only when status is "blocked": what it is waiting for. Omit the field entirely (never an empty string) for other statuses' })),
|
|
207
|
+
phase: Type.Optional(Type.String({ description: 'Optional group name (≤30 chars); omit for a flat list (never an empty string)' })),
|
|
186
208
|
},
|
|
187
209
|
{ additionalProperties: false },
|
|
188
210
|
),
|
|
@@ -290,9 +312,14 @@ export default function todoPlugin(ctx: Context): void {
|
|
|
290
312
|
ui?.notify?.(`"${content}" ${verb}`, 'info');
|
|
291
313
|
return;
|
|
292
314
|
}
|
|
293
|
-
|
|
294
|
-
const
|
|
295
|
-
|
|
315
|
+
// 全量视图(/todos 不受 widget 10 行预算限制——"看整个列表"的正式入口)
|
|
316
|
+
const body = renderGroups(todos.items);
|
|
317
|
+
if (body.length === 0) {
|
|
318
|
+
ui?.notify?.('todo list is empty', 'info');
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
const c = countTodos(todos.items);
|
|
322
|
+
ui?.notify?.([`todo: ${c.inProgress}▶ ${c.pending}○ ${c.blocked}■ ${c.completed}✓`, ...body].join('\n'), 'info');
|
|
296
323
|
},
|
|
297
324
|
});
|
|
298
325
|
|
package/src/subagent-core.ts
CHANGED
|
@@ -98,6 +98,76 @@ export function formatSubagentResult(outcome: SubagentOutcome, description: stri
|
|
|
98
98
|
}
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
/* ── 探活 + 存活通知(A2/B1 统一出口;纯逻辑,IO 在 adapter) ────── */
|
|
102
|
+
|
|
103
|
+
/** 探活快照:pane 实时状态 + 会话活动时间(毫秒 epoch;null = 未知)。 */
|
|
104
|
+
export interface AliveProbe {
|
|
105
|
+
paneExists: boolean;
|
|
106
|
+
agentStatus: string | null;
|
|
107
|
+
/** 会话文件 mtime(最新候选);无文件/不可读 = null。 */
|
|
108
|
+
lastActivityMs: number | null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** 探活判活:pane 在 +(working/blocked 或近期有会话活动)→ 视为还在干活。 */
|
|
112
|
+
export function isAlive(probe: AliveProbe, nowMs: number, staleAfterMs = 120_000): boolean {
|
|
113
|
+
if (!probe.paneExists) return false;
|
|
114
|
+
if (probe.agentStatus === 'working' || probe.agentStatus === 'blocked') return true;
|
|
115
|
+
// idle/unknown:会话 2 分钟内有活动仍算活(写作间隙/刚结算未采集)
|
|
116
|
+
return probe.lastActivityMs != null && nowMs - probe.lastActivityMs < staleAfterMs;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** 人话相对时间("12s ago" / "3m ago")。 */
|
|
120
|
+
export function agoText(ms: number, nowMs: number): string {
|
|
121
|
+
const s = Math.max(0, Math.round((nowMs - ms) / 1000));
|
|
122
|
+
if (s < 60) return `${s}s ago`;
|
|
123
|
+
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
|
|
124
|
+
return `${Math.floor(s / 3600)}h ago`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* 存活通知(A2 转后台返回值 与 B1 tool_result 改写 的统一构造出口):
|
|
129
|
+
* 两场景同一话术——"它在干活 / 别重做 / 转后台了 / 结算会通知 / list_agents 可查"。
|
|
130
|
+
* scenario: 'moved-to-bg'(耐心阈值转后台)| 'error-alive'(工具报错但探活判活,兜底改写)。
|
|
131
|
+
*/
|
|
132
|
+
export function buildAliveNotice(
|
|
133
|
+
opts: { paneId: string; description: string; scenario: 'moved-to-bg' | 'error-alive'; probe: AliveProbe },
|
|
134
|
+
nowMs: number,
|
|
135
|
+
): string {
|
|
136
|
+
const { paneId, description, scenario, probe } = opts;
|
|
137
|
+
const status = probe.agentStatus ?? 'unknown';
|
|
138
|
+
const activity = probe.lastActivityMs != null ? `, last session activity ${agoText(probe.lastActivityMs, nowMs)}` : '';
|
|
139
|
+
if (scenario === 'moved-to-bg') {
|
|
140
|
+
return [
|
|
141
|
+
`Subagent "${description}" is still running in pane ${paneId} (agent_status=${status}${activity}).`,
|
|
142
|
+
'The foreground wait has moved it to background so this call returns now.',
|
|
143
|
+
'Do NOT redo its work. When it settles you will receive a notification with its closing output.',
|
|
144
|
+
'Use list_agents to check its live state; send_message to give it follow-up work.',
|
|
145
|
+
].join(' ');
|
|
146
|
+
}
|
|
147
|
+
return [
|
|
148
|
+
`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.`,
|
|
149
|
+
'Do NOT redo its work. It is being moved to background; you will be notified when it settles.',
|
|
150
|
+
'Use list_agents to check its live state.',
|
|
151
|
+
].join(' ');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* 人类闸门通知(E1/E2 统一出口):subagent blocked(ask_user_question 等人类)时
|
|
156
|
+
* 给 master 的话术——不揽活、引导用户去 pane、授权例外经 send_message 转达。
|
|
157
|
+
* question 来自 herdr tokens['pi-ask'](子扩展 reportAskFlag 上报)。
|
|
158
|
+
*/
|
|
159
|
+
export function buildBlockedGateNotice(opts: { paneId: string; description: string; question: string | null }): string {
|
|
160
|
+
const { paneId, description, question } = opts;
|
|
161
|
+
const q = question ? ` (question: "${question}")` : '';
|
|
162
|
+
return [
|
|
163
|
+
`Subagent "${description}" is BLOCKED waiting for a HUMAN decision in pane ${paneId}${q}.`,
|
|
164
|
+
'It keeps running in background; once the human answers it resumes, and you will be notified when it settles.',
|
|
165
|
+
'Do NOT take over its work and do NOT answer on the human\'s behalf.',
|
|
166
|
+
'Tell the user to open that pane in herdr and answer directly.',
|
|
167
|
+
'Only if the user explicitly authorizes you: relay their decision to the subagent via send_message — do not redo the work yourself.',
|
|
168
|
+
].join(' ');
|
|
169
|
+
}
|
|
170
|
+
|
|
101
171
|
/* ── 子代理注册表(custom 条目持久化) ────────────────────────────── */
|
|
102
172
|
|
|
103
173
|
export const SUBS_CUSTOM_TYPE = 'pi-herdr.subs';
|
package/src/todo-core.ts
CHANGED
|
@@ -93,20 +93,31 @@ export function validateTodos(
|
|
|
93
93
|
const item: TodoItem = { content, status: status as TodoStatus };
|
|
94
94
|
if ('blocker' in entry) {
|
|
95
95
|
const blocker = entry.blocker;
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
if (
|
|
96
|
+
// 容忍归一(用户实证修复):Optional 字段用空串/null 填充是常见模型行为
|
|
97
|
+
//(grok 实测 9 连拒死循环);status 是权威——非 blocked 态的 blocker 一律视为
|
|
98
|
+
// 缺省丢弃(无论是否有值),空值同样丢弃。仅类型畸形仍硬拒。
|
|
99
|
+
if (blocker != null && typeof blocker !== 'string') {
|
|
100
100
|
return { ok: false, error: 'invalid todo: `blocker` must be a non-empty string' };
|
|
101
101
|
}
|
|
102
|
-
|
|
102
|
+
if (status === 'blocked') {
|
|
103
|
+
if (typeof blocker !== 'string' || blocker.trim() === '') {
|
|
104
|
+
return { ok: false, error: 'invalid todo: `blocker` must be a non-empty string' };
|
|
105
|
+
}
|
|
106
|
+
item.blocker = blocker;
|
|
107
|
+
}
|
|
103
108
|
}
|
|
104
109
|
if ('phase' in entry) {
|
|
105
110
|
const phase = entry.phase;
|
|
106
|
-
if (
|
|
111
|
+
if (phase != null && typeof phase !== 'string') {
|
|
107
112
|
return { ok: false, error: `invalid todo: \`phase\` must be a non-empty string ≤ ${PHASE_MAX_LEN} chars` };
|
|
108
113
|
}
|
|
109
|
-
|
|
114
|
+
// 空串/纯空白 = 缺省(实证循环里 phase:"" 同样是被掩盖的雷);超长是真实约束仍硬拒
|
|
115
|
+
if (typeof phase === 'string') {
|
|
116
|
+
if (phase.length > PHASE_MAX_LEN) {
|
|
117
|
+
return { ok: false, error: `invalid todo: \`phase\` must be a non-empty string ≤ ${PHASE_MAX_LEN} chars` };
|
|
118
|
+
}
|
|
119
|
+
if (phase.trim() !== '') item.phase = phase;
|
|
120
|
+
}
|
|
110
121
|
}
|
|
111
122
|
if (status === 'in_progress') {
|
|
112
123
|
inProgress++;
|
|
@@ -298,7 +309,7 @@ export function fuzzyFind(items: readonly TodoItem[], query: string): string[] {
|
|
|
298
309
|
return substr.map((it) => it.content);
|
|
299
310
|
}
|
|
300
311
|
|
|
301
|
-
/** D39:有界视图选择——先丢 completed
|
|
312
|
+
/** D39:有界视图选择——先丢 completed、再截 open;两端都超额时保最新(尾部切片)。 */
|
|
302
313
|
export interface BoundedView {
|
|
303
314
|
visible: TodoItem[];
|
|
304
315
|
/** 被隐藏的数量按状态计数(completed 单独计,其余合并)。 */
|
|
@@ -312,15 +323,18 @@ export function boundedView(items: readonly TodoItem[], budget: number): Bounded
|
|
|
312
323
|
}
|
|
313
324
|
const completed = items.filter((it) => it.status === 'completed');
|
|
314
325
|
const open = items.filter((it) => it.status !== 'completed');
|
|
315
|
-
|
|
326
|
+
// 修正(用户实证):widget 是头部可见窗口——超预算必须隐最老(丢头部),保最新可见;
|
|
327
|
+
// 旧实现 slice(0, budget) 保最老,最新任务永远落在窗口外。
|
|
328
|
+
const visible = open.length > budget ? open.slice(-budget) : open;
|
|
316
329
|
const hiddenOpen = open.length - visible.length;
|
|
317
330
|
let hiddenCompleted = completed.length;
|
|
331
|
+
let final = visible;
|
|
318
332
|
if (hiddenOpen === 0 && visible.length < budget) {
|
|
319
333
|
const room = budget - visible.length;
|
|
320
|
-
|
|
334
|
+
final = [...visible, ...completed.slice(-room)];
|
|
321
335
|
hiddenCompleted = completed.length - room;
|
|
322
336
|
}
|
|
323
|
-
return { visible, hiddenCompleted, hiddenOpen };
|
|
337
|
+
return { visible: final, hiddenCompleted, hiddenOpen };
|
|
324
338
|
}
|
|
325
339
|
|
|
326
340
|
/** 当前 in_progress 中的第一条(上报 agent 状态时作为 message,供侧边栏显示"正在做什么")。 */
|