pi-pier 0.1.3 → 0.1.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/package.json +1 -1
- package/src/core/grid-shape.ts +10 -5
- package/src/core/subagent.ts +157 -53
- package/src/core/todo.ts +120 -66
- package/src/herdr-client.ts +6 -3
- package/src/history-store.ts +10 -0
- package/src/index.ts +72 -10
- package/src/pane-title.ts +11 -1
- package/src/pipe-channel.ts +3 -0
- package/src/settle-wake-core.ts +54 -0
- package/src/slim-frame.ts +219 -0
- package/src/stale-core.ts +76 -0
- package/src/subagent-core.ts +30 -0
- package/src/todo-core.ts +41 -4
- package/src/todo-read-hook.ts +122 -20
- package/src/todos-service.ts +13 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-pier",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
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/grid-shape.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* D97 网格形态:spawn 时挑「最大格子」一律 down(上下拆),让 pane 长成全宽横条。
|
|
3
|
+
*
|
|
4
|
+
* 动因(用户实证):热力把非焦点格压窄后,横条仍保全宽——pane title 静帧
|
|
5
|
+
* (slim-frame.ts 同源投影)一行横读;旧「方格」拓扑的非焦点格是竖窄条,
|
|
6
|
+
* title 折成一竖列不可读。静帧 overlay 的可见性谓词(长宽 < TUI 下限)与
|
|
7
|
+
* 本拓扑配套:横条命中行数下限、存量竖条命中列数下限,两代布局都被接住。
|
|
4
8
|
*
|
|
5
9
|
* 依据 herdr 0.8.2 源码:ratio = split 节点 first child 份额(split_rect:
|
|
6
10
|
* first_w = width*ratio);layout.apply 不能挂接既有 pane(只建新 shell),
|
|
@@ -67,8 +71,9 @@ export function paneCells(root: ShapeNode, width = 200, height = 50): PaneCell[]
|
|
|
67
71
|
}
|
|
68
72
|
|
|
69
73
|
/**
|
|
70
|
-
*
|
|
71
|
-
*
|
|
74
|
+
* 挑分裂目标:面积最大的格子(并列取先序第一,稳定),一律 down(上下拆)。
|
|
75
|
+
* 模型 200×50 下 pane 数上限(热力 MAX_AUTO_LAYOUT_PANES=10)内条高最低 5 行
|
|
76
|
+
* ——低于 TUI 可用下限即静帧,正是设计语义(条 = title 帧,点击放大看真身)。
|
|
72
77
|
* exclude = 不许动的格子(board 等无 agent 常驻 shell)。
|
|
73
78
|
*/
|
|
74
79
|
export function pickGridSplit(
|
|
@@ -82,5 +87,5 @@ export function pickGridSplit(
|
|
|
82
87
|
for (const c of cells) {
|
|
83
88
|
if (c.w * c.h > best.w * best.h + 1e-9) best = c;
|
|
84
89
|
}
|
|
85
|
-
return { targetPaneId: best.id, direction:
|
|
90
|
+
return { targetPaneId: best.id, direction: 'down' };
|
|
86
91
|
}
|
package/src/core/subagent.ts
CHANGED
|
@@ -21,9 +21,9 @@ 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, 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';
|
|
24
|
+
import { Semaphore, SUBS_CUSTOM_TYPE, agoText, buildAliveNotice, buildBlockedGateNotice, buildLaunchLine, buildLaunchParts, 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
|
-
import { appendHistory, applyReportedSessionFile, historyFilePath, latestGeneration, normalizeEntryKind, readHistory, type HistoryEntry } from '../history-store.ts';
|
|
26
|
+
import { appendHistory, applyReportedSessionFile, historyFilePath, inheritOutcome, latestGeneration, normalizeEntryKind, readHistory, type HistoryEntry } from '../history-store.ts';
|
|
27
27
|
import { execFile } from 'node:child_process';
|
|
28
28
|
import { stat as statCb } from 'node:fs';
|
|
29
29
|
import { resolve as pathResolve } from 'node:path';
|
|
@@ -33,7 +33,7 @@ import { pingUntilReady, pipeNameFor, pipeRequest } from '../pipe-channel.ts';
|
|
|
33
33
|
import { shouldClosePane, shouldCloseTaskTab } from '../gc-core.ts';
|
|
34
34
|
import { composeForRole } from '../manifest-compose.ts';
|
|
35
35
|
import { formatSettlementNotice } from '../vocab.ts';
|
|
36
|
-
import {
|
|
36
|
+
import { ABORT_STOP_REASON } from '../settle-wake-core.ts';
|
|
37
37
|
|
|
38
38
|
export interface SubagentEnv {
|
|
39
39
|
paneId: string;
|
|
@@ -66,9 +66,10 @@ export interface SubagentDeps {
|
|
|
66
66
|
* D92 结算通知注入器(index.ts 提供):忙时入缓冲、turn_end 折叠 steer 批量注入。
|
|
67
67
|
* 缺省回退 pi.sendUserMessage(followUp)(旧路径——整个 run 结束才回填,测试桩用)。
|
|
68
68
|
*/
|
|
69
|
-
deliverNotice?: (content: string) => Promise<void>;
|
|
69
|
+
deliverNotice?: (content: string, paneId?: string) => Promise<void>;
|
|
70
|
+
/** B2:结算通知尚在缓冲未送达的 pane 集合(GC 豁免——先送达再回收)。 */
|
|
71
|
+
noticePending?: () => ReadonlySet<string>;
|
|
70
72
|
/** terminal 族 GC 豁免槽。 */
|
|
71
|
-
terminalState: { activePaneIds: () => Set<string> };
|
|
72
73
|
todos: TodosService;
|
|
73
74
|
}
|
|
74
75
|
|
|
@@ -84,10 +85,18 @@ const SUBAGENT_DESCRIPTION = [
|
|
|
84
85
|
|
|
85
86
|
/** 子代理并发上限(D10)。 */
|
|
86
87
|
const SUBAGENT_CONCURRENCY = 4;
|
|
87
|
-
/**
|
|
88
|
+
/** B1:观察超时 = 无活动时长预算(非总墙钟)——working 切片持续续命;marker
|
|
89
|
+
* PI_HERDR_SUBAGENT_TIMEOUT_MS 沿用旧名,语义已从「poller 起算总墙钟」改为
|
|
90
|
+
* 「距最近一次活动」——>10min 的健康长任务不再被误杀(session 01a03c0d 实证)。 */
|
|
88
91
|
const SUBAGENT_TIMEOUT_MS = Number(process.env.PI_HERDR_SUBAGENT_TIMEOUT_MS ?? 600000) || 600000;
|
|
89
92
|
/** 就绪等待上限(TUI 启动 + herdr 检测)。 */
|
|
90
93
|
const SUB_READY_TIMEOUT_MS = 30000;
|
|
94
|
+
/** D94/B4:结算观察窗(此期内 working 默认视为用户接管)。 */
|
|
95
|
+
const OBSERVE_WINDOW_MS = Number(process.env.PI_HERDR_OBSERVE_MS ?? 30000) || 30000;
|
|
96
|
+
/** B4:机器注入宽限——观察窗内的 working 若由自己的 prompt/follow_up 触发,不算接管。 */
|
|
97
|
+
const MACHINE_INJECT_GRACE_MS = Number(process.env.PI_HERDR_INJECT_GRACE_MS ?? 30000) || 30000;
|
|
98
|
+
/** D94:接管态归还阈值(持续 idle 即归还)。 */
|
|
99
|
+
const TAKEOVER_IDLE_MS = Number(process.env.PI_HERDR_TAKEOVER_IDLE_MS ?? 60000) || 60000;
|
|
91
100
|
|
|
92
101
|
function defaultAgentSessionsDir(): string {
|
|
93
102
|
const base = process.env.PI_CODING_AGENT_DIR || join(homedir(), '.pi', 'agent');
|
|
@@ -114,7 +123,9 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
114
123
|
extPath: d.extPath,
|
|
115
124
|
};
|
|
116
125
|
const subSemaphore = new Semaphore(SUBAGENT_CONCURRENCY);
|
|
117
|
-
|
|
126
|
+
/** B4:每 pane 最近一次机器注入(prompt/follow_up pipe 投递成功)时间——观察窗内的
|
|
127
|
+
* working 若发生在宽限期内,是自己的注入在被处理,不是用户接管。 */
|
|
128
|
+
const lastMachineInjectAt = new Map<string, number>();
|
|
118
129
|
/* 子代理注册表(custom 条目持久化,分支正确;parent 重启后可从分支重建) */
|
|
119
130
|
const subs = new Map<string, SubEntry>();
|
|
120
131
|
const pollers = new Set<string>();
|
|
@@ -122,14 +133,20 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
122
133
|
/** D50:每 pane 最近一次机器请求 id(interrupt 按轮次占位、pollLoop 去重用)。 */
|
|
123
134
|
const lastRequestIdByPane = new Map<string, string>();
|
|
124
135
|
|
|
136
|
+
/** O1:上次落盘快照(内容哈希门控——01a03c0d 实证 48% session 行是心跳期重复快照)。 */
|
|
137
|
+
let lastSubsSnapshot = '';
|
|
138
|
+
|
|
125
139
|
function persistSubs(): void {
|
|
126
140
|
try {
|
|
127
|
-
|
|
141
|
+
const reg = makeRegistry([...subs.values()]);
|
|
142
|
+
const snap = JSON.stringify(reg);
|
|
143
|
+
if (snap === lastSubsSnapshot) return; // 内容未变不落(1s 观察窗/5s 接管循环的重复心跳)
|
|
144
|
+
lastSubsSnapshot = snap;
|
|
145
|
+
pi.appendEntry?.(SUBS_CUSTOM_TYPE, reg);
|
|
128
146
|
} catch {
|
|
129
147
|
/* 持久化尽力而为 */
|
|
130
148
|
}
|
|
131
149
|
}
|
|
132
|
-
|
|
133
150
|
/** E2:已发过闸门通知的 pane(blocked 期间去重;解除后移除,二次 ask 可再通知)。 */
|
|
134
151
|
const blockedGateNotified = new Set<string>();
|
|
135
152
|
|
|
@@ -141,7 +158,7 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
141
158
|
if (next === entry.sessionFile) return;
|
|
142
159
|
entry.sessionFile = next;
|
|
143
160
|
persistSubs();
|
|
144
|
-
writeHistory(entry);
|
|
161
|
+
writeHistory(entry, undefined, 'session-report');
|
|
145
162
|
};
|
|
146
163
|
slots.reconcileOnReply = (paneId) => {
|
|
147
164
|
const entry = subs.get(paneId);
|
|
@@ -160,16 +177,39 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
160
177
|
?.sessionManager?.getBranch?.() ?? [];
|
|
161
178
|
const reg = foldSubsRegistry(entries as Parameters<typeof foldSubsRegistry>[0]);
|
|
162
179
|
for (const sub of reg.subs) subs.set(sub.paneId, sub);
|
|
180
|
+
lastSubsSnapshot = ''; // 分支回放改变了状态 → 下次 persistSubs 强制落盘
|
|
163
181
|
} catch {
|
|
164
182
|
/* 重建失败不影响主流程 */
|
|
165
183
|
}
|
|
166
184
|
}
|
|
167
185
|
|
|
186
|
+
/** B6:master 崩溃遗留的 running 行——按 herdr 实况补 closed(pane 没了才关,
|
|
187
|
+
* pane 还在的真在跑则保留)。01a03c0d 台账实证:p6/p7 永久 running 无回收。 */
|
|
188
|
+
async function sweepZombieRunning(): Promise<void> {
|
|
189
|
+
if (!client.available || subs.size === 0) return;
|
|
190
|
+
let livePaneIds: ReadonlySet<string>;
|
|
191
|
+
try {
|
|
192
|
+
livePaneIds = new Set((await client.listPanes()).map((p) => p.paneId));
|
|
193
|
+
} catch {
|
|
194
|
+
return; // 查询失败不误关
|
|
195
|
+
}
|
|
196
|
+
let changed = false;
|
|
197
|
+
for (const [paneId, e] of subs) {
|
|
198
|
+
if (e.status !== 'running' || livePaneIds.has(paneId)) continue;
|
|
199
|
+
e.status = 'closed';
|
|
200
|
+
writeHistory(e, { status: 'closed', closedAt: Date.now() }, 'zombie-sweep');
|
|
201
|
+
changed = true;
|
|
202
|
+
}
|
|
203
|
+
if (changed) persistSubs();
|
|
204
|
+
}
|
|
205
|
+
|
|
168
206
|
scoped.on('session_start', async (_event: unknown, eventCtx: unknown) => {
|
|
169
207
|
rebuildSubs(eventCtx);
|
|
208
|
+
await sweepZombieRunning();
|
|
170
209
|
});
|
|
171
210
|
scoped.on('session_tree', async (_event: unknown, eventCtx: unknown) => {
|
|
172
211
|
rebuildSubs(eventCtx);
|
|
212
|
+
await sweepZombieRunning();
|
|
173
213
|
});
|
|
174
214
|
|
|
175
215
|
/* ── B1(用户实证修复):subagent isError 结果的探活改写 ──────────────
|
|
@@ -205,7 +245,20 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
205
245
|
|
|
206
246
|
let todoReminders = 0;
|
|
207
247
|
const TODO_REMINDERS_MAX = 3;
|
|
248
|
+
// 反唤醒风暴:用户 ESC 中止后的 settled 不催(否则 abort → 提醒 → 新 run → 再 abort
|
|
249
|
+
// 死循环;与 index 的 D96/结算缓冲同款守卫,session 01a03bf0 实证)。
|
|
250
|
+
let lastAssistantStopReason: string | null = null;
|
|
251
|
+
scoped.on('turn_end', async (event: unknown) => {
|
|
252
|
+
if (event === null || typeof event !== 'object' || !('message' in event)) return;
|
|
253
|
+
const msg = (event as { message: unknown }).message; // 'message' in 已守卫
|
|
254
|
+
if (msg === null || typeof msg !== 'object') return;
|
|
255
|
+
const { role, stopReason } = msg as { role?: unknown; stopReason?: unknown };
|
|
256
|
+
if (role === 'assistant' && typeof stopReason === 'string') {
|
|
257
|
+
lastAssistantStopReason = stopReason;
|
|
258
|
+
}
|
|
259
|
+
});
|
|
208
260
|
scoped.on('agent_settled', async () => {
|
|
261
|
+
if (lastAssistantStopReason === ABORT_STOP_REASON) return;
|
|
209
262
|
if (todoReminders >= TODO_REMINDERS_MAX) return;
|
|
210
263
|
if (pollers.size > 0) return; // 有在途子代理 → 主控本来就在等,不催
|
|
211
264
|
if (d.getBlockedDepth() > 0) return; // 等人类回答 → 不催
|
|
@@ -331,8 +384,8 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
331
384
|
): Promise<{ text: string | null; pendingTool: boolean; activity: boolean }> {
|
|
332
385
|
for (const file of await resolveSessionFileCandidates(paneId, cwd)) {
|
|
333
386
|
const entries = readSessionFile(file);
|
|
334
|
-
if (
|
|
335
|
-
const r = lastAssistantText(entries,
|
|
387
|
+
if (entries.length === 0) continue;
|
|
388
|
+
const r = lastAssistantText(entries, sinceTs);
|
|
336
389
|
if (r?.text) return { text: r.text, pendingTool: false, activity: true };
|
|
337
390
|
if (hasPendingToolCall(entries, sinceTs)) return { text: null, pendingTool: true, activity: true };
|
|
338
391
|
if (hasAssistantAfter(entries, sinceTs)) return { text: null, pendingTool: false, activity: true };
|
|
@@ -355,7 +408,11 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
355
408
|
requestId: string,
|
|
356
409
|
): Promise<void> {
|
|
357
410
|
void spawnedAt;
|
|
411
|
+
// B1:无活动预算锚点——working 切片(waitAgent 超时 null)即心跳(O3):每次续命;
|
|
412
|
+
// 只有「不 working 也无结算进展」的真空期才消耗预算(01a03c0d:27min 健康长任务
|
|
413
|
+
// 被旧总墙钟 10min 误杀两次)。startedAt 保留供通知文案诊断。
|
|
358
414
|
const startedAt = Date.now();
|
|
415
|
+
let lastActivityAt = Date.now();
|
|
359
416
|
try {
|
|
360
417
|
while (true) {
|
|
361
418
|
const entry = subs.get(paneId);
|
|
@@ -445,17 +502,29 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
445
502
|
// 已在观察期
|
|
446
503
|
const elapsed = Date.now() - entry.observationStartedAt;
|
|
447
504
|
|
|
448
|
-
// 检测用户介入:查询当前状态是否变为 working
|
|
505
|
+
// 检测用户介入:查询当前状态是否变为 working。
|
|
506
|
+
// B4:观察窗内 working ≠ 必然用户接管——自己的 follow_up 注入也呈现
|
|
507
|
+
// working。距最近机器注入 < MACHINE_INJECT_GRACE_MS 的 working 视为
|
|
508
|
+
// 注入被处理(取消结算继续观察),否则才判接管(01a03c0d:契约消息
|
|
509
|
+
// 20min 后投递,恰好撞进观察窗)。
|
|
449
510
|
try {
|
|
450
511
|
const agents = await client.listAgents();
|
|
451
512
|
const agent = agents.find((a) => a.paneId === paneId);
|
|
452
513
|
if (agent && agent.status === 'working') {
|
|
453
|
-
|
|
454
|
-
|
|
514
|
+
const injectedAgo = Date.now() - (lastMachineInjectAt.get(paneId) ?? 0);
|
|
515
|
+
if (injectedAgo > MACHINE_INJECT_GRACE_MS) {
|
|
516
|
+
// 用户介入!标记接管,取消结算
|
|
517
|
+
entry.userTakeover = true;
|
|
518
|
+
entry.observationStartedAt = Date.now();
|
|
519
|
+
entry.lastAgentStatus = 'working';
|
|
520
|
+
persistSubs();
|
|
521
|
+
continue; // 跳过结算,进入用户接管模式
|
|
522
|
+
}
|
|
523
|
+
// 机器注入在处理中 → 归零观察窗,等这轮 run 收尾再判结算
|
|
455
524
|
entry.observationStartedAt = Date.now();
|
|
456
|
-
entry.lastAgentStatus = 'working';
|
|
457
525
|
persistSubs();
|
|
458
|
-
|
|
526
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
527
|
+
continue;
|
|
459
528
|
}
|
|
460
529
|
} catch {
|
|
461
530
|
// listAgents 失败静默
|
|
@@ -470,7 +539,6 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
470
539
|
// 观察期结束(30s 无用户介入),正常结算
|
|
471
540
|
entry.observationStartedAt = null; // 清除观察期标记
|
|
472
541
|
}
|
|
473
|
-
|
|
474
542
|
// 正常结算逻辑
|
|
475
543
|
// O6:reply 已写的 sessionFile 是权威;poll 只在缺失时用扫描补,绝不反向覆盖
|
|
476
544
|
if (!entry.sessionFile) {
|
|
@@ -481,15 +549,13 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
481
549
|
}
|
|
482
550
|
entry.status = 'consumed';
|
|
483
551
|
entry.consumedAt = Date.now();
|
|
484
|
-
|
|
485
|
-
writeHistory(entry, { outcome: closing });
|
|
552
|
+
writeHistory(entry, { outcome: closing }, 'poll-settle');
|
|
486
553
|
// M17:结算自动对账(先于通知;与 reply 快路径幂等双跑)
|
|
487
554
|
const notes = d.reconcileOnSettlement(description, 'settled');
|
|
488
555
|
const notice = d.withReconcileNotes(
|
|
489
556
|
formatSettlementNotice(`${paneId} (${description})`, closing),
|
|
490
557
|
notes,
|
|
491
558
|
);
|
|
492
|
-
// D50:push 快路径可能已发过通知 → 按轮次(paneId+请求id)去重,只发一条
|
|
493
559
|
if (d.claimSettleNotice(`${paneId}:${requestId}`)) {
|
|
494
560
|
try {
|
|
495
561
|
await injectNotice(notice);
|
|
@@ -501,6 +567,8 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
501
567
|
}
|
|
502
568
|
// 挂起 toolCall(等人类输入)或还没开工 → 继续等
|
|
503
569
|
}
|
|
570
|
+
// B1/O3:state=null(30s 切片超时)= working 中的心跳 → 续命无活动预算。
|
|
571
|
+
if (state === null) lastActivityAt = Date.now();
|
|
504
572
|
// 切片超时:检查 pane 存活与总预算
|
|
505
573
|
let alive = false;
|
|
506
574
|
try {
|
|
@@ -512,7 +580,7 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
512
580
|
entry.status = 'consumed';
|
|
513
581
|
entry.consumedAt = Date.now(); // O8:提前退出路径必须置消费时间,否则 tab TTL 被卡住
|
|
514
582
|
persistSubs();
|
|
515
|
-
writeHistory(entry, { outcome: 'pane closed before settling' });
|
|
583
|
+
writeHistory(entry, { outcome: 'pane closed before settling' }, 'poll-pane-closed');
|
|
516
584
|
const notes = d.reconcileOnSettlement(description, 'failed');
|
|
517
585
|
const notice = d.withReconcileNotes(
|
|
518
586
|
`Background subagent ${paneId} (${description}) stopped before settling (its pane closed).`,
|
|
@@ -525,14 +593,19 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
525
593
|
}
|
|
526
594
|
return;
|
|
527
595
|
}
|
|
528
|
-
|
|
596
|
+
// B1:无活动预算超时——working 心跳持续续命,此处只在「pane 活着但
|
|
597
|
+
// 既不 working、也无结算进展」真空期超预算时触发(旧总墙钟 10min 误杀
|
|
598
|
+
// 27min 健康长任务 ×2,01a03c0d 实证)。
|
|
599
|
+
if (Date.now() - lastActivityAt > SUBAGENT_TIMEOUT_MS) {
|
|
529
600
|
entry.status = 'consumed';
|
|
530
601
|
entry.consumedAt = Date.now(); // O8:observation timeout 同样置消费时间
|
|
531
602
|
persistSubs();
|
|
532
|
-
writeHistory(entry, { outcome: 'observation timeout' });
|
|
603
|
+
writeHistory(entry, { outcome: 'observation timeout' }, 'poll-timeout');
|
|
533
604
|
const notes = d.reconcileOnSettlement(description, 'failed');
|
|
605
|
+
// O2:给行动钩子——list_agents 看活态;真在跑就让它跑完(结算会自动通知),
|
|
606
|
+
// 别 sleep 盲等(01a03c0d 实证 master 收到旧文案后连 sleep 90/120/180)。
|
|
534
607
|
const notice = d.withReconcileNotes(
|
|
535
|
-
`Background subagent ${paneId} (${description})
|
|
608
|
+
`Background subagent ${paneId} (${description}) has shown no progress for ${Math.round((Date.now() - lastActivityAt) / 1000)}s (observed since ${new Date(startedAt).toISOString()}). Run list_agents to check its live state; if it is working, let it run — its settlement notice will arrive automatically. Do not sleep-wait.`,
|
|
536
609
|
notes,
|
|
537
610
|
);
|
|
538
611
|
try {
|
|
@@ -574,7 +647,7 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
574
647
|
return historyFilePath(agentRootDir(), cwd);
|
|
575
648
|
}
|
|
576
649
|
|
|
577
|
-
function toHistory(e: SubEntry): HistoryEntry {
|
|
650
|
+
function toHistory(e: SubEntry, outcome: string | null): HistoryEntry {
|
|
578
651
|
return {
|
|
579
652
|
taskId: e.taskId,
|
|
580
653
|
kind: e.kind,
|
|
@@ -587,7 +660,7 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
587
660
|
sessionFile: e.sessionFile,
|
|
588
661
|
launchCommand: e.launchCommand,
|
|
589
662
|
status: e.status,
|
|
590
|
-
outcome
|
|
663
|
+
outcome,
|
|
591
664
|
createdAt: e.createdAt,
|
|
592
665
|
consumedAt: e.consumedAt ?? null,
|
|
593
666
|
closedAt: null,
|
|
@@ -595,8 +668,14 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
595
668
|
};
|
|
596
669
|
}
|
|
597
670
|
|
|
598
|
-
|
|
599
|
-
|
|
671
|
+
/** B5:每 taskId 最近非空 outcome(closed 补记行继承——latestGeneration 取最新行,
|
|
672
|
+
* 旧实现 closed 行恒 outcome:null → 结算成果在「最新行」语义下丢失)。 */
|
|
673
|
+
const lastOutcomeByTask = new Map<string, string>();
|
|
674
|
+
|
|
675
|
+
function writeHistory(e: SubEntry, patch?: Partial<HistoryEntry>, via?: string): void {
|
|
676
|
+
const outcome = inheritOutcome(lastOutcomeByTask.get(e.taskId), patch?.outcome);
|
|
677
|
+
if (typeof outcome === 'string' && outcome.length > 0) lastOutcomeByTask.set(e.taskId, outcome);
|
|
678
|
+
appendHistory(histFile(e.cwd), { ...toHistory(e, outcome), ...(patch ?? {}), ...(via ? { via } : {}) });
|
|
600
679
|
}
|
|
601
680
|
|
|
602
681
|
/** 存活任务 tab(本 workspace;herdr 为权威,tab.rename/自动关后自动纠正)。 */
|
|
@@ -665,7 +744,7 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
665
744
|
mainTabId,
|
|
666
745
|
});
|
|
667
746
|
if (plan.mode === 'append' && plan.tabId) {
|
|
668
|
-
//
|
|
747
|
+
// D97 网格形态:目标格 = tab 内面积最大的格子,一律 down(全宽横条,title 静帧横读);
|
|
669
748
|
// board 等无 agent 常驻 shell(agentStatus=unknown)不入候选,避免被 worker 蚕食。
|
|
670
749
|
const exclude = new Set(
|
|
671
750
|
allPanes.filter((p) => p.tabId === plan.tabId && p.agentStatus === 'unknown').map((p) => p.paneId),
|
|
@@ -682,7 +761,7 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
682
761
|
?? allPanes.find((p) => p.tabId === plan.tabId)?.paneId;
|
|
683
762
|
if (anchorPaneId) {
|
|
684
763
|
const paneId = await client.splitPane({
|
|
685
|
-
direction: pick?.direction ?? '
|
|
764
|
+
direction: pick?.direction ?? 'down',
|
|
686
765
|
cwd,
|
|
687
766
|
env: envOver,
|
|
688
767
|
targetPaneId: anchorPaneId,
|
|
@@ -708,12 +787,8 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
708
787
|
|
|
709
788
|
function launchLine(resumeFile?: string | null, roleModel?: string | null, approve = false): string {
|
|
710
789
|
// 裸 argv → 平台 shell 语法(win32=PowerShell `&`,POSIX=sh);引号转义在 buildLaunchLine
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
parts.push('-e', runtime.extPath);
|
|
714
|
-
if (roleModel) parts.push('--provider', roleModel.split('/')[0], '--model', roleModel.split('/')[1] ?? roleModel);
|
|
715
|
-
if (resumeFile) parts.push('--session', resumeFile);
|
|
716
|
-
return buildLaunchLine(parts);
|
|
790
|
+
// argv 构造在 buildLaunchParts(D97:默认 --tui-mode fullscreen,PI_HERDR_TUI=regular 逃生)
|
|
791
|
+
return buildLaunchLine(buildLaunchParts(runtime, { resumeFile, roleModel, approve }));
|
|
717
792
|
}
|
|
718
793
|
|
|
719
794
|
/**
|
|
@@ -742,9 +817,10 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
742
817
|
} catch {
|
|
743
818
|
return;
|
|
744
819
|
}
|
|
745
|
-
const statuses = new Map(panesList.map((p) => [p.paneId, p.agentStatus]));
|
|
746
820
|
// D71:有活跃终端会话的 pane 豁免回收(tab 级与 pane 级两处)
|
|
747
821
|
const termPaneIds = terminalState.activePaneIds();
|
|
822
|
+
// B2:结算通知未送达的 pane 豁免(先送达再回收)——pD/pC 实证被 GC 抢关。
|
|
823
|
+
const pendingNoticeIds = d.noticePending?.() ?? new Set<string>();
|
|
748
824
|
|
|
749
825
|
// 任务 tab 分组(含 closed 条目:closed 工作 pane 也算完成)
|
|
750
826
|
const byTab = new Map<string, SubEntry[]>();
|
|
@@ -767,7 +843,7 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
767
843
|
for (const e of entries) {
|
|
768
844
|
if (e.status !== 'closed') {
|
|
769
845
|
e.status = 'closed';
|
|
770
|
-
writeHistory(e, { status: 'closed', closedAt: Date.now() });
|
|
846
|
+
writeHistory(e, { status: 'closed', closedAt: Date.now() }, 'gc');
|
|
771
847
|
}
|
|
772
848
|
}
|
|
773
849
|
continue;
|
|
@@ -779,7 +855,8 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
779
855
|
now: Date.now(),
|
|
780
856
|
});
|
|
781
857
|
if (!autoCloseTabs || !should) continue;
|
|
782
|
-
|
|
858
|
+
// B2:结算通知未送达的 pane 豁免(先送达再回收)
|
|
859
|
+
if (tabPanes.some((p) => termPaneIds.has(p.paneId) || pendingNoticeIds.has(p.paneId))) continue;
|
|
783
860
|
try {
|
|
784
861
|
await client.tabClose(tabId);
|
|
785
862
|
} catch {
|
|
@@ -788,7 +865,7 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
788
865
|
for (const e of entries) {
|
|
789
866
|
if (e.status !== 'closed') {
|
|
790
867
|
e.status = 'closed';
|
|
791
|
-
writeHistory(e, { status: 'closed', closedAt: Date.now() });
|
|
868
|
+
writeHistory(e, { status: 'closed', closedAt: Date.now() }, 'gc');
|
|
792
869
|
}
|
|
793
870
|
}
|
|
794
871
|
await new Promise((r) => setTimeout(r, 300)); // 串行关闭(#1358 同类风险护栏)
|
|
@@ -801,7 +878,7 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
801
878
|
(e) => e.status === 'consumed' && !(e.tabId && closableTaskTabIds.has(e.tabId)),
|
|
802
879
|
);
|
|
803
880
|
for (const e of candidates) {
|
|
804
|
-
if (termPaneIds.has(e.paneId)) continue; //
|
|
881
|
+
if (termPaneIds.has(e.paneId) || pendingNoticeIds.has(e.paneId)) continue; // 活跃终端(D71)/未送达通知(B2)豁免
|
|
805
882
|
if (!shouldClosePane({
|
|
806
883
|
consumedAt: e.consumedAt ?? null,
|
|
807
884
|
herdrStatus: statuses.get(e.paneId),
|
|
@@ -810,7 +887,7 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
810
887
|
if (statuses.get(e.paneId) === undefined) {
|
|
811
888
|
// pane 已消失(随 tab 被关)→ 补记 closed
|
|
812
889
|
e.status = 'closed';
|
|
813
|
-
writeHistory(e, { status: 'closed', closedAt: Date.now() });
|
|
890
|
+
writeHistory(e, { status: 'closed', closedAt: Date.now() }, 'gc');
|
|
814
891
|
continue;
|
|
815
892
|
}
|
|
816
893
|
try {
|
|
@@ -819,7 +896,7 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
819
896
|
/* pane 已消失 */
|
|
820
897
|
}
|
|
821
898
|
e.status = 'closed';
|
|
822
|
-
writeHistory(e, { status: 'closed', closedAt: Date.now() });
|
|
899
|
+
writeHistory(e, { status: 'closed', closedAt: Date.now() }, 'gc');
|
|
823
900
|
await new Promise((r) => setTimeout(r, 300));
|
|
824
901
|
}
|
|
825
902
|
persistSubs();
|
|
@@ -896,7 +973,7 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
896
973
|
entry.revivedFrom = latest.paneId;
|
|
897
974
|
entry.launchCommand = [launchLine(resumeFile, null, approve)];
|
|
898
975
|
entry.createdAt = Date.now();
|
|
899
|
-
writeHistory(entry);
|
|
976
|
+
writeHistory(entry, undefined, 'revive');
|
|
900
977
|
return entry;
|
|
901
978
|
}
|
|
902
979
|
|
|
@@ -996,7 +1073,6 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
996
1073
|
const release = await subSemaphore.acquire();
|
|
997
1074
|
let paneId = '';
|
|
998
1075
|
try {
|
|
999
|
-
onUpdate?.(makeProgressUpdate(`spawning ${kind !== 'task' ? `${kind} ` : ''}${background ? 'background ' : ''}subagent pane…`));
|
|
1000
1076
|
const spawnedAt = Date.now();
|
|
1001
1077
|
const taskId = randomUUID();
|
|
1002
1078
|
const spawned = await spawnPaneInTaskTab(
|
|
@@ -1026,7 +1102,24 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
1026
1102
|
entry.sessionFile = await resolveSessionFile(paneId, cwd);
|
|
1027
1103
|
subs.set(paneId, entry);
|
|
1028
1104
|
persistSubs();
|
|
1105
|
+
onUpdate?.(makeProgressUpdate(`subagent ready in pane ${paneId}; injecting prompt via pipe…`));
|
|
1029
1106
|
// M11(D45/D46):注入走扩展管道 → 子扩展 sendUserMessage(followUp);
|
|
1107
|
+
// PTY 键盘通道 100% 归人(无软锁窗口、无混行)。
|
|
1108
|
+
// 回归修复(session 01a03bf0 实证):ecc0bc4 重写前台等待时误删本块——
|
|
1109
|
+
// prompt 永不注入 + injectTs 未定义(ReferenceError → spawn-failed,
|
|
1110
|
+
// 台账留幽灵 running 条目)。任何后续重构不得把注入与 injectTs 拆开。
|
|
1111
|
+
const injectTs = Date.now();
|
|
1112
|
+
const injected = await pipeRequest(pipeNameFor(cwd, paneId), {
|
|
1113
|
+
type: 'prompt',
|
|
1114
|
+
id: `prompt-${taskId}`,
|
|
1115
|
+
text: spec.prompt,
|
|
1116
|
+
from: pipeNameFor(cwd, env?.paneId ?? ''),
|
|
1117
|
+
push: background,
|
|
1118
|
+
});
|
|
1119
|
+
if (injected.type !== 'ok') {
|
|
1120
|
+
throw new Error(`pipe prompt rejected: ${injected.type === 'error' ? injected.message : 'unknown response'}`);
|
|
1121
|
+
}
|
|
1122
|
+
lastMachineInjectAt.set(paneId, injectTs); // B4:观察窗内 working 归因判据
|
|
1030
1123
|
// A1+A2(用户实证修复):前台等待 = 内容闸 + 耐心阈值转后台。
|
|
1031
1124
|
// 旧实现的 idle 即结算 + 90s 硬窗口在真实任务(working 期 4-6 分钟)上必然误判
|
|
1032
1125
|
// no-output(实测:3 个健康子代理 101s 时被同时 consumed,成果 4 分钟后才产出)。
|
|
@@ -1037,14 +1130,13 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
1037
1130
|
while (Date.now() < Math.min(patienceDeadline, spawnedAt + SUBAGENT_TIMEOUT_MS)) {
|
|
1038
1131
|
const state = await client.waitAgent(paneId, ['idle', 'done', 'blocked'], 30_000);
|
|
1039
1132
|
if (state === 'blocked') {
|
|
1040
|
-
// E1(人类闸门):blocked 不是终态——转后台 poller(人类答后续跑、结算自动通知),
|
|
1041
1133
|
// 返回 gate 通知:不揽活、提醒用户去 pane 答题、授权例外经 send_message 转达。
|
|
1042
1134
|
const question = await readAskFlag(paneId);
|
|
1043
1135
|
entry.background = true;
|
|
1044
1136
|
lastRequestIdByPane.set(paneId, `prompt-${taskId}`);
|
|
1045
1137
|
startPoller(paneId, cwd, spawnedAt, injectTs, spec.description, `prompt-${taskId}`);
|
|
1046
1138
|
persistSubs();
|
|
1047
|
-
writeHistory(entry);
|
|
1139
|
+
writeHistory(entry, undefined, 'to-background');
|
|
1048
1140
|
return {
|
|
1049
1141
|
content: [{ type: 'text', text: buildBlockedGateNotice({ paneId, description: spec.description, question }) }],
|
|
1050
1142
|
details: { paneId, taskId, background: true, blocked: true, role: kind },
|
|
@@ -1094,12 +1186,20 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
1094
1186
|
entry.status = 'consumed';
|
|
1095
1187
|
entry.consumedAt = Date.now();
|
|
1096
1188
|
persistSubs();
|
|
1097
|
-
writeHistory(entry, { outcome: outcome.kind === 'completed' ? text : null });
|
|
1189
|
+
writeHistory(entry, { outcome: outcome.kind === 'completed' ? text : null }, 'fg-settle');
|
|
1098
1190
|
return {
|
|
1099
1191
|
content: [{ type: 'text', text: formatSubagentResult(outcome, spec.description) }],
|
|
1100
1192
|
details: { paneId, taskId, background, role: kind },
|
|
1101
1193
|
};
|
|
1102
1194
|
} catch (err) {
|
|
1195
|
+
// 回归修复(session 01a03bf0):spawn 中途失败必须回收台账 + 关 pane——
|
|
1196
|
+
// 否则幽灵 running 条目永不结算 → D96 提醒风暴 + send_message 打到
|
|
1197
|
+
// 无任务上下文的空会话。pane 关闭尽力而为(board pane 例外场景由 GC 兜底)。
|
|
1198
|
+
if (paneId) {
|
|
1199
|
+
subs.delete(paneId);
|
|
1200
|
+
persistSubs();
|
|
1201
|
+
void client.closePane(paneId).catch(() => { /* 尽力而为 */ });
|
|
1202
|
+
}
|
|
1103
1203
|
return {
|
|
1104
1204
|
content: [{
|
|
1105
1205
|
type: 'text',
|
|
@@ -1165,7 +1265,7 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
1165
1265
|
};
|
|
1166
1266
|
subs.set(entry.paneId, entry);
|
|
1167
1267
|
persistSubs();
|
|
1168
|
-
writeHistory(entry);
|
|
1268
|
+
writeHistory(entry, undefined, 'resume');
|
|
1169
1269
|
return {
|
|
1170
1270
|
content: [{
|
|
1171
1271
|
type: 'text',
|
|
@@ -1265,13 +1365,13 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
1265
1365
|
},
|
|
1266
1366
|
});
|
|
1267
1367
|
|
|
1268
|
-
/* ── 工具:send_message(
|
|
1368
|
+
/* ── 工具:send_message(steer 间隙投递) ── */
|
|
1269
1369
|
|
|
1270
1370
|
scoped.registerTool({
|
|
1271
1371
|
name: 'send_message',
|
|
1272
1372
|
label: 'Send to Subagent',
|
|
1273
1373
|
description:
|
|
1274
|
-
'Send a follow-up message to a background subagent. If it is working, the message is
|
|
1374
|
+
'Send a follow-up message to a background subagent. If it is working, the message is delivered at its next tool-call gap (steer, seconds); if it is idle, it wakes the subagent for a new turn. `agentId` is the id returned by the subagent tool or shown by list_agents.',
|
|
1275
1375
|
parameters: Type.Object({
|
|
1276
1376
|
agentId: Type.String({ description: 'The subagent id (herdr pane id)' }),
|
|
1277
1377
|
message: Type.String({ description: 'The follow-up message' }),
|
|
@@ -1289,7 +1389,9 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
1289
1389
|
subs.set(entry.paneId, entry);
|
|
1290
1390
|
persistSubs();
|
|
1291
1391
|
}
|
|
1292
|
-
// M11(D46):follow_up
|
|
1392
|
+
// M11(D46):follow_up 走扩展管道。B3:带 steer——worker 长跑中途的补充
|
|
1393
|
+
// 指令在 tool-call 间隙秒级到达(旧 followUp 队列语义 = 整个 run 结束才投,
|
|
1394
|
+
// 01a03c0d 实证 20min 延迟致 worker 按旧契约返工)。
|
|
1293
1395
|
const ready = await waitSubReady(entry.cwd, entry.paneId);
|
|
1294
1396
|
if (!ready) throw new Error(`subagent pane ${entry.paneId} pipe not ready`);
|
|
1295
1397
|
const fuId = `fu-${Date.now()}`;
|
|
@@ -1299,10 +1401,12 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
1299
1401
|
text: String(params?.message ?? ''),
|
|
1300
1402
|
from: pipeNameFor(entry.cwd, env?.paneId ?? ''),
|
|
1301
1403
|
push: true,
|
|
1404
|
+
steer: true,
|
|
1302
1405
|
});
|
|
1303
1406
|
if (res.type !== 'ok') {
|
|
1304
1407
|
throw new Error(`pipe follow_up rejected: ${res.type === 'error' ? res.message : 'unknown response'}`);
|
|
1305
1408
|
}
|
|
1409
|
+
lastMachineInjectAt.set(entry.paneId, Date.now()); // B4:观察窗内 working 归因判据
|
|
1306
1410
|
entry.status = 'running';
|
|
1307
1411
|
persistSubs();
|
|
1308
1412
|
lastRequestIdByPane.set(entry.paneId, fuId);
|