pi-pier 0.1.0
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 +62 -0
- package/package.json +55 -0
- package/schemas/role-manifest.schema.json +53 -0
- package/skills/pi-herdr-workflow/SKILL.md +86 -0
- package/src/bootstrap.ts +106 -0
- package/src/core/grid-shape.ts +86 -0
- package/src/core/subagent.ts +1256 -0
- package/src/core/terminal.ts +359 -0
- package/src/core/todo.ts +313 -0
- package/src/gc-core.ts +56 -0
- package/src/herdr-client.ts +579 -0
- package/src/history-store.ts +115 -0
- package/src/index.ts +751 -0
- package/src/ledger.ts +71 -0
- package/src/lock-core.ts +155 -0
- package/src/manifest-compose.ts +102 -0
- package/src/notice-buffer.ts +28 -0
- package/src/pane-title.ts +84 -0
- package/src/pi-surface.ts +135 -0
- package/src/pipe-channel.ts +181 -0
- package/src/progress-core.ts +109 -0
- package/src/reconcile-core.ts +130 -0
- package/src/role-loader.ts +164 -0
- package/src/role-manifest.ts +166 -0
- package/src/roles/master.json +21 -0
- package/src/roles/worker-default.json +30 -0
- package/src/session-tail.ts +169 -0
- package/src/subagent-core.ts +310 -0
- package/src/subagent-scope.ts +39 -0
- package/src/terminal-core.ts +328 -0
- package/src/todo-core.ts +331 -0
- package/src/todo-read-hook.ts +54 -0
- package/src/todos-service.ts +66 -0
- package/src/tool-gate.ts +106 -0
- package/src/vocab.ts +82 -0
|
@@ -0,0 +1,1256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 档1 core/subagent(subagent 族迁 loader entry —— D78 挂载树 / D81 融合核心)。
|
|
3
|
+
*
|
|
4
|
+
* 承接(master-only):五工具(subagent/resume_subagent/list_agents/send_message/
|
|
5
|
+
* interrupt_agent)+ 注册表(subs/pollers/持久化/重建)+ 扩展管道客户端(prompt/
|
|
6
|
+
* follow_up/interrupt 注入)+ poller(结算观察/通知去重)+ 历史(history-store
|
|
7
|
+
* 代际)+ 复活(revive)+ 任务 tab 放置(D25/D26)+ GC(tab 级 + pane 级 + ticker)。
|
|
8
|
+
*
|
|
9
|
+
* 依赖全经 `pi-herdr.subagent-deps`(client/env/extPath/sessionRoot/回调槽…):
|
|
10
|
+
* common 段的 pipe 消费者(applyReplySession/reconcileOnReply)经 slots 反向暴露。
|
|
11
|
+
* reconcileOnSettlement(M17 对账)与 withReconcileNotes/claimSettleNotice 留在
|
|
12
|
+
* index session 状态层,本插件回调消费(③ 的显式留守决策)。
|
|
13
|
+
*/
|
|
14
|
+
import { Context } from '@deepseek-ai/cordis';
|
|
15
|
+
import { Type } from 'typebox';
|
|
16
|
+
import { randomUUID } from 'node:crypto';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
import { homedir } from 'node:os';
|
|
19
|
+
import { join, dirname } from 'node:path';
|
|
20
|
+
import type { PiSurface } from '../pi-surface.ts';
|
|
21
|
+
import type { HerdrClientLike, HerdrAgentState } from '../herdr-client.ts';
|
|
22
|
+
import type { TodosService } from '../todos-service.ts';
|
|
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';
|
|
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';
|
|
27
|
+
import { execFile } from 'node:child_process';
|
|
28
|
+
import { stat as statCb } from 'node:fs';
|
|
29
|
+
import { resolve as pathResolve } from 'node:path';
|
|
30
|
+
import { promisify } from 'node:util';
|
|
31
|
+
const statAsync = promisify(statCb);
|
|
32
|
+
import { pingUntilReady, pipeNameFor, pipeRequest } from '../pipe-channel.ts';
|
|
33
|
+
import { shouldClosePane, shouldCloseTaskTab } from '../gc-core.ts';
|
|
34
|
+
import { composeForRole } from '../manifest-compose.ts';
|
|
35
|
+
import { formatSettlementNotice } from '../vocab.ts';
|
|
36
|
+
import { parseShapeTree, pickGridSplit } from './grid-shape.ts';
|
|
37
|
+
|
|
38
|
+
export interface SubagentEnv {
|
|
39
|
+
paneId: string;
|
|
40
|
+
tabId: string;
|
|
41
|
+
workspaceId: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** common 段 pipe 消费者槽(插件挂载时回填)。 */
|
|
45
|
+
export interface SubagentSlots {
|
|
46
|
+
applyReplySession: ((paneId: string, sessionFile: string | null) => void) | null;
|
|
47
|
+
reconcileOnReply: ((paneId: string) => string[]) | null;
|
|
48
|
+
/** D96:running 后台 subagent 列表(master settled 时检查用;worker 侧为 null)。 */
|
|
49
|
+
listRunningSubs: (() => Array<{ paneId: string; description: string }>) | null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface SubagentDeps {
|
|
53
|
+
client: HerdrClientLike;
|
|
54
|
+
env: SubagentEnv | null;
|
|
55
|
+
/** 本扩展入口(index.ts)路径——launchLine 的 -e 参数。 */
|
|
56
|
+
extPath: string;
|
|
57
|
+
/** master 树根(scope 挂载)。 */
|
|
58
|
+
sessionRoot: Context;
|
|
59
|
+
slots: SubagentSlots;
|
|
60
|
+
getSessionId: () => string;
|
|
61
|
+
getBlockedDepth: () => number;
|
|
62
|
+
reconcileOnSettlement: (description: string, outcome: 'settled' | 'failed') => string[];
|
|
63
|
+
withReconcileNotes: (base: string, notes: readonly string[]) => string;
|
|
64
|
+
claimSettleNotice: (key: string) => boolean;
|
|
65
|
+
/**
|
|
66
|
+
* D92 结算通知注入器(index.ts 提供):忙时入缓冲、turn_end 折叠 steer 批量注入。
|
|
67
|
+
* 缺省回退 pi.sendUserMessage(followUp)(旧路径——整个 run 结束才回填,测试桩用)。
|
|
68
|
+
*/
|
|
69
|
+
deliverNotice?: (content: string) => Promise<void>;
|
|
70
|
+
/** terminal 族 GC 豁免槽。 */
|
|
71
|
+
terminalState: { activePaneIds: () => Set<string> };
|
|
72
|
+
todos: TodosService;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const SUBAGENT_DESCRIPTION = [
|
|
76
|
+
'Delegate a self-contained subtask to an isolated subagent that runs in its own herdr pane as an interactive pi session (separate context window; it does NOT see this conversation). A human can also open that pane and talk to the subagent directly.',
|
|
77
|
+
'`description`: short display label for the pane; `prompt`: the COMPLETE task — include all needed context, since only the prompt reaches the subagent. The description also doubles as the todo-reconcile key: when delegating a todo entry, use the entry content WITHOUT its ` <sub>` marker as the description, and the entry is auto-completed when this subagent settles.',
|
|
78
|
+
'The subagent shares this workspace and works independently; the result is its final text answer.',
|
|
79
|
+
'Concurrent delegation is supported: several subagent calls in one message run in parallel (at most 4 at once). Use this for well-scoped, independent subtasks; do not delegate the current step itself.',
|
|
80
|
+
'`run_in_background` (default false): when true, the call returns immediately with an agentId; the subagent keeps running in its pane. Use list_agents to see its state, send_message to give it follow-up work, interrupt_agent to stop it. When it settles, you receive a notification message with its closing output.',
|
|
81
|
+
'`tab` (optional): name of a task tab to place the subagent into (join if a tab with this name exists, otherwise create it). Overrides the default placement. Default placement groups by git worktree: a subagent working in your checkout shares your tab; one working in a separate worktree (pass its path via `cwd`; create worktrees with `git worktree add`) gets its own tab named after the worktree directory.',
|
|
82
|
+
'`role` + `allowed_tools`: when role matches a profile (searched: workspace .pi-herdr/roles/ → user-global ~/.pi/agent/herdr-pi/roles/ → builtin), the worker toolset becomes the composed manifest — baseline ∪ allowed_tools minus role-deny tools (deny always wins). Custom roles: drop a JSON profile into .pi-herdr/roles/ (master/worker-default reserved). Unknown role names remain display labels only.',
|
|
83
|
+
].join(' ');
|
|
84
|
+
|
|
85
|
+
/** 子代理并发上限(D10)。 */
|
|
86
|
+
const SUBAGENT_CONCURRENCY = 4;
|
|
87
|
+
/** 单子代理等待上限(内部 marker PI_HERDR_SUBAGENT_TIMEOUT_MS 可调,默认 10 分钟)。 */
|
|
88
|
+
const SUBAGENT_TIMEOUT_MS = Number(process.env.PI_HERDR_SUBAGENT_TIMEOUT_MS ?? 600000) || 600000;
|
|
89
|
+
/** 就绪等待上限(TUI 启动 + herdr 检测)。 */
|
|
90
|
+
const SUB_READY_TIMEOUT_MS = 30000;
|
|
91
|
+
|
|
92
|
+
function defaultAgentSessionsDir(): string {
|
|
93
|
+
const base = process.env.PI_CODING_AGENT_DIR || join(homedir(), '.pi', 'agent');
|
|
94
|
+
return join(base, 'sessions');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function agentRootDir(): string {
|
|
98
|
+
return dirname(defaultAgentSessionsDir());
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export default function subagentPlugin(ctx: Context): void {
|
|
102
|
+
const surface = ctx.get('pi-herdr.surface') as PiSurface<object>;
|
|
103
|
+
const d = ctx.get('pi-herdr.subagent-deps') as SubagentDeps;
|
|
104
|
+
const { client, env, sessionRoot, slots, terminalState, todos } = d;
|
|
105
|
+
const pi = surface.raw as {
|
|
106
|
+
appendEntry?: (customType: string, data: unknown) => void;
|
|
107
|
+
sendUserMessage?: (content: string, opts?: { deliverAs?: string }) => Promise<void>;
|
|
108
|
+
};
|
|
109
|
+
const scoped = surface.forModule(import.meta.url);
|
|
110
|
+
|
|
111
|
+
const runtime = {
|
|
112
|
+
nodePath: process.execPath,
|
|
113
|
+
cliPath: process.argv[1] ?? '',
|
|
114
|
+
extPath: d.extPath,
|
|
115
|
+
};
|
|
116
|
+
const subSemaphore = new Semaphore(SUBAGENT_CONCURRENCY);
|
|
117
|
+
|
|
118
|
+
/* 子代理注册表(custom 条目持久化,分支正确;parent 重启后可从分支重建) */
|
|
119
|
+
const subs = new Map<string, SubEntry>();
|
|
120
|
+
const pollers = new Set<string>();
|
|
121
|
+
const subScopes = new Map<string, { dispose: () => Promise<void> }>();
|
|
122
|
+
/** D50:每 pane 最近一次机器请求 id(interrupt 按轮次占位、pollLoop 去重用)。 */
|
|
123
|
+
const lastRequestIdByPane = new Map<string, string>();
|
|
124
|
+
|
|
125
|
+
function persistSubs(): void {
|
|
126
|
+
try {
|
|
127
|
+
pi.appendEntry?.(SUBS_CUSTOM_TYPE, makeRegistry([...subs.values()]));
|
|
128
|
+
} catch {
|
|
129
|
+
/* 持久化尽力而为 */
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// slots 回填:common 段 pipe reply 消费
|
|
134
|
+
slots.applyReplySession = (paneId, sessionFile) => {
|
|
135
|
+
const entry = subs.get(paneId);
|
|
136
|
+
if (!entry) return;
|
|
137
|
+
const next = applyReportedSessionFile(entry.sessionFile, sessionFile);
|
|
138
|
+
if (next === entry.sessionFile) return;
|
|
139
|
+
entry.sessionFile = next;
|
|
140
|
+
persistSubs();
|
|
141
|
+
writeHistory(entry);
|
|
142
|
+
};
|
|
143
|
+
slots.reconcileOnReply = (paneId) => {
|
|
144
|
+
const entry = subs.get(paneId);
|
|
145
|
+
return entry ? d.reconcileOnSettlement(entry.description, 'settled') : [];
|
|
146
|
+
};
|
|
147
|
+
// D96:master settled 时检查还有哪些后台 subagent 在 running(防过早总结)
|
|
148
|
+
slots.listRunningSubs = () => {
|
|
149
|
+
return [...subs.values()]
|
|
150
|
+
.filter((s) => s.background && s.status === 'running')
|
|
151
|
+
.map((s) => ({ paneId: s.paneId, description: s.description }));
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
function rebuildSubs(eventCtx: unknown): void {
|
|
155
|
+
try {
|
|
156
|
+
const entries = (eventCtx as { sessionManager?: { getBranch?: () => readonly unknown[] } })
|
|
157
|
+
?.sessionManager?.getBranch?.() ?? [];
|
|
158
|
+
const reg = foldSubsRegistry(entries as Parameters<typeof foldSubsRegistry>[0]);
|
|
159
|
+
for (const sub of reg.subs) subs.set(sub.paneId, sub);
|
|
160
|
+
} catch {
|
|
161
|
+
/* 重建失败不影响主流程 */
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
scoped.on('session_start', async (_event: unknown, eventCtx: unknown) => {
|
|
166
|
+
rebuildSubs(eventCtx);
|
|
167
|
+
});
|
|
168
|
+
scoped.on('session_tree', async (_event: unknown, eventCtx: unknown) => {
|
|
169
|
+
rebuildSubs(eventCtx);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
/* ── D41:stop 未完成提醒(仅主控;子代理不注册本块) ── */
|
|
173
|
+
|
|
174
|
+
let todoReminders = 0;
|
|
175
|
+
const TODO_REMINDERS_MAX = 3;
|
|
176
|
+
scoped.on('agent_settled', async () => {
|
|
177
|
+
if (todoReminders >= TODO_REMINDERS_MAX) return;
|
|
178
|
+
if (pollers.size > 0) return; // 有在途子代理 → 主控本来就在等,不催
|
|
179
|
+
if (d.getBlockedDepth() > 0) return; // 等人类回答 → 不催
|
|
180
|
+
const open = todos.items.filter((it) => it.status === 'pending' || it.status === 'in_progress');
|
|
181
|
+
if (open.length === 0) return;
|
|
182
|
+
todoReminders += 1;
|
|
183
|
+
const list = open
|
|
184
|
+
.map((it) => (it.status === 'in_progress' ? `▶ ${it.content}` : `· ${it.content}`))
|
|
185
|
+
.join('\n');
|
|
186
|
+
const reminder = `<system-reminder>You stopped with unfinished todos (Reminder ${todoReminders}/${TODO_REMINDERS_MAX}):\n${list}\nContinue working on them before stopping, or call todo_write to update the list if they no longer apply.</system-reminder>`;
|
|
187
|
+
try {
|
|
188
|
+
await pi.sendUserMessage?.(reminder, { deliverAs: 'followUp' });
|
|
189
|
+
} catch {
|
|
190
|
+
/* 静默 */
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
/* ── 通道助手(M11:就绪 = 管道握手) ── */
|
|
195
|
+
|
|
196
|
+
/** 就绪等待:管道 ping(子扩展 session_start 起 server 即就绪;D47)。 */
|
|
197
|
+
async function waitSubReady(cwd: string, paneId: string): Promise<boolean> {
|
|
198
|
+
return pingUntilReady(pipeNameFor(cwd, paneId), SUB_READY_TIMEOUT_MS);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* 子代理会话文件候选(v1.3 M7 修复结算文本串线,实测):
|
|
203
|
+
* 1. 上报路径(本扩展 report_agent_session 的 .jsonl path);
|
|
204
|
+
* 2. 上报 session id → 按 id 还原;
|
|
205
|
+
* 3. 目录内最新 4 个(排除主控自己的会话——父在写盘时其 mtime 常最新,
|
|
206
|
+
* 曾实测致结算文本取到父的回复)。
|
|
207
|
+
*/
|
|
208
|
+
async function resolveSessionFileCandidates(paneId: string, cwd: string): Promise<string[]> {
|
|
209
|
+
const out: string[] = [];
|
|
210
|
+
try {
|
|
211
|
+
const reported = await client.getAgentSessionPath(paneId);
|
|
212
|
+
if (reported) {
|
|
213
|
+
if (/\.jsonl$/.test(reported)) out.push(reported);
|
|
214
|
+
else {
|
|
215
|
+
const byId = sessionFileById(cwd, defaultAgentSessionsDir(), reported);
|
|
216
|
+
if (byId) out.push(byId);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
} catch {
|
|
220
|
+
/* 走目录扫描 */
|
|
221
|
+
}
|
|
222
|
+
const ownSession = d.getSessionId();
|
|
223
|
+
for (const f of listSessionFiles(cwd, defaultAgentSessionsDir(), 4)) {
|
|
224
|
+
if (f !== ownSession && !out.includes(f)) out.push(f);
|
|
225
|
+
}
|
|
226
|
+
return out;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** 定位可解析的子代理会话文件(spawn/结算登记用)。 */
|
|
230
|
+
async function resolveSessionFile(paneId: string, cwd: string): Promise<string | null> {
|
|
231
|
+
for (const file of await resolveSessionFileCandidates(paneId, cwd)) {
|
|
232
|
+
if (readSessionFile(file)) return file;
|
|
233
|
+
}
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** 结算后(带重试)取注入时间点之后的最终回答。 */
|
|
238
|
+
async function collectFinalText(
|
|
239
|
+
paneId: string,
|
|
240
|
+
cwd: string,
|
|
241
|
+
sinceTs: number,
|
|
242
|
+
attempts = 12,
|
|
243
|
+
): Promise<string | null> {
|
|
244
|
+
for (let i = 0; i < attempts; i++) {
|
|
245
|
+
for (const file of await resolveSessionFileCandidates(paneId, cwd)) {
|
|
246
|
+
const entries = readSessionFile(file);
|
|
247
|
+
if (!entries) continue;
|
|
248
|
+
const r = lastAssistantText(entries, { sinceTs });
|
|
249
|
+
if (r?.text) return r.text;
|
|
250
|
+
}
|
|
251
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
252
|
+
}
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* 子会话结算状态(v1.3 M8 结算竞态修复):
|
|
258
|
+
* herdr 的 idle/done 在"注入瞬间"也是真,不能当结算信号;
|
|
259
|
+
* 以会话内容为准——定稿文本 = 已结算;挂起 toolCall = 未结算;
|
|
260
|
+
* 有 assistant 活动但无文本 = 真·无输出;无活动 = 还没开始。
|
|
261
|
+
*/
|
|
262
|
+
async function subSessionState(
|
|
263
|
+
paneId: string,
|
|
264
|
+
cwd: string,
|
|
265
|
+
sinceTs: number,
|
|
266
|
+
): Promise<{ text: string | null; pendingTool: boolean; activity: boolean }> {
|
|
267
|
+
for (const file of await resolveSessionFileCandidates(paneId, cwd)) {
|
|
268
|
+
const entries = readSessionFile(file);
|
|
269
|
+
if (!entries) continue;
|
|
270
|
+
const r = lastAssistantText(entries, { sinceTs });
|
|
271
|
+
if (r?.text) return { text: r.text, pendingTool: false, activity: true };
|
|
272
|
+
if (hasPendingToolCall(entries, sinceTs)) return { text: null, pendingTool: true, activity: true };
|
|
273
|
+
if (hasAssistantAfter(entries, sinceTs)) return { text: null, pendingTool: false, activity: true };
|
|
274
|
+
}
|
|
275
|
+
return { text: null, pendingTool: false, activity: false };
|
|
276
|
+
}
|
|
277
|
+
|
|
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
|
+
/** D92:结算通知统一出口——经 deps 缓冲(忙时 turn_end 折叠注入),缺省回退旧 followUp 直投。 */
|
|
294
|
+
const injectNotice = (content: string): Promise<void> =>
|
|
295
|
+
d.deliverNotice ? d.deliverNotice(content)
|
|
296
|
+
: (pi.sendUserMessage?.(content, { deliverAs: 'followUp' }) ?? Promise.resolve());
|
|
297
|
+
|
|
298
|
+
/** 后台子代理轮询器:结算→取文→followUp 通知;blocked 让位给人类;D94:用户接管检测。 */
|
|
299
|
+
async function pollLoop(
|
|
300
|
+
paneId: string,
|
|
301
|
+
cwd: string,
|
|
302
|
+
spawnedAt: number,
|
|
303
|
+
injectTs: number,
|
|
304
|
+
description: string,
|
|
305
|
+
requestId: string,
|
|
306
|
+
): Promise<void> {
|
|
307
|
+
void spawnedAt;
|
|
308
|
+
const startedAt = Date.now();
|
|
309
|
+
try {
|
|
310
|
+
while (true) {
|
|
311
|
+
const entry = subs.get(paneId);
|
|
312
|
+
if (!entry || entry.status === 'settled') return;
|
|
313
|
+
|
|
314
|
+
// D94:归还检测——若用户已接管且 idle 超 60s,自动归还控制权
|
|
315
|
+
if (entry.userTakeover) {
|
|
316
|
+
try {
|
|
317
|
+
const agents = await client.listAgents();
|
|
318
|
+
const agent = agents.find((a) => a.paneId === paneId);
|
|
319
|
+
if (agent) {
|
|
320
|
+
const currentStatus = agent.status;
|
|
321
|
+
const previousStatus = entry.lastAgentStatus;
|
|
322
|
+
|
|
323
|
+
if (currentStatus === 'idle') {
|
|
324
|
+
// 状态刚变为 idle(从 working/blocked 转来)→ 记录 idle 起点
|
|
325
|
+
if (previousStatus !== 'idle') {
|
|
326
|
+
entry.observationStartedAt = Date.now();
|
|
327
|
+
entry.lastAgentStatus = 'idle';
|
|
328
|
+
persistSubs();
|
|
329
|
+
} else if (entry.observationStartedAt) {
|
|
330
|
+
// 持续 idle → 检查是否超过 60s
|
|
331
|
+
if (Date.now() - entry.observationStartedAt > 60000) {
|
|
332
|
+
// Idle 超 60s,归还控制权
|
|
333
|
+
entry.userTakeover = false;
|
|
334
|
+
entry.observationStartedAt = null;
|
|
335
|
+
entry.lastAgentStatus = null;
|
|
336
|
+
persistSubs();
|
|
337
|
+
// 继续正常管理流程(下一轮会走正常 waitAgent 分支)
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
} else {
|
|
341
|
+
// working/blocked → 重置 idle 计时(只记录状态,不记时间)
|
|
342
|
+
entry.lastAgentStatus = currentStatus;
|
|
343
|
+
entry.observationStartedAt = null; // 清除 idle 计时
|
|
344
|
+
persistSubs();
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
} catch {
|
|
348
|
+
// listAgents 失败静默
|
|
349
|
+
}
|
|
350
|
+
// 已接管,跳过正常管理逻辑,5s 后再检查
|
|
351
|
+
if (entry.userTakeover) {
|
|
352
|
+
await new Promise((resolve) => setTimeout(resolve, 5000));
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
let state: HerdrAgentState | null;
|
|
358
|
+
try {
|
|
359
|
+
state = await client.waitAgent(paneId, ['idle', 'done', 'blocked'], 30000);
|
|
360
|
+
} catch {
|
|
361
|
+
state = null;
|
|
362
|
+
}
|
|
363
|
+
if (state === 'blocked') continue; // 人类闸门:保持 running,通知钩子已触发
|
|
364
|
+
if (state === 'idle' || state === 'done') {
|
|
365
|
+
const s = await subSessionState(paneId, cwd, injectTs);
|
|
366
|
+
if (s.text || (!s.pendingTool && s.activity)) {
|
|
367
|
+
// Settled
|
|
368
|
+
const closing = s.text;
|
|
369
|
+
|
|
370
|
+
// D94:观察期逻辑——settled 后 30s 内检测用户介入
|
|
371
|
+
if (!entry.observationStartedAt) {
|
|
372
|
+
// 首次 settled → 启动 30s 观察期
|
|
373
|
+
entry.observationStartedAt = Date.now();
|
|
374
|
+
entry.lastAgentStatus = 'idle';
|
|
375
|
+
persistSubs();
|
|
376
|
+
// 短暂等待后进入下一轮(观察期检测)
|
|
377
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
378
|
+
continue;
|
|
379
|
+
} else {
|
|
380
|
+
// 已在观察期
|
|
381
|
+
const elapsed = Date.now() - entry.observationStartedAt;
|
|
382
|
+
|
|
383
|
+
// 检测用户介入:查询当前状态是否变为 working
|
|
384
|
+
try {
|
|
385
|
+
const agents = await client.listAgents();
|
|
386
|
+
const agent = agents.find((a) => a.paneId === paneId);
|
|
387
|
+
if (agent && agent.status === 'working') {
|
|
388
|
+
// 用户介入!标记接管,取消结算
|
|
389
|
+
entry.userTakeover = true;
|
|
390
|
+
entry.observationStartedAt = Date.now();
|
|
391
|
+
entry.lastAgentStatus = 'working';
|
|
392
|
+
persistSubs();
|
|
393
|
+
continue; // 跳过结算,进入用户接管模式
|
|
394
|
+
}
|
|
395
|
+
} catch {
|
|
396
|
+
// listAgents 失败静默
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (elapsed < 30000) {
|
|
400
|
+
// 观察期未结束,继续等待
|
|
401
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// 观察期结束(30s 无用户介入),正常结算
|
|
406
|
+
entry.observationStartedAt = null; // 清除观察期标记
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// 正常结算逻辑
|
|
410
|
+
// O6:reply 已写的 sessionFile 是权威;poll 只在缺失时用扫描补,绝不反向覆盖
|
|
411
|
+
if (!entry.sessionFile) {
|
|
412
|
+
entry.sessionFile = applyReportedSessionFile(
|
|
413
|
+
entry.sessionFile,
|
|
414
|
+
await resolveSessionFile(paneId, cwd),
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
entry.status = 'consumed';
|
|
418
|
+
entry.consumedAt = Date.now();
|
|
419
|
+
persistSubs();
|
|
420
|
+
writeHistory(entry, { outcome: closing });
|
|
421
|
+
// M17:结算自动对账(先于通知;与 reply 快路径幂等双跑)
|
|
422
|
+
const notes = d.reconcileOnSettlement(description, 'settled');
|
|
423
|
+
const notice = d.withReconcileNotes(
|
|
424
|
+
formatSettlementNotice(`${paneId} (${description})`, closing),
|
|
425
|
+
notes,
|
|
426
|
+
);
|
|
427
|
+
// D50:push 快路径可能已发过通知 → 按轮次(paneId+请求id)去重,只发一条
|
|
428
|
+
if (d.claimSettleNotice(`${paneId}:${requestId}`)) {
|
|
429
|
+
try {
|
|
430
|
+
await injectNotice(notice);
|
|
431
|
+
} catch {
|
|
432
|
+
/* 注入失败静默(下次 turn 可 list_agents 自查) */
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
// 挂起 toolCall(等人类输入)或还没开工 → 继续等
|
|
438
|
+
}
|
|
439
|
+
// 切片超时:检查 pane 存活与总预算
|
|
440
|
+
let alive = false;
|
|
441
|
+
try {
|
|
442
|
+
alive = (await client.listAgents()).some((a) => a.paneId === paneId);
|
|
443
|
+
} catch {
|
|
444
|
+
alive = true; // 查询失败不误判
|
|
445
|
+
}
|
|
446
|
+
if (!alive) {
|
|
447
|
+
entry.status = 'consumed';
|
|
448
|
+
entry.consumedAt = Date.now(); // O8:提前退出路径必须置消费时间,否则 tab TTL 被卡住
|
|
449
|
+
persistSubs();
|
|
450
|
+
writeHistory(entry, { outcome: 'pane closed before settling' });
|
|
451
|
+
const notes = d.reconcileOnSettlement(description, 'failed');
|
|
452
|
+
const notice = d.withReconcileNotes(
|
|
453
|
+
`Background subagent ${paneId} (${description}) stopped before settling (its pane closed).`,
|
|
454
|
+
notes,
|
|
455
|
+
);
|
|
456
|
+
try {
|
|
457
|
+
await injectNotice(notice);
|
|
458
|
+
} catch {
|
|
459
|
+
/* 静默 */
|
|
460
|
+
}
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
if (Date.now() - startedAt > SUBAGENT_TIMEOUT_MS) {
|
|
464
|
+
entry.status = 'consumed';
|
|
465
|
+
entry.consumedAt = Date.now(); // O8:observation timeout 同样置消费时间
|
|
466
|
+
persistSubs();
|
|
467
|
+
writeHistory(entry, { outcome: 'observation timeout' });
|
|
468
|
+
const notes = d.reconcileOnSettlement(description, 'failed');
|
|
469
|
+
const notice = d.withReconcileNotes(
|
|
470
|
+
`Background subagent ${paneId} (${description}) stopped being observed before settling; check its pane.`,
|
|
471
|
+
notes,
|
|
472
|
+
);
|
|
473
|
+
try {
|
|
474
|
+
await injectNotice(notice);
|
|
475
|
+
} catch {
|
|
476
|
+
/* 静默 */
|
|
477
|
+
}
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
} finally {
|
|
482
|
+
pollers.delete(paneId);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function startPoller(paneId: string, cwd: string, spawnedAt: number, injectTs: number, description: string, requestId: string): void {
|
|
487
|
+
if (pollers.has(paneId)) return;
|
|
488
|
+
pollers.add(paneId);
|
|
489
|
+
void (async () => {
|
|
490
|
+
if (!subScopes.has(paneId)) {
|
|
491
|
+
const fiber = await mountSubagentScope(sessionRoot, paneId, {
|
|
492
|
+
onDispose: () => { pollers.delete(paneId); },
|
|
493
|
+
});
|
|
494
|
+
subScopes.set(paneId, fiber);
|
|
495
|
+
}
|
|
496
|
+
await pollLoop(paneId, cwd, spawnedAt, injectTs, description, requestId);
|
|
497
|
+
const fiber = subScopes.get(paneId);
|
|
498
|
+
subScopes.delete(paneId);
|
|
499
|
+
try { await fiber?.dispose(); } catch { /* already gone */ }
|
|
500
|
+
})();
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/* ── 工具:subagent(v1.3:任务 tab 放置 + 短/长 pane + 历史 + GC) ── */
|
|
504
|
+
|
|
505
|
+
/** 串行化 tab 创建/追加(同消息并行委派的读改写互斥;D26 放置决策在锁内做)。 */
|
|
506
|
+
const tabMutex = new Semaphore(1);
|
|
507
|
+
|
|
508
|
+
function histFile(cwd: string): string {
|
|
509
|
+
return historyFilePath(agentRootDir(), cwd);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function toHistory(e: SubEntry): HistoryEntry {
|
|
513
|
+
return {
|
|
514
|
+
taskId: e.taskId,
|
|
515
|
+
kind: e.kind,
|
|
516
|
+
paneId: e.paneId,
|
|
517
|
+
tabId: e.tabId,
|
|
518
|
+
tabName: e.tabName,
|
|
519
|
+
workspaceId: env?.workspaceId ?? '',
|
|
520
|
+
cwd: e.cwd,
|
|
521
|
+
description: e.description,
|
|
522
|
+
sessionFile: e.sessionFile,
|
|
523
|
+
launchCommand: e.launchCommand,
|
|
524
|
+
status: e.status,
|
|
525
|
+
outcome: null,
|
|
526
|
+
createdAt: e.createdAt,
|
|
527
|
+
consumedAt: e.consumedAt ?? null,
|
|
528
|
+
closedAt: null,
|
|
529
|
+
revivedFrom: e.revivedFrom ?? null,
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function writeHistory(e: SubEntry, patch?: Partial<HistoryEntry>): void {
|
|
534
|
+
appendHistory(histFile(e.cwd), { ...toHistory(e), ...(patch ?? {}) });
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/** 存活任务 tab(本 workspace;herdr 为权威,tab.rename/自动关后自动纠正)。 */
|
|
538
|
+
async function liveTabs(): Promise<Array<{ tabName: string; tabId: string }>> {
|
|
539
|
+
try {
|
|
540
|
+
const ws = env?.workspaceId ?? '';
|
|
541
|
+
return (await client.tabList())
|
|
542
|
+
.filter((t) => !ws || t.workspaceId === ws)
|
|
543
|
+
.map((t) => ({ tabName: t.label, tabId: t.tabId }));
|
|
544
|
+
} catch {
|
|
545
|
+
return [];
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/* ── D86:git worktree 分组键 ─────────────────────────────────── */
|
|
550
|
+
|
|
551
|
+
/** worktree 列表缓存(spawn 时一次查询;TTL 短——放置只需近似新鲜度)。 */
|
|
552
|
+
let worktreesCache: { at: number; list: string[] } | null = null;
|
|
553
|
+
const WORKTREES_CACHE_MS = 5000;
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* 列出 repo 的全部 git worktree(`git worktree list --porcelain`,主检出在前)。
|
|
557
|
+
* 非 git 目录 / git 不可用 → 空数组(分类器自然全兜底 main,规则退化不炸)。
|
|
558
|
+
*/
|
|
559
|
+
async function listWorktrees(cwd: string): Promise<string[]> {
|
|
560
|
+
if (worktreesCache && Date.now() - worktreesCache.at < WORKTREES_CACHE_MS) return worktreesCache.list;
|
|
561
|
+
const list = await new Promise<string[]>((resolve) => {
|
|
562
|
+
execFile('git', ['-C', cwd, 'worktree', 'list', '--porcelain'], { timeout: 5000 }, (err, stdout) => {
|
|
563
|
+
if (err) return resolve([]);
|
|
564
|
+
const out: string[] = [];
|
|
565
|
+
for (const line of String(stdout).split('\n')) {
|
|
566
|
+
const m = /^worktree (.+)$/.exec(line.trim());
|
|
567
|
+
if (m) out.push(m[1]);
|
|
568
|
+
}
|
|
569
|
+
resolve(out);
|
|
570
|
+
});
|
|
571
|
+
});
|
|
572
|
+
worktreesCache = { at: Date.now(), list };
|
|
573
|
+
return list;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* 按放置计划在任务 tab 内新建子 pane(v1.3 D25/D26 + D86 worktree 分组):
|
|
578
|
+
* - new:tab.create{label}(focus 默认 false 不抢焦点)+ 根 pane 注入启动命令;
|
|
579
|
+
* - append:校验既有 tab 存活 → split 追加(进程不重启);
|
|
580
|
+
* tab 已消失 → 降级为同名的 new。放置决策在互斥锁内做(同名竞态)。
|
|
581
|
+
* D86:placement.zone 由调用方算好传入(main → master 所在 tab;worktree → 目录名 tab)。
|
|
582
|
+
*/
|
|
583
|
+
async function spawnPaneInTaskTab(
|
|
584
|
+
placement: { desiredTab?: string | null; description: string; zone?: WorktreeZone },
|
|
585
|
+
cwd: string,
|
|
586
|
+
envOver: Record<string, string>,
|
|
587
|
+
launch: string,
|
|
588
|
+
): Promise<{ tabId: string; paneId: string; tabName: string }> {
|
|
589
|
+
const release = await tabMutex.acquire();
|
|
590
|
+
try {
|
|
591
|
+
// D86:main tab = master pane 所在 tab(paneId 反查;HERDR_TAB_ID 注入不可依赖,实测可为空)
|
|
592
|
+
const allPanes = await client.listPanes();
|
|
593
|
+
const mainTabId = allPanes.find((p) => p.paneId === env?.paneId)?.tabId
|
|
594
|
+
?? (env?.tabId ? env.tabId : null);
|
|
595
|
+
let plan: TabPlacementPlan = planTabPlacement({
|
|
596
|
+
desiredTab: placement.desiredTab,
|
|
597
|
+
description: placement.description,
|
|
598
|
+
knownTabs: await liveTabs(),
|
|
599
|
+
zone: placement.zone,
|
|
600
|
+
mainTabId,
|
|
601
|
+
});
|
|
602
|
+
if (plan.mode === 'append' && plan.tabId) {
|
|
603
|
+
// D91 网格形态:目标格 = tab 内面积最大的格子(沿长轴分裂,自然铺成方格);
|
|
604
|
+
// board 等无 agent 常驻 shell(agentStatus=unknown)不入候选,避免被 worker 蚕食。
|
|
605
|
+
const exclude = new Set(
|
|
606
|
+
allPanes.filter((p) => p.tabId === plan.tabId && p.agentStatus === 'unknown').map((p) => p.paneId),
|
|
607
|
+
);
|
|
608
|
+
let pick: { targetPaneId: string; direction: 'right' | 'down' } | null = null;
|
|
609
|
+
try {
|
|
610
|
+
const snapshot = await client.exportLayout({ tabId: plan.tabId });
|
|
611
|
+
const tree = snapshot?.root ? parseShapeTree(snapshot.root) : null;
|
|
612
|
+
if (tree) pick = pickGridSplit(tree, { exclude });
|
|
613
|
+
} catch { /* 布局导出失败 → 回退锚分裂(旧行为) */ }
|
|
614
|
+
// 锚兜底:agent 已知状态的工作 pane 优先(避开 board pane)
|
|
615
|
+
const anchorPaneId = pick?.targetPaneId
|
|
616
|
+
?? allPanes.find((p) => p.tabId === plan.tabId && p.agentStatus !== 'unknown')?.paneId
|
|
617
|
+
?? allPanes.find((p) => p.tabId === plan.tabId)?.paneId;
|
|
618
|
+
if (anchorPaneId) {
|
|
619
|
+
const paneId = await client.splitPane({
|
|
620
|
+
direction: pick?.direction ?? 'right',
|
|
621
|
+
cwd,
|
|
622
|
+
env: envOver,
|
|
623
|
+
targetPaneId: anchorPaneId,
|
|
624
|
+
});
|
|
625
|
+
await client.sendPaneText(paneId, launch);
|
|
626
|
+
return { tabId: plan.tabId!, paneId, tabName: plan.tabName };
|
|
627
|
+
}
|
|
628
|
+
// 锚 pane 缺失(tab 已空/已关)→ 同名新 tab;main tab 消失(罕见)→ 兜底新 tab
|
|
629
|
+
plan = { mode: 'new', tabName: plan.tabName, tabId: null };
|
|
630
|
+
}
|
|
631
|
+
const created = await client.createTab({
|
|
632
|
+
workspaceId: env?.workspaceId ?? '',
|
|
633
|
+
label: plan.tabName,
|
|
634
|
+
cwd,
|
|
635
|
+
env: envOver,
|
|
636
|
+
});
|
|
637
|
+
await client.sendPaneText(created.paneId, launch);
|
|
638
|
+
return { tabId: created.tabId, paneId: created.paneId, tabName: plan.tabName };
|
|
639
|
+
} finally {
|
|
640
|
+
release();
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function launchLine(resumeFile?: string | null, roleModel?: string | null, approve = false): string {
|
|
645
|
+
// 裸 argv → 平台 shell 语法(win32=PowerShell `&`,POSIX=sh);引号转义在 buildLaunchLine
|
|
646
|
+
const parts = [runtime.nodePath, runtime.cliPath];
|
|
647
|
+
if (approve) parts.push('-a');
|
|
648
|
+
parts.push('-e', runtime.extPath);
|
|
649
|
+
if (roleModel) parts.push('--provider', roleModel.split('/')[0], '--model', roleModel.split('/')[1] ?? roleModel);
|
|
650
|
+
if (resumeFile) parts.push('--session', resumeFile);
|
|
651
|
+
return buildLaunchLine(parts);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* D86 信任旗标:委派即信任,但仅限 master 自己的检出与其 worktree(同 git 仓库 =
|
|
656
|
+
* 同一批项目文件,master 本就载着它们跑)。外来目录不加 -a —— pi 的 Trust 对话框
|
|
657
|
+
* 成为天然闸门(spawn 会在握手超时处失败,人不点头不执行未知项目扩展)。
|
|
658
|
+
*/
|
|
659
|
+
async function approveFor(cwd: string, masterCwd: string): Promise<boolean> {
|
|
660
|
+
if (isPathUnder(cwd, masterCwd)) return true;
|
|
661
|
+
const zone = classifyWorktreeZone({ cwd, masterCwd, worktrees: await listWorktrees(masterCwd) });
|
|
662
|
+
return zone.zone === 'worktree';
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/* ── GC:turn_start 回收(v1.3 M8:tab 级为主 + pane 级兼容/孤儿路径) ── */
|
|
666
|
+
|
|
667
|
+
let prevTurnStart = Date.now();
|
|
668
|
+
|
|
669
|
+
async function gcPass(): Promise<void> {
|
|
670
|
+
if (subs.size === 0) return;
|
|
671
|
+
// D44:唯一用户可见开关——TTL 秒(默认 600);0 = 不自动关(只由人关)
|
|
672
|
+
const ttlMs = Number(process.env.PI_HERDR_TASK_TAB_TTL ?? 600) * 1000;
|
|
673
|
+
const autoCloseTabs = ttlMs > 0;
|
|
674
|
+
let panesList: Array<{ paneId: string; tabId: string; agentStatus: string }>;
|
|
675
|
+
try {
|
|
676
|
+
panesList = await client.listPanes();
|
|
677
|
+
} catch {
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
const statuses = new Map(panesList.map((p) => [p.paneId, p.agentStatus]));
|
|
681
|
+
// D71:有活跃终端会话的 pane 豁免回收(tab 级与 pane 级两处)
|
|
682
|
+
const termPaneIds = terminalState.activePaneIds();
|
|
683
|
+
|
|
684
|
+
// 任务 tab 分组(含 closed 条目:closed 工作 pane 也算完成)
|
|
685
|
+
const byTab = new Map<string, SubEntry[]>();
|
|
686
|
+
for (const e of subs.values()) {
|
|
687
|
+
if (!e.tabId) continue;
|
|
688
|
+
const arr = byTab.get(e.tabId) ?? [];
|
|
689
|
+
arr.push(e);
|
|
690
|
+
byTab.set(e.tabId, arr);
|
|
691
|
+
}
|
|
692
|
+
const taskTabIds = new Set(byTab.keys());
|
|
693
|
+
// D86 R4:main tab(master 所在)永不整关——它的 consumed 子代理走 pane 级回收
|
|
694
|
+
const mainTabId = env?.tabId ?? '';
|
|
695
|
+
|
|
696
|
+
// 1) tab 级(判定规则在 gc-core.shouldCloseTaskTab,纯函数单测覆盖)
|
|
697
|
+
for (const [tabId, entries] of byTab) {
|
|
698
|
+
if (tabId === mainTabId) continue; // D86 R4:main tab 豁免(防 master 连坐)
|
|
699
|
+
const tabPanes = panesList.filter((p) => p.tabId === tabId);
|
|
700
|
+
if (tabPanes.length === 0) {
|
|
701
|
+
// tab 已消失(人关/自动关)→ 补记 closed
|
|
702
|
+
for (const e of entries) {
|
|
703
|
+
if (e.status !== 'closed') {
|
|
704
|
+
e.status = 'closed';
|
|
705
|
+
writeHistory(e, { status: 'closed', closedAt: Date.now() });
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
continue;
|
|
709
|
+
}
|
|
710
|
+
const should = shouldCloseTaskTab({
|
|
711
|
+
entries,
|
|
712
|
+
paneStatuses: tabPanes.map((p) => p.agentStatus),
|
|
713
|
+
ttlMs,
|
|
714
|
+
now: Date.now(),
|
|
715
|
+
});
|
|
716
|
+
if (!autoCloseTabs || !should) continue;
|
|
717
|
+
if (tabPanes.some((p) => termPaneIds.has(p.paneId))) continue; // 活跃终端豁免
|
|
718
|
+
try {
|
|
719
|
+
await client.tabClose(tabId);
|
|
720
|
+
} catch {
|
|
721
|
+
/* tab 已消失 */
|
|
722
|
+
}
|
|
723
|
+
for (const e of entries) {
|
|
724
|
+
if (e.status !== 'closed') {
|
|
725
|
+
e.status = 'closed';
|
|
726
|
+
writeHistory(e, { status: 'closed', closedAt: Date.now() });
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
await new Promise((r) => setTimeout(r, 300)); // 串行关闭(#1358 同类风险护栏)
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// 2) pane 级(v1.2 兼容路径 + 孤儿 + D86 main tab 回收):
|
|
733
|
+
// 不属于任何「可整关任务 tab」的 consumed 短 pane(main tab 也算——R4)
|
|
734
|
+
const closableTaskTabIds = new Set([...taskTabIds].filter((t) => t !== mainTabId));
|
|
735
|
+
const candidates = [...subs.values()].filter(
|
|
736
|
+
(e) => e.status === 'consumed' && !(e.tabId && closableTaskTabIds.has(e.tabId)),
|
|
737
|
+
);
|
|
738
|
+
for (const e of candidates) {
|
|
739
|
+
if (termPaneIds.has(e.paneId)) continue; // 活跃终端豁免(D71)
|
|
740
|
+
if (!shouldClosePane({
|
|
741
|
+
consumedAt: e.consumedAt ?? null,
|
|
742
|
+
herdrStatus: statuses.get(e.paneId),
|
|
743
|
+
prevTurnStart,
|
|
744
|
+
})) continue;
|
|
745
|
+
if (statuses.get(e.paneId) === undefined) {
|
|
746
|
+
// pane 已消失(随 tab 被关)→ 补记 closed
|
|
747
|
+
e.status = 'closed';
|
|
748
|
+
writeHistory(e, { status: 'closed', closedAt: Date.now() });
|
|
749
|
+
continue;
|
|
750
|
+
}
|
|
751
|
+
try {
|
|
752
|
+
await client.closePane(e.paneId);
|
|
753
|
+
} catch {
|
|
754
|
+
/* pane 已消失 */
|
|
755
|
+
}
|
|
756
|
+
e.status = 'closed';
|
|
757
|
+
writeHistory(e, { status: 'closed', closedAt: Date.now() });
|
|
758
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
759
|
+
}
|
|
760
|
+
persistSubs();
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/** GC 互斥 + 双驱动:turn_start 与周期 ticker。 */
|
|
764
|
+
let gcRunning = false;
|
|
765
|
+
async function runGcSafely(): Promise<void> {
|
|
766
|
+
if (gcRunning) return;
|
|
767
|
+
gcRunning = true;
|
|
768
|
+
try {
|
|
769
|
+
await gcPass();
|
|
770
|
+
} catch {
|
|
771
|
+
/* GC 失败静默,下轮/tick 重试 */
|
|
772
|
+
} finally {
|
|
773
|
+
gcRunning = false;
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
scoped.on('turn_start', async () => {
|
|
778
|
+
const now = Date.now();
|
|
779
|
+
await runGcSafely();
|
|
780
|
+
prevTurnStart = now;
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
const gcTickMs = Number(process.env.PI_HERDR_GC_TICK_MS ?? 30000);
|
|
784
|
+
const gcTicker = gcTickMs > 0 ? setInterval(() => { void runGcSafely(); }, gcTickMs) : null;
|
|
785
|
+
// GC ticker 经 effect 拆(hmr 重载本模块 = 旧 ticker 拆、新 ticker 起,D80⑤ 语义)。
|
|
786
|
+
// 注意:pipe server 的关闭 effect **不在这里**——server 由 index common 段创建持有,
|
|
787
|
+
// 挂进本插件会被 hmr 重载误杀(跨属资源,d87 修)。
|
|
788
|
+
ctx.effect(() => () => {
|
|
789
|
+
if (gcTicker) clearInterval(gcTicker);
|
|
790
|
+
}, 'gc-ticker');
|
|
791
|
+
|
|
792
|
+
/**
|
|
793
|
+
* D94:查找已存在的同 session pane(resume 复用而非重复 spawn)。
|
|
794
|
+
* 返回 paneId + tabId,若无匹配或 agent 已死则 null。
|
|
795
|
+
*/
|
|
796
|
+
async function findExistingPaneWithSession(sessionFile: string | null): Promise<{ paneId: string; tabId: string } | null> {
|
|
797
|
+
if (!sessionFile) return null;
|
|
798
|
+
try {
|
|
799
|
+
const agents = await client.listAgents();
|
|
800
|
+
const match = agents.find((a) => a.session === sessionFile && a.status !== 'unknown');
|
|
801
|
+
if (!match) return null;
|
|
802
|
+
// listAgents 不带 tabId,需要 pane.list 补
|
|
803
|
+
const panes = await client.listPanes();
|
|
804
|
+
const pane = panes.find((p) => p.paneId === match.paneId);
|
|
805
|
+
return pane ? { paneId: match.paneId, tabId: pane.tabId } : null;
|
|
806
|
+
} catch {
|
|
807
|
+
return null;
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/** 复活已关闭任务(resume 工具与 send_message 自动复活共用)。 */
|
|
812
|
+
async function reviveEntry(entry: SubEntry): Promise<SubEntry> {
|
|
813
|
+
const latest = latestGeneration(readHistory(histFile(entry.cwd)), entry.taskId) ?? entry;
|
|
814
|
+
const resumeFile = latest.sessionFile && /\.jsonl$/.test(latest.sessionFile) ? latest.sessionFile : null;
|
|
815
|
+
// D86 信任旗标同 spawn:master 检出/worktree 才 -a(revive 无 toolCtx,用进程 cwd 代 master 检出)
|
|
816
|
+
const approve = await approveFor(entry.cwd, process.cwd());
|
|
817
|
+
const spawned = await spawnPaneInTaskTab(
|
|
818
|
+
{ desiredTab: entry.tabName || latest.tabName || null, description: entry.description },
|
|
819
|
+
entry.cwd,
|
|
820
|
+
{ PI_HERDR_SUBAGENT: '1' },
|
|
821
|
+
launchLine(resumeFile, null, approve),
|
|
822
|
+
);
|
|
823
|
+
const ready = await waitSubReady(entry.cwd, spawned.paneId);
|
|
824
|
+
if (!ready) throw new Error(`revived pane ${spawned.paneId} pipe not ready`);
|
|
825
|
+
entry.paneId = spawned.paneId;
|
|
826
|
+
entry.tabId = spawned.tabId;
|
|
827
|
+
entry.tabName = spawned.tabName;
|
|
828
|
+
entry.sessionFile = resumeFile;
|
|
829
|
+
entry.status = 'running';
|
|
830
|
+
entry.consumedAt = null;
|
|
831
|
+
entry.revivedFrom = latest.paneId;
|
|
832
|
+
entry.launchCommand = [launchLine(resumeFile, null, approve)];
|
|
833
|
+
entry.createdAt = Date.now();
|
|
834
|
+
writeHistory(entry);
|
|
835
|
+
return entry;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
scoped.registerTool({
|
|
839
|
+
name: 'subagent',
|
|
840
|
+
label: 'Subagent',
|
|
841
|
+
description: SUBAGENT_DESCRIPTION,
|
|
842
|
+
parameters: Type.Object({
|
|
843
|
+
description: Type.String({ description: 'Short label for this subtask (pane title)' }),
|
|
844
|
+
prompt: Type.String({ description: 'The complete self-contained task for the subagent' }),
|
|
845
|
+
run_in_background: Type.Optional(Type.Boolean({ description: 'Return immediately with an agentId; the subagent keeps running in its own pane (default false)' })),
|
|
846
|
+
cwd: Type.Optional(Type.String({ description: 'Working directory for the subagent (absolute, or relative to this workspace). Use it to delegate into a git worktree: panes group by worktree — same checkout as you share your tab; a separate worktree gets its own tab named after the worktree directory. Create worktrees yourself with git worktree add' })),
|
|
847
|
+
role: Type.Optional(Type.String({ description: 'Role for this pane. Role profiles are searched in this order: workspace .pi-herdr/roles/<name>.json → user-global ~/.pi/agent/herdr-pi/roles/<name>.json → builtin src/roles/. Builtins: worker-default [default, read+write; omit role = this]. Custom roles: add a JSON profile in .pi-herdr/roles/ (fields: role/version/manifest{tools,rules,unknownTools}); names master/worker-default are reserved. A matching profile governs the toolset (deny rules cannot be bypassed); unknown role names remain display labels only' })),
|
|
848
|
+
allowed_tools: Type.Optional(Type.Array(Type.String(), { description: 'Extra tools the task needs beyond the role baseline (union; role deny rules always win). Only meaningful with a role profile' })),
|
|
849
|
+
tab: Type.Optional(Type.String({ description: 'Task tab name: join the existing tab with this name, or create a new tab named this. Overrides the default worktree-based placement; omit for automatic placement (same checkout = your tab, separate worktree = its own tab)' })),
|
|
850
|
+
}),
|
|
851
|
+
async execute(toolCallId, params, signal, onUpdate, toolCtx) {
|
|
852
|
+
void toolCallId;
|
|
853
|
+
void signal;
|
|
854
|
+
if (!client.available) {
|
|
855
|
+
return {
|
|
856
|
+
content: [{
|
|
857
|
+
type: 'text',
|
|
858
|
+
text: 'Error: subagent requires pi to run inside a herdr-managed pane (HERDR_ENV not set).',
|
|
859
|
+
}],
|
|
860
|
+
details: {},
|
|
861
|
+
};
|
|
862
|
+
}
|
|
863
|
+
const spec: SubagentSpec = {
|
|
864
|
+
description: String(params?.description ?? 'subagent'),
|
|
865
|
+
prompt: String(params?.prompt ?? ''),
|
|
866
|
+
};
|
|
867
|
+
if (!spec.prompt.trim()) {
|
|
868
|
+
return {
|
|
869
|
+
content: [{ type: 'text', text: 'Error: `prompt` must be a non-empty string' }],
|
|
870
|
+
details: {},
|
|
871
|
+
};
|
|
872
|
+
}
|
|
873
|
+
const background = params?.run_in_background === true;
|
|
874
|
+
const kind = normalizeEntryKind(typeof params?.role === 'string' ? params.role.trim() : undefined);
|
|
875
|
+
const masterCwd = (toolCtx as { cwd?: string }).cwd ?? process.cwd();
|
|
876
|
+
// D86 R5:cwd 参数 = 跨 worktree 委派的事实键(目录是事实,worktree 是分组推论)。
|
|
877
|
+
// 相对路径相对 master cwd 解析;不存在的目录拒绝(spawn 进无效目录只会白开 pane)。
|
|
878
|
+
const cwdParam = typeof params?.cwd === 'string' && params.cwd.trim() ? params.cwd.trim() : null;
|
|
879
|
+
let cwd = masterCwd;
|
|
880
|
+
if (cwdParam) {
|
|
881
|
+
cwd = pathResolve(masterCwd, cwdParam);
|
|
882
|
+
try {
|
|
883
|
+
const st = await statAsync(cwd);
|
|
884
|
+
if (!st.isDirectory()) throw new Error('not a directory');
|
|
885
|
+
} catch {
|
|
886
|
+
return {
|
|
887
|
+
content: [{ type: 'text', text: `Error: \`cwd\` is not an existing directory: ${cwdParam}` }],
|
|
888
|
+
details: {},
|
|
889
|
+
};
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
// D86 R1:分组键 = cwd 所属 git worktree(主检出 → main tab;其他 worktree → 目录名 tab)
|
|
893
|
+
const zone = classifyWorktreeZone({
|
|
894
|
+
cwd,
|
|
895
|
+
masterCwd,
|
|
896
|
+
worktrees: await listWorktrees(masterCwd),
|
|
897
|
+
});
|
|
898
|
+
// D86 信任旗标:master 自己的检出/worktree 才 -a;外来目录留 Trust 对话框作闸门
|
|
899
|
+
const approve = isPathUnder(cwd, masterCwd) || zone.zone === 'worktree';
|
|
900
|
+
// 档2(C7 v2):role 命中档案 → 三态合成 manifest,env 下发 worker。
|
|
901
|
+
// roles-v1.0(V57/V58):省略 role = worker-default manifest 生效
|
|
902
|
+
// (默认 executor:能写能执行、禁嵌套 spawn)——kind 历史分类不受影响。
|
|
903
|
+
const suggested = Array.isArray(params?.allowed_tools)
|
|
904
|
+
? params.allowed_tools.filter((t): t is string => typeof t === 'string' && t.trim() !== '')
|
|
905
|
+
: [];
|
|
906
|
+
const manifestRole =
|
|
907
|
+
typeof params?.role === 'string' && params.role.trim() ? params.role.trim() : 'worker-default';
|
|
908
|
+
let roleManifestEnv: Record<string, string> = {};
|
|
909
|
+
let roleModel: string | null = null;
|
|
910
|
+
try {
|
|
911
|
+
const { role, manifest } = composeForRole(manifestRole, suggested, { loadRoleOpts: { baseDir: masterCwd } });
|
|
912
|
+
roleManifestEnv = {
|
|
913
|
+
PI_HERDR_ROLE_MANIFEST: JSON.stringify({
|
|
914
|
+
role: role.role,
|
|
915
|
+
version: role.version,
|
|
916
|
+
tools: manifest.tools,
|
|
917
|
+
permissions: manifest.permissions,
|
|
918
|
+
unknownTools: manifest.unknownTools,
|
|
919
|
+
services: role.services ?? {},
|
|
920
|
+
}),
|
|
921
|
+
};
|
|
922
|
+
// WS-D10:按角色路由模型;省略 = 进程默认
|
|
923
|
+
if (typeof role.model === 'string' && role.model.trim()) roleModel = role.model.trim();
|
|
924
|
+
} catch (err) {
|
|
925
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
926
|
+
return {
|
|
927
|
+
content: [{ type: 'text', text: `Error: role "${manifestRole}" manifest invalid — ${msg}` }],
|
|
928
|
+
details: {},
|
|
929
|
+
};
|
|
930
|
+
}
|
|
931
|
+
const release = await subSemaphore.acquire();
|
|
932
|
+
let paneId = '';
|
|
933
|
+
try {
|
|
934
|
+
onUpdate?.(makeProgressUpdate(`spawning ${kind !== 'task' ? `${kind} ` : ''}${background ? 'background ' : ''}subagent pane…`));
|
|
935
|
+
const spawnedAt = Date.now();
|
|
936
|
+
const taskId = randomUUID();
|
|
937
|
+
const spawned = await spawnPaneInTaskTab(
|
|
938
|
+
{ desiredTab: typeof params?.tab === 'string' ? params.tab : null, description: spec.description, zone },
|
|
939
|
+
cwd,
|
|
940
|
+
{ PI_HERDR_SUBAGENT: '1', ...roleManifestEnv },
|
|
941
|
+
launchLine(null, roleModel, approve),
|
|
942
|
+
);
|
|
943
|
+
paneId = spawned.paneId;
|
|
944
|
+
const entry: SubEntry = {
|
|
945
|
+
taskId,
|
|
946
|
+
kind,
|
|
947
|
+
paneId,
|
|
948
|
+
tabId: spawned.tabId,
|
|
949
|
+
tabName: spawned.tabName,
|
|
950
|
+
cwd,
|
|
951
|
+
description: spec.description,
|
|
952
|
+
background,
|
|
953
|
+
status: 'running',
|
|
954
|
+
sessionFile: null,
|
|
955
|
+
launchCommand: [launchLine(null, roleModel, approve)],
|
|
956
|
+
createdAt: Date.now(),
|
|
957
|
+
revivedFrom: null,
|
|
958
|
+
};
|
|
959
|
+
const ready = await waitSubReady(cwd, paneId);
|
|
960
|
+
if (!ready) throw new Error(`subagent pane ${paneId} pipe not ready within ${SUB_READY_TIMEOUT_MS}ms`);
|
|
961
|
+
entry.sessionFile = await resolveSessionFile(paneId, cwd);
|
|
962
|
+
subs.set(paneId, entry);
|
|
963
|
+
persistSubs();
|
|
964
|
+
writeHistory(entry);
|
|
965
|
+
onUpdate?.(makeProgressUpdate(`subagent ready in pane ${paneId}; injecting prompt via pipe…`));
|
|
966
|
+
// M11(D45/D46):注入走扩展管道 → 子扩展 sendUserMessage(followUp);
|
|
967
|
+
// PTY 键盘通道 100% 归人(无软锁窗口、无混行)。
|
|
968
|
+
const injectTs = Date.now();
|
|
969
|
+
const injected = await pipeRequest(pipeNameFor(cwd, paneId), {
|
|
970
|
+
type: 'prompt',
|
|
971
|
+
id: `prompt-${taskId}`,
|
|
972
|
+
text: spec.prompt,
|
|
973
|
+
from: pipeNameFor(cwd, env?.paneId ?? ''),
|
|
974
|
+
push: background,
|
|
975
|
+
});
|
|
976
|
+
if (injected.type !== 'ok') {
|
|
977
|
+
throw new Error(`pipe prompt rejected: ${injected.type === 'error' ? injected.message : 'unknown response'}`);
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
if (background) {
|
|
981
|
+
lastRequestIdByPane.set(paneId, `prompt-${taskId}`);
|
|
982
|
+
startPoller(paneId, cwd, spawnedAt, injectTs, spec.description, `prompt-${taskId}`);
|
|
983
|
+
return {
|
|
984
|
+
content: [{ type: 'text', text: `started subagent ${paneId} (task ${taskId})` }],
|
|
985
|
+
details: { paneId, taskId, background: true, role: kind },
|
|
986
|
+
};
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
let settled = await waitSubSettled(paneId, SUBAGENT_TIMEOUT_MS);
|
|
990
|
+
let text = await collectFinalText(paneId, cwd, injectTs);
|
|
991
|
+
// v1.3 M8:早结算竞态(注入瞬间 herdr 仍是 idle)→ 无文本时再等真实结算(最多 90s)
|
|
992
|
+
if (settled === 'settled' && !text) {
|
|
993
|
+
const reDeadline = Date.now() + 90000;
|
|
994
|
+
while (Date.now() < reDeadline) {
|
|
995
|
+
await new Promise((r) => setTimeout(r, 3000));
|
|
996
|
+
const st = await client.waitAgent(paneId, ['idle', 'done', 'blocked'], 15000);
|
|
997
|
+
if (st === 'blocked') {
|
|
998
|
+
settled = 'blocked';
|
|
999
|
+
break;
|
|
1000
|
+
}
|
|
1001
|
+
if (st === 'idle' || st === 'done') {
|
|
1002
|
+
text = await collectFinalText(paneId, cwd, injectTs);
|
|
1003
|
+
if (text) break;
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
const outcome =
|
|
1008
|
+
settled === 'blocked' ? { kind: 'blocked' as const, text: text ?? '' }
|
|
1009
|
+
: settled === 'timeout' ? { kind: 'timeout' as const, text: text ?? '' }
|
|
1010
|
+
: text ? { kind: 'completed' as const, text }
|
|
1011
|
+
: { kind: 'no-output' as const, text: '' };
|
|
1012
|
+
entry.status = 'consumed';
|
|
1013
|
+
entry.consumedAt = Date.now();
|
|
1014
|
+
persistSubs();
|
|
1015
|
+
writeHistory(entry, { outcome: outcome.kind === 'completed' ? text : null });
|
|
1016
|
+
return {
|
|
1017
|
+
content: [{ type: 'text', text: formatSubagentResult(outcome, spec.description) }],
|
|
1018
|
+
details: { paneId, taskId, background, role: kind },
|
|
1019
|
+
};
|
|
1020
|
+
} catch (err) {
|
|
1021
|
+
return {
|
|
1022
|
+
content: [{
|
|
1023
|
+
type: 'text',
|
|
1024
|
+
text: formatSubagentResult(
|
|
1025
|
+
{ kind: 'spawn-failed', text: String((err as Error)?.message ?? err) },
|
|
1026
|
+
spec.description,
|
|
1027
|
+
),
|
|
1028
|
+
}],
|
|
1029
|
+
details: {},
|
|
1030
|
+
};
|
|
1031
|
+
} finally {
|
|
1032
|
+
release();
|
|
1033
|
+
}
|
|
1034
|
+
},
|
|
1035
|
+
});
|
|
1036
|
+
|
|
1037
|
+
/* ── 工具:resume_subagent(历史复活,v1.2) ── */
|
|
1038
|
+
|
|
1039
|
+
scoped.registerTool({
|
|
1040
|
+
name: 'resume_subagent',
|
|
1041
|
+
label: 'Resume Subagent',
|
|
1042
|
+
description:
|
|
1043
|
+
'Revive a finished (collected) subagent from the delegation ledger: opens its saved conversation in a new pane (pi --session), then use send_message to give it new work. The ledger is an append-only JSONL file, one row per status change (same taskId rows = generations, latest row is current): fields taskId, description, status (running|settled|consumed|closed), outcome (closing text), paneId, sessionFile, launchCommand, createdAt. It is per-checkout at ~/.pi/agent/herdr-pi/history/<flattened-cwd>/history.jsonl (e.g. checkout F:\\repo -> --F--repo--), so each git worktree has its own volume. Use list_agents for live panes from this session; for tasks from earlier sessions or closed panes, read/grep the ledger file for the taskId, then pass it here.',
|
|
1044
|
+
parameters: Type.Object({
|
|
1045
|
+
taskId: Type.String({ description: 'The task id to revive' }),
|
|
1046
|
+
}),
|
|
1047
|
+
async execute(_tc, params, _sig, _upd, toolCtx) {
|
|
1048
|
+
if (!client.available) {
|
|
1049
|
+
return {
|
|
1050
|
+
content: [{ type: 'text', text: 'Error: requires a herdr-managed pane.' }],
|
|
1051
|
+
details: {},
|
|
1052
|
+
};
|
|
1053
|
+
}
|
|
1054
|
+
const cwd = (toolCtx as { cwd?: string }).cwd ?? process.cwd();
|
|
1055
|
+
const taskId = String(params?.taskId ?? '');
|
|
1056
|
+
const latest = latestGeneration(readHistory(histFile(cwd)), taskId);
|
|
1057
|
+
if (!latest) {
|
|
1058
|
+
return {
|
|
1059
|
+
content: [{ type: 'text', text: `Error: no history for task "${taskId}" in this workspace.` }],
|
|
1060
|
+
details: {},
|
|
1061
|
+
};
|
|
1062
|
+
}
|
|
1063
|
+
const release = await subSemaphore.acquire();
|
|
1064
|
+
try {
|
|
1065
|
+
// D94:复用已存在的同 session pane(避免双 pi 冲突)
|
|
1066
|
+
const existing = await findExistingPaneWithSession(latest.sessionFile);
|
|
1067
|
+
if (existing) {
|
|
1068
|
+
const entry: SubEntry = {
|
|
1069
|
+
taskId,
|
|
1070
|
+
kind: latest.kind,
|
|
1071
|
+
paneId: existing.paneId,
|
|
1072
|
+
tabId: existing.tabId,
|
|
1073
|
+
tabName: latest.tabName ?? tabNameForTask(latest.description),
|
|
1074
|
+
cwd,
|
|
1075
|
+
description: latest.description,
|
|
1076
|
+
background: true,
|
|
1077
|
+
status: 'running',
|
|
1078
|
+
sessionFile: latest.sessionFile,
|
|
1079
|
+
launchCommand: latest.launchCommand,
|
|
1080
|
+
createdAt: Date.now(),
|
|
1081
|
+
revivedFrom: latest.paneId,
|
|
1082
|
+
consumedAt: null,
|
|
1083
|
+
};
|
|
1084
|
+
subs.set(entry.paneId, entry);
|
|
1085
|
+
persistSubs();
|
|
1086
|
+
writeHistory(entry);
|
|
1087
|
+
return {
|
|
1088
|
+
content: [{
|
|
1089
|
+
type: 'text',
|
|
1090
|
+
text: `resumed subagent ${entry.paneId} from task ${taskId} (reused existing pane with same session; pi still running there).`,
|
|
1091
|
+
}],
|
|
1092
|
+
details: { paneId: entry.paneId, taskId },
|
|
1093
|
+
};
|
|
1094
|
+
}
|
|
1095
|
+
// 无现成 pane,才创建新的
|
|
1096
|
+
const entry: SubEntry = {
|
|
1097
|
+
taskId,
|
|
1098
|
+
kind: latest.kind,
|
|
1099
|
+
paneId: '',
|
|
1100
|
+
tabId: '',
|
|
1101
|
+
tabName: latest.tabName ?? tabNameForTask(latest.description),
|
|
1102
|
+
cwd,
|
|
1103
|
+
description: latest.description,
|
|
1104
|
+
background: true,
|
|
1105
|
+
status: 'running',
|
|
1106
|
+
sessionFile: latest.sessionFile,
|
|
1107
|
+
launchCommand: latest.launchCommand,
|
|
1108
|
+
createdAt: Date.now(),
|
|
1109
|
+
revivedFrom: latest.paneId,
|
|
1110
|
+
consumedAt: null,
|
|
1111
|
+
};
|
|
1112
|
+
await reviveEntry(entry);
|
|
1113
|
+
subs.set(entry.paneId, entry);
|
|
1114
|
+
persistSubs();
|
|
1115
|
+
return {
|
|
1116
|
+
content: [{
|
|
1117
|
+
type: 'text',
|
|
1118
|
+
text: entry.sessionFile
|
|
1119
|
+
? `resumed subagent ${entry.paneId} from task ${taskId} (session restored).`
|
|
1120
|
+
: `resumed subagent ${entry.paneId} from task ${taskId} (session file missing; fresh conversation).`,
|
|
1121
|
+
}],
|
|
1122
|
+
details: { paneId: entry.paneId, taskId },
|
|
1123
|
+
};
|
|
1124
|
+
} catch (err) {
|
|
1125
|
+
return {
|
|
1126
|
+
content: [{ type: 'text', text: `Error: failed to resume task "${taskId}": ${(err as Error).message}` }],
|
|
1127
|
+
details: {},
|
|
1128
|
+
};
|
|
1129
|
+
} finally {
|
|
1130
|
+
release();
|
|
1131
|
+
}
|
|
1132
|
+
},
|
|
1133
|
+
});
|
|
1134
|
+
|
|
1135
|
+
/* ── 工具:list_agents(仅后台/常驻子代理;前台短 pane 是瞬态 UI) ── */
|
|
1136
|
+
|
|
1137
|
+
scoped.registerTool({
|
|
1138
|
+
name: 'list_agents',
|
|
1139
|
+
label: 'List Subagents',
|
|
1140
|
+
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.',
|
|
1142
|
+
parameters: Type.Object({}),
|
|
1143
|
+
async execute() {
|
|
1144
|
+
const listed = [...subs.values()].filter((s) => s.background);
|
|
1145
|
+
if (listed.length === 0) {
|
|
1146
|
+
return { content: [{ type: 'text', text: 'No background subagents started (from this session branch).' }], details: {} };
|
|
1147
|
+
}
|
|
1148
|
+
// 按 taskId 去重:只显示每任务的最新代(复活后旧 paneId 不重复出现)
|
|
1149
|
+
const byTask = new Map<string, SubEntry>();
|
|
1150
|
+
for (const sub of listed) {
|
|
1151
|
+
const prev = byTask.get(sub.taskId);
|
|
1152
|
+
if (!prev || sub.createdAt >= prev.createdAt) byTask.set(sub.taskId, sub);
|
|
1153
|
+
}
|
|
1154
|
+
const lines: string[] = [];
|
|
1155
|
+
for (const sub of byTask.values()) {
|
|
1156
|
+
const tabTag = sub.tabName ? ` [tab: ${sub.tabName}]` : '';
|
|
1157
|
+
if (sub.status === 'closed') {
|
|
1158
|
+
lines.push(`${sub.taskId.slice(0, 8)} [idle] (${sub.kind}, closed; send_message revives)${tabTag} ${sub.description}`);
|
|
1159
|
+
continue;
|
|
1160
|
+
}
|
|
1161
|
+
// 注册表状态即权威:settled = idle,其余 = running(含 blocked 人类闸门,对齐 DSH 映射)
|
|
1162
|
+
const state = sub.status === 'settled' ? 'idle' : 'running';
|
|
1163
|
+
// D94:用户接管标记
|
|
1164
|
+
const takeoverMark = sub.userTakeover ? ', user-controlled' : '';
|
|
1165
|
+
lines.push(`${sub.paneId} [${state}${takeoverMark}] (${sub.kind})${tabTag} ${sub.description}`);
|
|
1166
|
+
}
|
|
1167
|
+
return { content: [{ type: 'text', text: lines.join('\n') }], details: {} };
|
|
1168
|
+
},
|
|
1169
|
+
});
|
|
1170
|
+
|
|
1171
|
+
/* ── 工具:send_message(followUp 队列语义) ── */
|
|
1172
|
+
|
|
1173
|
+
scoped.registerTool({
|
|
1174
|
+
name: 'send_message',
|
|
1175
|
+
label: 'Send to Subagent',
|
|
1176
|
+
description:
|
|
1177
|
+
'Send a follow-up message to a background subagent. If it is working, the message is queued and delivered after it finishes (pi followUp queue); 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.',
|
|
1178
|
+
parameters: Type.Object({
|
|
1179
|
+
agentId: Type.String({ description: 'The subagent id (herdr pane id)' }),
|
|
1180
|
+
message: Type.String({ description: 'The follow-up message' }),
|
|
1181
|
+
}),
|
|
1182
|
+
async execute(_tc, params) {
|
|
1183
|
+
const entry = subs.get(String(params?.agentId ?? ''));
|
|
1184
|
+
if (!entry) {
|
|
1185
|
+
return { content: [{ type: 'text', text: `Error: unknown subagent id "${params?.agentId}" (see list_agents)` }], details: {} };
|
|
1186
|
+
}
|
|
1187
|
+
const spawnedAt = Date.now();
|
|
1188
|
+
try {
|
|
1189
|
+
// 已关闭 → 自动复活(任务级可继续:pane 是临时宿主)
|
|
1190
|
+
if (entry.status === 'closed') {
|
|
1191
|
+
await reviveEntry(entry);
|
|
1192
|
+
subs.set(entry.paneId, entry);
|
|
1193
|
+
persistSubs();
|
|
1194
|
+
}
|
|
1195
|
+
// M11(D46):follow_up 走扩展管道(sendUserMessage(followUp) 队列语义)
|
|
1196
|
+
const ready = await waitSubReady(entry.cwd, entry.paneId);
|
|
1197
|
+
if (!ready) throw new Error(`subagent pane ${entry.paneId} pipe not ready`);
|
|
1198
|
+
const fuId = `fu-${Date.now()}`;
|
|
1199
|
+
const res = await pipeRequest(pipeNameFor(entry.cwd, entry.paneId), {
|
|
1200
|
+
type: 'follow_up',
|
|
1201
|
+
id: fuId,
|
|
1202
|
+
text: String(params?.message ?? ''),
|
|
1203
|
+
from: pipeNameFor(entry.cwd, env?.paneId ?? ''),
|
|
1204
|
+
push: true,
|
|
1205
|
+
});
|
|
1206
|
+
if (res.type !== 'ok') {
|
|
1207
|
+
throw new Error(`pipe follow_up rejected: ${res.type === 'error' ? res.message : 'unknown response'}`);
|
|
1208
|
+
}
|
|
1209
|
+
entry.status = 'running';
|
|
1210
|
+
persistSubs();
|
|
1211
|
+
lastRequestIdByPane.set(entry.paneId, fuId);
|
|
1212
|
+
startPoller(entry.paneId, entry.cwd, spawnedAt, Date.now(), entry.description, fuId);
|
|
1213
|
+
return { content: [{ type: 'text', text: `Message sent to subagent ${entry.paneId}.` }], details: { paneId: entry.paneId } };
|
|
1214
|
+
} catch (err) {
|
|
1215
|
+
return { content: [{ type: 'text', text: `Error: failed to reach subagent ${entry.paneId}: ${(err as Error).message}` }], details: {} };
|
|
1216
|
+
}
|
|
1217
|
+
},
|
|
1218
|
+
});
|
|
1219
|
+
|
|
1220
|
+
/* ── 工具:interrupt_agent(D48:管道 → 子扩展 ctx.abort() 进程内中止) ── */
|
|
1221
|
+
|
|
1222
|
+
scoped.registerTool({
|
|
1223
|
+
name: 'interrupt_agent',
|
|
1224
|
+
label: 'Interrupt Subagent',
|
|
1225
|
+
description:
|
|
1226
|
+
'Interrupt a background subagent (stops its current turn; fire-and-return). The subagent stays alive and can receive further send_message work.',
|
|
1227
|
+
parameters: Type.Object({
|
|
1228
|
+
agentId: Type.String({ description: 'The subagent id (herdr pane id)' }),
|
|
1229
|
+
}),
|
|
1230
|
+
async execute(_tc, params) {
|
|
1231
|
+
const entry = subs.get(String(params?.agentId ?? ''));
|
|
1232
|
+
if (!entry) {
|
|
1233
|
+
return { content: [{ type: 'text', text: `Error: unknown subagent id "${params?.agentId}" (see list_agents)` }], details: {} };
|
|
1234
|
+
}
|
|
1235
|
+
if (entry.status === 'closed') {
|
|
1236
|
+
// DSH 对齐:空闲/已结束目标是幂等 no-op
|
|
1237
|
+
return { content: [{ type: 'text', text: `Interrupt accepted for subagent ${entry.paneId} (already idle/closed; no-op).` }], details: {} };
|
|
1238
|
+
}
|
|
1239
|
+
try {
|
|
1240
|
+
const res = await pipeRequest(pipeNameFor(entry.cwd, entry.paneId), {
|
|
1241
|
+
type: 'interrupt',
|
|
1242
|
+
id: `int-${Date.now()}`,
|
|
1243
|
+
});
|
|
1244
|
+
if (res.type !== 'ok') {
|
|
1245
|
+
throw new Error(`pipe interrupt rejected: ${res.type === 'error' ? res.message : 'unknown response'}`);
|
|
1246
|
+
}
|
|
1247
|
+
// 被中断的轮次不结算通知(请求方已知情)
|
|
1248
|
+
const lastId = lastRequestIdByPane.get(entry.paneId);
|
|
1249
|
+
if (lastId) d.claimSettleNotice(`${entry.paneId}:${lastId}`);
|
|
1250
|
+
return { content: [{ type: 'text', text: `Interrupt accepted for subagent ${entry.paneId} (fire-and-return).` }], details: { paneId: entry.paneId } };
|
|
1251
|
+
} catch (err) {
|
|
1252
|
+
return { content: [{ type: 'text', text: `Error: failed to reach subagent ${entry.paneId}: ${(err as Error).message}` }], details: {} };
|
|
1253
|
+
}
|
|
1254
|
+
},
|
|
1255
|
+
});
|
|
1256
|
+
}
|