@buyi1net/pi-toolkit 0.0.1
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/LICENSE +21 -0
- package/README.md +45 -0
- package/assembler.ts +127 -0
- package/config.ts +201 -0
- package/i18n.ts +693 -0
- package/index.ts +99 -0
- package/menu/items.ts +264 -0
- package/menu/panels.ts +91 -0
- package/menu/settings-list.ts +84 -0
- package/menu/theme.ts +31 -0
- package/menu/toolkit-menu.ts +139 -0
- package/module.ts +156 -0
- package/modules/eyes/chain.ts +173 -0
- package/modules/eyes/config.ts +319 -0
- package/modules/eyes/index.ts +161 -0
- package/modules/eyes/menu.ts +828 -0
- package/modules/eyes/pi-model-backend.ts +245 -0
- package/modules/eyes/resilience.ts +161 -0
- package/modules/eyes/vision-bridge.ts +232 -0
- package/modules/eyes/vision-cache.ts +86 -0
- package/modules/eyes/vision-json.ts +19 -0
- package/modules/eyes/vision-preprocess.ts +192 -0
- package/modules/eyes/vision-probe.ts +21 -0
- package/modules/eyes/vision-prompt.ts +90 -0
- package/modules/eyes/vision-tool.ts +59 -0
- package/modules/eyes/vision-types.ts +33 -0
- package/modules/index.ts +10 -0
- package/modules/subagents/agents/researcher.md +51 -0
- package/modules/subagents/agents/scout.md +40 -0
- package/modules/subagents/agents/worker.md +79 -0
- package/modules/subagents/config.json.example +6 -0
- package/modules/subagents/config.ts +144 -0
- package/modules/subagents/index.ts +91 -0
- package/modules/subagents/menu.ts +388 -0
- package/modules/subagents/src/activity.ts +511 -0
- package/modules/subagents/src/agents.ts +126 -0
- package/modules/subagents/src/command.ts +37 -0
- package/modules/subagents/src/dependencies.ts +246 -0
- package/modules/subagents/src/diagnostics.ts +13 -0
- package/modules/subagents/src/display.ts +94 -0
- package/modules/subagents/src/headless.ts +342 -0
- package/modules/subagents/src/herdr.ts +203 -0
- package/modules/subagents/src/index.ts +2798 -0
- package/modules/subagents/src/inspect-tool.ts +839 -0
- package/modules/subagents/src/launch-config.ts +196 -0
- package/modules/subagents/src/layout-budget.ts +26 -0
- package/modules/subagents/src/list-tool.ts +292 -0
- package/modules/subagents/src/message-tool.ts +808 -0
- package/modules/subagents/src/names.ts +17 -0
- package/modules/subagents/src/pane-layout.ts +49 -0
- package/modules/subagents/src/params.ts +145 -0
- package/modules/subagents/src/renderers.ts +181 -0
- package/modules/subagents/src/result.ts +43 -0
- package/modules/subagents/src/retention.ts +114 -0
- package/modules/subagents/src/route-error.ts +212 -0
- package/modules/subagents/src/routing.ts +235 -0
- package/modules/subagents/src/runtime-registry.ts +159 -0
- package/modules/subagents/src/session.ts +801 -0
- package/modules/subagents/src/status.ts +513 -0
- package/modules/subagents/src/stop-tool.ts +235 -0
- package/modules/subagents/src/subagent-done.ts +590 -0
- package/modules/subagents/src/subagent-tool.ts +1155 -0
- package/modules/subagents/src/surface.ts +355 -0
- package/modules/subagents/src/team-dispatch-tool.ts +219 -0
- package/modules/subagents/src/team.ts +232 -0
- package/modules/subagents/src/tmux.ts +210 -0
- package/modules/subagents/src/tools/safe-bash.ts +72 -0
- package/modules/subagents/src/types.ts +131 -0
- package/modules/tui/adapter/provider-usage.ts +143 -0
- package/modules/tui/config.ts +50 -0
- package/modules/tui/index.ts +120 -0
- package/modules/tui/kernel/pkg/shared/grok-subscription.ts +169 -0
- package/modules/tui/kernel/pkg/shared/official-subscription.ts +237 -0
- package/modules/tui/kernel/pkg/shared/provider-catalog.ts +367 -0
- package/modules/tui/kernel/pkg/shared/provider-contracts.ts +150 -0
- package/modules/tui/kernel/pkg/shared/provider-display.ts +55 -0
- package/modules/tui/kernel/pkg/shared/provider-parsers.ts +171 -0
- package/modules/tui/kernel/pkg/shared/volcengine.ts +191 -0
- package/modules/tui/kernel/pkg/shared/zhipu.ts +149 -0
- package/modules/tui/kernel/pkg/usage-core/index.ts +335 -0
- package/modules/tui/kernel/pkg/usage-core/provider-routes.ts +187 -0
- package/modules/tui/kernel/pkg/usage-node/index.ts +635 -0
- package/modules/tui/kernel/pkg/usage-node/provider-usage.ts +388 -0
- package/modules/tui/kernel/usage-core.ts +2 -0
- package/modules/tui/kernel/usage-node.ts +2 -0
- package/modules/tui/menu.ts +418 -0
- package/modules/tui/plugin/editor.ts +396 -0
- package/modules/tui/plugin/footer.ts +161 -0
- package/modules/tui/plugin/index.ts +30 -0
- package/modules/tui/plugin/lifecycle.ts +637 -0
- package/modules/tui/plugin/package-order.ts +169 -0
- package/modules/tui/plugin/screen-transition.ts +203 -0
- package/modules/tui/plugin/settings-config.ts +297 -0
- package/modules/tui/plugin/status-sources.ts +49 -0
- package/modules/tui/plugin/transition-gate.ts +261 -0
- package/modules/tui/renderer/custom-header.ts +157 -0
- package/modules/tui/renderer/editor.ts +70 -0
- package/modules/tui/renderer/header.ts +72 -0
- package/modules/tui/renderer/icons.ts +149 -0
- package/modules/tui/renderer/pi-installer-logo.ts +194 -0
- package/modules/tui/status/auto-compaction.ts +67 -0
- package/modules/tui/status/project-status.ts +655 -0
- package/modules/tui/status/provider-status.ts +120 -0
- package/modules/tui/status/runtime-status.ts +307 -0
- package/modules/tui/status/session-status.ts +325 -0
- package/modules/tui/status/status-config.ts +95 -0
- package/modules/tui/status/status-segments.ts +263 -0
- package/modules/tui/status/turn-telemetry.ts +466 -0
- package/modules/tui/themes/LICENSE.pi-themes-bundle +21 -0
- package/modules/tui/themes/UPSTREAM.md +7 -0
- package/modules/tui/themes/catppuccin-latte.json +80 -0
- package/modules/tui/themes/catppuccin-mocha.json +79 -0
- package/modules/tui/themes/crimson-noir.json +85 -0
- package/modules/tui/themes/dracula.json +79 -0
- package/modules/tui/themes/everforest-dark.json +85 -0
- package/modules/tui/themes/gruvbox-dark.json +85 -0
- package/modules/tui/themes/gruvbox-light.json +85 -0
- package/modules/tui/themes/matrix.json +85 -0
- package/modules/tui/themes/nord.json +85 -0
- package/modules/tui/themes/one-dark.json +85 -0
- package/modules/tui/themes/rose-pine-dawn.json +85 -0
- package/modules/tui/themes/rose-pine.json +85 -0
- package/modules/tui/themes/solarized-dark.json +85 -0
- package/modules/tui/themes/solarized-light.json +85 -0
- package/modules/tui/themes/tokyo-night-storm.json +79 -0
- package/modules/tui/themes/tokyo-night.json +79 -0
- package/package.json +28 -0
- package/services.ts +38 -0
- package/toolkit.ts +147 -0
|
@@ -0,0 +1,2798 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { dirname, join, resolve, sep } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import {
|
|
5
|
+
readFileSync,
|
|
6
|
+
writeFileSync,
|
|
7
|
+
existsSync,
|
|
8
|
+
mkdirSync,
|
|
9
|
+
renameSync,
|
|
10
|
+
statSync,
|
|
11
|
+
unlinkSync,
|
|
12
|
+
} from "node:fs";
|
|
13
|
+
import { homedir } from "node:os";
|
|
14
|
+
import { randomUUID } from "node:crypto";
|
|
15
|
+
import {
|
|
16
|
+
isMuxAvailable,
|
|
17
|
+
muxSetupHint,
|
|
18
|
+
createSurface,
|
|
19
|
+
detectSurface,
|
|
20
|
+
sendCommand,
|
|
21
|
+
pollForExit,
|
|
22
|
+
closeSurface,
|
|
23
|
+
shellEscape,
|
|
24
|
+
sentinelSuffix,
|
|
25
|
+
adoptSurface,
|
|
26
|
+
probeSurface,
|
|
27
|
+
readExitSidecar,
|
|
28
|
+
type PollResult,
|
|
29
|
+
} from "./surface.ts";
|
|
30
|
+
import {
|
|
31
|
+
formatHeadlessSurface,
|
|
32
|
+
isPidAlive,
|
|
33
|
+
parseHeadlessSurface,
|
|
34
|
+
spawnHeadlessPi,
|
|
35
|
+
createSubagentLaunchEnv,
|
|
36
|
+
terminateHeadlessProcess,
|
|
37
|
+
type HeadlessChild,
|
|
38
|
+
} from "./headless.ts";
|
|
39
|
+
|
|
40
|
+
import {
|
|
41
|
+
countSessionEntryLines,
|
|
42
|
+
getSessionId,
|
|
43
|
+
getNewEntries,
|
|
44
|
+
readNameRegistry,
|
|
45
|
+
readSubagentLoadout,
|
|
46
|
+
registerName,
|
|
47
|
+
resolveNameInRegistry,
|
|
48
|
+
seedSubagentSessionFile,
|
|
49
|
+
writeSubagentLoadout,
|
|
50
|
+
writeAnchoredLoadout,
|
|
51
|
+
readAnchoredLoadout,
|
|
52
|
+
diffSubagentLoadouts,
|
|
53
|
+
SUBAGENT_LOADOUT_VERSION,
|
|
54
|
+
type AnchoredSubagentLoadout,
|
|
55
|
+
type MessageEntry,
|
|
56
|
+
type SubagentLoadout,
|
|
57
|
+
} from "./session.ts";
|
|
58
|
+
import {
|
|
59
|
+
type StatusSnapshot,
|
|
60
|
+
advanceStatusState,
|
|
61
|
+
capStatusLines,
|
|
62
|
+
classifyStatus,
|
|
63
|
+
createStatusState,
|
|
64
|
+
forceStatusAfterInterrupt,
|
|
65
|
+
formatStatusAggregate,
|
|
66
|
+
formatTransitionLine,
|
|
67
|
+
observeStatus,
|
|
68
|
+
loadStatusConfig,
|
|
69
|
+
} from "./status.ts";
|
|
70
|
+
import {
|
|
71
|
+
getSubagentActivityFile,
|
|
72
|
+
readSubagentActivityFile,
|
|
73
|
+
type ActivityReadResult,
|
|
74
|
+
type SubagentActivityState,
|
|
75
|
+
} from "./activity.ts";
|
|
76
|
+
import {
|
|
77
|
+
discoverAgentDefinitions as discoverAgents,
|
|
78
|
+
loadAgentDefaults as loadAgentDefaultsFrom,
|
|
79
|
+
type AgentDefaults,
|
|
80
|
+
} from "./agents.ts";
|
|
81
|
+
import { normalizeSubagentName, sanitizeSubagentFileName } from "./names.ts";
|
|
82
|
+
import { loadTierRouteConfig, resolveTierForParams, normalizeTier, type TierRouteInjectedSource } from "./routing.ts";
|
|
83
|
+
import { routeExceptionFromResult } from "./route-error.ts";
|
|
84
|
+
import { settleCompletionFromResult } from "./dependencies.ts";
|
|
85
|
+
import { extractSubagentResult } from "./result.ts";
|
|
86
|
+
import { registerSubagentRenderers } from "./renderers.ts";
|
|
87
|
+
import { registerSubagentsListTool } from "./list-tool.ts";
|
|
88
|
+
import { registerSubagentInspectTool } from "./inspect-tool.ts";
|
|
89
|
+
import { registerSubagentCommand } from "./command.ts";
|
|
90
|
+
import { registerSubagentTool } from "./subagent-tool.ts";
|
|
91
|
+
import { registerSubagentMessageTool } from "./message-tool.ts";
|
|
92
|
+
import { registerSubagentStopTool } from "./stop-tool.ts";
|
|
93
|
+
import { registerTeamDispatchTool } from "./team-dispatch-tool.ts";
|
|
94
|
+
import { claimRoundSignal, claimTeamMessages, buildTeamRoundPrompt, findRosterMember, rosterPath, teamRoundMarker, upsertRosterMember } from "./team.ts";
|
|
95
|
+
import { normalizeCohortId, SubagentParams, validateCohortId } from "./params.ts";
|
|
96
|
+
import type { RunningSubagent, SubagentResult } from "./types.ts";
|
|
97
|
+
import {
|
|
98
|
+
buildPiPromptArgs,
|
|
99
|
+
buildSubagentToolAllowlist,
|
|
100
|
+
getDefaultSessionDirFor,
|
|
101
|
+
resolveEffectiveAutoExit,
|
|
102
|
+
resolveEffectiveInteractive,
|
|
103
|
+
resolveEffectiveSessionMode,
|
|
104
|
+
resolveLaunchBehavior,
|
|
105
|
+
resolveSubagentPaths,
|
|
106
|
+
resolveSurfaceChoice,
|
|
107
|
+
validateAgentLifecycleConfig,
|
|
108
|
+
SPAWNING_TOOLS,
|
|
109
|
+
} from "./launch-config.ts";
|
|
110
|
+
import {
|
|
111
|
+
borderBottom,
|
|
112
|
+
borderLine,
|
|
113
|
+
borderTop,
|
|
114
|
+
contextWindowFor,
|
|
115
|
+
formatContextUsage,
|
|
116
|
+
formatElapsed,
|
|
117
|
+
formatTokens,
|
|
118
|
+
formatUsageSegments,
|
|
119
|
+
widgetIcon,
|
|
120
|
+
} from "./display.ts";
|
|
121
|
+
import { debugLog } from "./diagnostics.ts";
|
|
122
|
+
import {
|
|
123
|
+
createRuntimeRecord,
|
|
124
|
+
readRuntimeRecords,
|
|
125
|
+
removeRuntimeRecord,
|
|
126
|
+
runtimeRegistryPath,
|
|
127
|
+
upsertRuntimeRecord,
|
|
128
|
+
} from "./runtime-registry.ts";
|
|
129
|
+
|
|
130
|
+
/** Absolute path to the source directory. https://github.com/nodejs/node/issues/37845 */
|
|
131
|
+
const SUBAGENTS_DIR = dirname(fileURLToPath(import.meta.url));
|
|
132
|
+
|
|
133
|
+
// Survive /reload: clear timers and abort poll loops from the previous module load.
|
|
134
|
+
// /reload re-imports this file, giving fresh module-level state, but closures from
|
|
135
|
+
// the old module keep running. See https://github.com/HazAT/pi-interactive-subagents/issues/5
|
|
136
|
+
const WIDGET_INTERVAL_KEY = Symbol.for("pi-subagents/widget-interval");
|
|
137
|
+
const STATUS_INTERVAL_KEY = Symbol.for("pi-subagents/status-interval");
|
|
138
|
+
const POLL_ABORT_KEY = Symbol.for("pi-subagents/poll-abort-controller");
|
|
139
|
+
const MODULE_INSTANCE_KEY = Symbol.for("pi-subagents/module-instance");
|
|
140
|
+
const moduleInstanceId = randomUUID();
|
|
141
|
+
(globalThis as any)[MODULE_INSTANCE_KEY] = moduleInstanceId;
|
|
142
|
+
|
|
143
|
+
{
|
|
144
|
+
const prevInterval = (globalThis as any)[WIDGET_INTERVAL_KEY];
|
|
145
|
+
if (prevInterval) {
|
|
146
|
+
clearInterval(prevInterval);
|
|
147
|
+
(globalThis as any)[WIDGET_INTERVAL_KEY] = null;
|
|
148
|
+
}
|
|
149
|
+
const prevStatusInterval = (globalThis as any)[STATUS_INTERVAL_KEY];
|
|
150
|
+
if (prevStatusInterval) {
|
|
151
|
+
clearInterval(prevStatusInterval);
|
|
152
|
+
(globalThis as any)[STATUS_INTERVAL_KEY] = null;
|
|
153
|
+
}
|
|
154
|
+
const prevAbort = (globalThis as any)[POLL_ABORT_KEY] as AbortController | undefined;
|
|
155
|
+
if (prevAbort) prevAbort.abort();
|
|
156
|
+
(globalThis as any)[POLL_ABORT_KEY] = new AbortController();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function getModuleAbortSignal(): AbortSignal {
|
|
160
|
+
return ((globalThis as any)[POLL_ABORT_KEY] as AbortController).signal;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ── 宿主(pi-toolkit)配置注入 ──────────────────────────────────────────
|
|
164
|
+
// 本文件既作为 pi-toolkit 的子代理模块被装配,也作为子进程 `-e` 的独立扩展
|
|
165
|
+
// 被直接加载。独立加载时没有宿主,配置完全按原 pi-subagents 的读取链走;
|
|
166
|
+
// 被 pi-toolkit 装配时由宿主注入 `subagents` 节(pi-toolkit.json 的
|
|
167
|
+
// `modules.subagents`),用于 tier 路由与 status 开关的取值覆盖。
|
|
168
|
+
|
|
169
|
+
/** 宿主注入的 subagents 节:`source` 仅用于错误提示定位 */
|
|
170
|
+
export interface SubagentsHostSection {
|
|
171
|
+
readonly source: string;
|
|
172
|
+
readonly section: Record<string, unknown>;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export interface SubagentsExtensionOptions {
|
|
176
|
+
/** 注册旧 `/subagent` 命令;合并后产品不再注册(决策 5),默认关闭 */
|
|
177
|
+
registerCommand?: boolean;
|
|
178
|
+
/** 读 pi-toolkit.json 的 subagents 节(同步、实时);未装配宿主时省略 */
|
|
179
|
+
readHostSection?: () => SubagentsHostSection | null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
let hostOptions: SubagentsExtensionOptions = {};
|
|
183
|
+
|
|
184
|
+
/** 设置宿主注入;幂等,重复调用以最后一次为准(/reload 后重新装配) */
|
|
185
|
+
export function configureSubagentsExtension(options: SubagentsExtensionOptions): void {
|
|
186
|
+
hostOptions = options;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** 读宿主 subagents 节;读取失败或不合法一律当作“没配置”,不拖垮启动 */
|
|
190
|
+
function hostSection(): SubagentsHostSection | null {
|
|
191
|
+
try {
|
|
192
|
+
return hostOptions.readHostSection?.() ?? null;
|
|
193
|
+
} catch {
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
199
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* 宿主 tier 层的注入候选。只有宿主节真的声明了 `models` 对象时才算一个候选层:
|
|
204
|
+
* 否则(用户没在菜单里配过 tier)就让链继续落到包内 config.json / example 兜底。
|
|
205
|
+
*/
|
|
206
|
+
export function resolveHostTierSources(): TierRouteInjectedSource[] {
|
|
207
|
+
const host = hostSection();
|
|
208
|
+
if (!host) return [];
|
|
209
|
+
const models = host.section.models;
|
|
210
|
+
if (!isPlainObject(models) || Object.keys(models).length === 0) return [];
|
|
211
|
+
return [{ source: host.source, raw: { models } }];
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const THINKING_LEVELS = new Set(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* resume 路径的信任校验:registry 与 .loadout.json 都是磁盘文件,可能被篡改
|
|
218
|
+
* (子代理进程可写自己 session 目录下的 sidecar)。恢复前强制验证:
|
|
219
|
+
* 1. loadout 字段合法(枚举值/类型),agentDir 存在且为目录;
|
|
220
|
+
* 2. session containment 只锚定父侧可信根,绝不由 loadout.agentDir 自己
|
|
221
|
+
* 决定:新快照(有锚定副本)要求 session 路径与锚定副本记录的
|
|
222
|
+
* sessionFile 精确匹配;旧快照(无锚定副本,兼容路径)固定用宿主
|
|
223
|
+
* agentDir(getAgentConfigDir,宿主环境可信)的 sessions 根,外加按
|
|
224
|
+
* spawn 时的推导规则从 loadout.cwd 重新推导出的项目本地
|
|
225
|
+
* .pi/agent sessions 根——除此之外的 agentDir 不再作为 containment 根;
|
|
226
|
+
* 3. 锚定副本存在时,session sidecar 必须与它逐字段一致(只收窄不放宽:
|
|
227
|
+
* 任何安全字段被改动都拒绝 resume,而不是静默接受更宽的授权)。
|
|
228
|
+
* 诚实边界:同用户恶意进程可同时改写两份副本,这里不做绝对隔离承诺;
|
|
229
|
+
* 目标是堵死“只改 session sidecar 即可扩权”的路径,并让篡改可检测。
|
|
230
|
+
*/
|
|
231
|
+
function validateResumeTarget(
|
|
232
|
+
sessionPath: string,
|
|
233
|
+
loadout: SubagentLoadout,
|
|
234
|
+
agentDir: string,
|
|
235
|
+
opts?: { anchored?: AnchoredSubagentLoadout | null; requireAnchored?: boolean },
|
|
236
|
+
): string | null {
|
|
237
|
+
const anchored = opts?.anchored ?? null;
|
|
238
|
+
const snapshotVersion = loadout.snapshotVersion;
|
|
239
|
+
if (
|
|
240
|
+
snapshotVersion !== undefined &&
|
|
241
|
+
(typeof snapshotVersion !== "number" ||
|
|
242
|
+
!Number.isInteger(snapshotVersion) ||
|
|
243
|
+
snapshotVersion !== SUBAGENT_LOADOUT_VERSION)
|
|
244
|
+
) {
|
|
245
|
+
return `unsupported loadout snapshot version: ${String(snapshotVersion)}`;
|
|
246
|
+
}
|
|
247
|
+
// 新版快照自身携带强制锚定标记;即使 registry 的 anchored 字段被删除,
|
|
248
|
+
// 也不能把新快照静默降级为 legacy 校验。
|
|
249
|
+
const requiresAnchored = opts?.requireAnchored === true || snapshotVersion === SUBAGENT_LOADOUT_VERSION;
|
|
250
|
+
if (requiresAnchored && !anchored) {
|
|
251
|
+
return "parent-anchored loadout snapshot is missing or unreadable; refusing to fall back to legacy resume validation";
|
|
252
|
+
}
|
|
253
|
+
const resolvedSession = resolve(sessionPath);
|
|
254
|
+
// 先校验会参与路径推导的字段,避免恶意快照用非字符串触发 resolve
|
|
255
|
+
// 异常并绕过后续的结构化拒绝。
|
|
256
|
+
if (loadout.agentDir !== undefined && loadout.agentDir !== null && typeof loadout.agentDir !== "string") {
|
|
257
|
+
return "loadout agentDir must be a string or null";
|
|
258
|
+
}
|
|
259
|
+
if (loadout.cwd !== undefined && loadout.cwd !== null && typeof loadout.cwd !== "string") {
|
|
260
|
+
return "loadout cwd must be a string or null";
|
|
261
|
+
}
|
|
262
|
+
if (anchored) {
|
|
263
|
+
// 锚定副本精确匹配:父进程自己登记过这个路径,containment 即成立。
|
|
264
|
+
if (resolve(anchored.sessionFile) !== resolvedSession) {
|
|
265
|
+
return `session path does not match the parent-anchored snapshot: ${sessionPath} (anchored: ${anchored.sessionFile})`;
|
|
266
|
+
}
|
|
267
|
+
} else {
|
|
268
|
+
// 旧快照兼容:containment 根固定为宿主 agentDir 的 sessions 根;项目
|
|
269
|
+
// 本地 .pi/agent 只有在能从 loadout.cwd 按 spawn 推导规则复算出同一
|
|
270
|
+
// agentDir 时才可作为根(localAgentDir = <cwd>/.pi/agent,存在才生效)。
|
|
271
|
+
// loadout.agentDir 本身不再单独决定 containment 根,只作被校验后的
|
|
272
|
+
// 运行配置。
|
|
273
|
+
const trustedRoots = [resolve(join(agentDir, "sessions"))];
|
|
274
|
+
if (loadout.cwd && loadout.agentDir) {
|
|
275
|
+
const derivedLocal = join(resolve(loadout.cwd), ".pi", "agent");
|
|
276
|
+
if (resolve(loadout.agentDir) === derivedLocal) {
|
|
277
|
+
trustedRoots.push(resolve(join(derivedLocal, "sessions")));
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
if (!trustedRoots.some((root) => resolvedSession.startsWith(root + sep))) {
|
|
281
|
+
return `session file escapes the sessions directory: ${sessionPath}`;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
if (!resolvedSession.endsWith(".jsonl") || !existsSync(resolvedSession)) {
|
|
285
|
+
return `session file missing or not a .jsonl session: ${sessionPath}`;
|
|
286
|
+
}
|
|
287
|
+
// 锚定副本交叉校验优先于字段存在性检查:篡改场景应报告“被改动”而不是
|
|
288
|
+
// 改动后的字段自身的次要错误。
|
|
289
|
+
if (anchored) {
|
|
290
|
+
const diff = diffSubagentLoadouts(anchored, loadout);
|
|
291
|
+
if (diff.length > 0) {
|
|
292
|
+
return (
|
|
293
|
+
`loadout snapshot was modified after spawn (fields: ${diff.join(", ")}); ` +
|
|
294
|
+
`refusing to resume with an altered authorization. ` +
|
|
295
|
+
`Re-run the task as a fresh subagent instead.`
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
if (loadout.agentDir) {
|
|
300
|
+
const ad = resolve(loadout.agentDir);
|
|
301
|
+
try {
|
|
302
|
+
if (!statSync(ad).isDirectory()) return `loadout agentDir is not a directory: ${loadout.agentDir}`;
|
|
303
|
+
} catch {
|
|
304
|
+
return `loadout agentDir does not exist: ${loadout.agentDir}`;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
if (loadout.thinking != null && !THINKING_LEVELS.has(loadout.thinking)) {
|
|
308
|
+
return `loadout thinking level invalid: ${loadout.thinking}`;
|
|
309
|
+
}
|
|
310
|
+
if (loadout.thinkingOverride != null && !THINKING_LEVELS.has(loadout.thinkingOverride)) {
|
|
311
|
+
return `loadout thinkingOverride invalid: ${loadout.thinkingOverride}`;
|
|
312
|
+
}
|
|
313
|
+
if (
|
|
314
|
+
loadout.systemPromptMode != null &&
|
|
315
|
+
loadout.systemPromptMode !== "append" &&
|
|
316
|
+
loadout.systemPromptMode !== "replace"
|
|
317
|
+
) {
|
|
318
|
+
return `loadout systemPromptMode invalid: ${loadout.systemPromptMode}`;
|
|
319
|
+
}
|
|
320
|
+
if (loadout.toolAllowlist !== undefined && loadout.toolAllowlist !== null && typeof loadout.toolAllowlist !== "string") {
|
|
321
|
+
return "loadout toolAllowlist must be a string or null";
|
|
322
|
+
}
|
|
323
|
+
if (loadout.model !== undefined && loadout.model !== null && typeof loadout.model !== "string") {
|
|
324
|
+
return "loadout model must be a string or null";
|
|
325
|
+
}
|
|
326
|
+
if (loadout.tier != null && (typeof loadout.tier !== "string" || normalizeTier(loadout.tier) === null)) {
|
|
327
|
+
return `loadout tier invalid: ${loadout.tier}`;
|
|
328
|
+
}
|
|
329
|
+
if (loadout.cohortId != null) {
|
|
330
|
+
const cohortError = validateCohortId(loadout.cohortId);
|
|
331
|
+
if (cohortError) return `loadout cohortId invalid: ${cohortError}`;
|
|
332
|
+
}
|
|
333
|
+
if (loadout.agent !== undefined && loadout.agent !== null && typeof loadout.agent !== "string") {
|
|
334
|
+
return "loadout agent must be a string or null";
|
|
335
|
+
}
|
|
336
|
+
if (loadout.identity !== undefined && loadout.identity !== null && typeof loadout.identity !== "string") {
|
|
337
|
+
return "loadout identity must be a string or null";
|
|
338
|
+
}
|
|
339
|
+
if (loadout.spawnable != null && (!Array.isArray(loadout.spawnable) || loadout.spawnable.some((item) => typeof item !== "string"))) {
|
|
340
|
+
return "loadout spawnable must be an array of agent names or null";
|
|
341
|
+
}
|
|
342
|
+
if (loadout.autoExit !== undefined && typeof loadout.autoExit !== "boolean") {
|
|
343
|
+
return "loadout autoExit must be a boolean";
|
|
344
|
+
}
|
|
345
|
+
return null;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** Built-in tools pi provides natively — no extension needs to be loaded. */
|
|
349
|
+
const BUILTIN_TOOLS = new Set(["read", "write", "edit", "bash", "grep", "find", "ls"]);
|
|
350
|
+
|
|
351
|
+
/** Resolve the global agent config directory, respecting PI_CODING_AGENT_DIR. */
|
|
352
|
+
export function getAgentConfigDir(): string {
|
|
353
|
+
return process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// ── Runtime tool-extension registration ─────────────────────────────────────
|
|
357
|
+
// `getToolExtensionPath` otherwise only knows a closed set of tool names. Other
|
|
358
|
+
// pi extensions that bundle a tool for subagents (e.g. a project-local
|
|
359
|
+
// extension exposing a bespoke tool) register its name → extension-file path
|
|
360
|
+
// here at load/session_start time so a child process can receive an explicit
|
|
361
|
+
// `-e <path>` even when that extension is outside the child's discovery root.
|
|
362
|
+
// Mirrors the legacy `subagents` extension's `registerToolExtension` hook.
|
|
363
|
+
const EXTRA_TOOL_EXTENSIONS = new Map<string, string>();
|
|
364
|
+
|
|
365
|
+
/** Register (or re-register) a custom tool's backing extension file. */
|
|
366
|
+
export function registerToolExtension(name: string, extensionPath: string): void {
|
|
367
|
+
if (BUILTIN_TOOLS.has(name)) {
|
|
368
|
+
throw new Error(`Cannot register custom tool "${name}": shadows a built-in pi tool`);
|
|
369
|
+
}
|
|
370
|
+
if ((SPAWNING_TOOLS as readonly string[]).includes(name)) {
|
|
371
|
+
throw new Error(`Cannot register custom tool "${name}": shadows a spawning tool`);
|
|
372
|
+
}
|
|
373
|
+
const existing = EXTRA_TOOL_EXTENSIONS.get(name);
|
|
374
|
+
if (existing === extensionPath) return; // idempotent / reload-safe
|
|
375
|
+
if (existing !== undefined) {
|
|
376
|
+
throw new Error(
|
|
377
|
+
`Tool extension already registered for "${name}": ${existing} (refusing to overwrite with ${extensionPath})`,
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
EXTRA_TOOL_EXTENSIONS.set(name, extensionPath);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Expose registration on a process-global so project-local extensions loaded
|
|
384
|
+
// via jiti (separate module instances) can reach this shared map. Set at module
|
|
385
|
+
// load so it's available before any `session_start` listener runs.
|
|
386
|
+
(globalThis as any).__pi_interactive_subagents = {
|
|
387
|
+
registerToolExtension,
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Map a custom (non-built-in) tool name to the pi-extension file that
|
|
392
|
+
* registers it. Explicitly passing a backing extension keeps parent-registered
|
|
393
|
+
* custom tools available even when the child has a different discovery root.
|
|
394
|
+
* Returns undefined for built-in tools and for unknown names.
|
|
395
|
+
*/
|
|
396
|
+
function getToolExtensionPath(tool: string): string | undefined {
|
|
397
|
+
if (BUILTIN_TOOLS.has(tool)) return undefined;
|
|
398
|
+
// The four spawning tools are registered by THIS extension.
|
|
399
|
+
if ((SPAWNING_TOOLS as readonly string[]).includes(tool)) {
|
|
400
|
+
return fileURLToPath(import.meta.url);
|
|
401
|
+
}
|
|
402
|
+
const extBase = join(getAgentConfigDir(), "extensions");
|
|
403
|
+
const map: Record<string, string> = {
|
|
404
|
+
web_search: join(extBase, "web-search", "index.ts"),
|
|
405
|
+
web_fetch: join(extBase, "web-fetch", "index.ts"),
|
|
406
|
+
video_extract: join(extBase, "video-extract", "index.ts"),
|
|
407
|
+
youtube_search: join(extBase, "youtube-search", "index.ts"),
|
|
408
|
+
google_image_search: join(extBase, "google-image-search", "index.ts"),
|
|
409
|
+
safe_bash: join(SUBAGENTS_DIR, "tools", "safe-bash.ts"),
|
|
410
|
+
};
|
|
411
|
+
// Prefer the built-in path, but fall back to a runtime-registered extension
|
|
412
|
+
// when that path no longer exists on disk (e.g. a built-in tool extension
|
|
413
|
+
// was disabled/removed but a project-local extension re-registered it).
|
|
414
|
+
const builtin = map[tool];
|
|
415
|
+
if (builtin && existsSync(builtin)) return builtin;
|
|
416
|
+
return EXTRA_TOOL_EXTENSIONS.get(tool);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* When this process was spawned as a restricted subagent, the parent pins the
|
|
421
|
+
* set of agents it may itself spawn via PI_SUBAGENT_ALLOWED. `null` means no
|
|
422
|
+
* restriction (top-level session, or an unrestricted child).
|
|
423
|
+
*/
|
|
424
|
+
// PI_SUBAGENT_ALLOWED 语义:env 未设置 → null(顶层会话,无限制);
|
|
425
|
+
// 设置了(即使为空串)→ 受限模式,空列表 = 禁止一切繁衍。
|
|
426
|
+
// 不能把空列表当作“无限制”——那会让受限子代理清空 env 即可绕过繁衍白名单。
|
|
427
|
+
const SUBAGENT_ALLOWLIST: Set<string> | null = (() => {
|
|
428
|
+
const raw = process.env.PI_SUBAGENT_ALLOWED;
|
|
429
|
+
if (raw === undefined) return null;
|
|
430
|
+
return new Set(raw.split(",").map((s) => s.trim()).filter(Boolean));
|
|
431
|
+
})();
|
|
432
|
+
|
|
433
|
+
function getBundledAgentsDir(): string {
|
|
434
|
+
return join(SUBAGENTS_DIR, "../agents");
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function agentDiscoveryOptions() {
|
|
438
|
+
return {
|
|
439
|
+
bundledDir: getBundledAgentsDir(),
|
|
440
|
+
configDir: getAgentConfigDir(),
|
|
441
|
+
projectDir: process.cwd(),
|
|
442
|
+
allowlist: SUBAGENT_ALLOWLIST,
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function discoverAgentDefinitions() {
|
|
447
|
+
return discoverAgents(agentDiscoveryOptions());
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function loadAgentDefaults(agentName: string): AgentDefaults | null {
|
|
451
|
+
return loadAgentDefaultsFrom(agentName, agentDiscoveryOptions());
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Wait long enough for a freshly created pane to finish shell startup.
|
|
456
|
+
*
|
|
457
|
+
* Some environments do extra shell-init work before the prompt is ready
|
|
458
|
+
* (for example direnv/devenv), so the delay is configurable for users who hit
|
|
459
|
+
* dropped commands. Keep the historical default at 500ms.
|
|
460
|
+
*/
|
|
461
|
+
function getShellReadyDelayMs(): number {
|
|
462
|
+
const raw = process.env.PI_SUBAGENT_SHELL_READY_DELAY_MS?.trim();
|
|
463
|
+
const parsed = raw ? Number.parseInt(raw, 10) : Number.NaN;
|
|
464
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 500;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function muxUnavailableResult() {
|
|
468
|
+
return {
|
|
469
|
+
content: [
|
|
470
|
+
{
|
|
471
|
+
type: "text" as const,
|
|
472
|
+
text:
|
|
473
|
+
`Sub-agents in a visible pane require herdr or tmux. ${muxSetupHint()} ` +
|
|
474
|
+
`Autonomous sub-agents can also run headless in the background without a multiplexer ` +
|
|
475
|
+
`(surface "background", or the default "auto" for auto-exit agents).`,
|
|
476
|
+
},
|
|
477
|
+
],
|
|
478
|
+
details: { error: "supported multiplexer not available" },
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Build the internal artifact directory path for the current session.
|
|
484
|
+
* Used by the subagents extension to stash task files, system prompts, and
|
|
485
|
+
* launch scripts for sub-agents. Path convention:
|
|
486
|
+
* <sessionDir>/artifacts/<session-id>/
|
|
487
|
+
*/
|
|
488
|
+
function getArtifactDir(sessionDir: string, sessionId: string): string {
|
|
489
|
+
return join(sessionDir, "artifacts", sessionId);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const statusConfig = loadStatusConfig();
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* 生效的状态显示开关:pi-toolkit.json 的 `subagents.status.enabled` 优先,
|
|
496
|
+
* 包内 config.json(→ config.json.example)只作兜底默认。运行时实时读取,
|
|
497
|
+
* 菜单里改完立即生效,不需要重启。
|
|
498
|
+
*/
|
|
499
|
+
function isStatusEnabled(): boolean {
|
|
500
|
+
const status = hostSection()?.section.status;
|
|
501
|
+
if (isPlainObject(status) && typeof status.enabled === "boolean") return status.enabled;
|
|
502
|
+
return statusConfig.enabled;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function formatWidgetRightLabel(snapshot: StatusSnapshot): string {
|
|
506
|
+
if (snapshot.kind === "starting") return " starting… ";
|
|
507
|
+
if (snapshot.kind === "running") return ` running ${snapshot.elapsedText} `;
|
|
508
|
+
if (snapshot.kind === "active") {
|
|
509
|
+
const label = snapshot.activityLabel ?? snapshot.activeScope;
|
|
510
|
+
const duration = snapshot.activeDurationText ? ` ${snapshot.activeDurationText}` : "";
|
|
511
|
+
return label ? ` active · ${label}${duration} ` : " active ";
|
|
512
|
+
}
|
|
513
|
+
if (snapshot.kind === "waiting") {
|
|
514
|
+
const duration = snapshot.waitingDurationText ? ` ${snapshot.waitingDurationText}` : "";
|
|
515
|
+
const detail = snapshot.statusLabel ? ` · ${snapshot.statusLabel}` : "";
|
|
516
|
+
return ` waiting${duration}${detail} `;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const detail = snapshot.statusLabel ? ` · ${snapshot.statusLabel}` : "";
|
|
520
|
+
const duration = snapshot.snapshotProblemText ? ` ${snapshot.snapshotProblemText}` : "";
|
|
521
|
+
return ` stalled${detail}${duration} `;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function resolveResultPresentation(
|
|
525
|
+
result: Pick<
|
|
526
|
+
SubagentResult,
|
|
527
|
+
"exitCode" | "elapsed" | "summary" | "sessionFile" | "sessionId" | "errorMessage"
|
|
528
|
+
>,
|
|
529
|
+
name: string,
|
|
530
|
+
options?: { sessionPreserved?: boolean },
|
|
531
|
+
): string {
|
|
532
|
+
// 终止引导(重要):结果消息曾以 "Follow up with subagent_message…" 结尾,
|
|
533
|
+
// 实测会诱导主模型在无新指令时反复 message 已完成的子代理,auto-exit 后
|
|
534
|
+
// 结果再回注,形成无限 resume 循环。现在改为明确的终止语义。
|
|
535
|
+
const sessionNote = options?.sessionPreserved === false
|
|
536
|
+
? `\n\nThis sub-agent's session artifacts were cleaned up by the retention policy; ` +
|
|
537
|
+
`the session cannot be resumed. If the task goal is achieved, simply continue ` +
|
|
538
|
+
`with your work — do NOT message it again unless you have a genuinely NEW instruction.`
|
|
539
|
+
: `\n\nThis sub-agent has finished and its session is preserved. If the task goal is ` +
|
|
540
|
+
`achieved, simply continue with your work — do NOT message it again unless you have ` +
|
|
541
|
+
`a genuinely NEW instruction (repeated messaging after auto-exit creates an infinite loop).`;
|
|
542
|
+
|
|
543
|
+
if (result.errorMessage) {
|
|
544
|
+
// Auto-retry exhausted or other agent-loop error. The subagent did not
|
|
545
|
+
// produce a usable result — surface the underlying provider/network
|
|
546
|
+
// failure so the orchestrator can decide whether to retry, resume, or
|
|
547
|
+
// change approach instead of silently treating the run as completed.
|
|
548
|
+
const recoveryHint = options?.sessionPreserved === false
|
|
549
|
+
? "spawn a new subagent with a corrected route or task"
|
|
550
|
+
: "spawn a new subagent or resume the session with subagent_message";
|
|
551
|
+
return (
|
|
552
|
+
`Sub-agent "${name}" failed after ${formatElapsed(result.elapsed)} ` +
|
|
553
|
+
`(provider/agent error — auto-retry exhausted).\n\n` +
|
|
554
|
+
`Error: ${result.errorMessage}\n\n` +
|
|
555
|
+
`The subagent did not produce a result. If a retry is genuinely warranted, ` +
|
|
556
|
+
`${recoveryHint}.${sessionNote}`
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
return result.exitCode !== 0
|
|
561
|
+
? `Sub-agent "${name}" failed (exit code ${result.exitCode}).\n\n${result.summary}${sessionNote}`
|
|
562
|
+
: `Sub-agent "${name}" completed (${formatElapsed(result.elapsed)}).\n\n${result.summary}${sessionNote}`;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// ── 结果回注 ───────────────────────────────────────────────────────────────
|
|
566
|
+
// 自动子代理(subagent 工具与 subagent_message resume)的 execute 内硬等待
|
|
567
|
+
// 子代理终态,真实结果直接作为 tool result 返回;pi 对同一条 assistant 消息
|
|
568
|
+
// 中的 sibling tool calls 默认并行执行(agent-core executeToolCallsParallel →
|
|
569
|
+
// Promise.all),turn_end 在全部工具调用完成后才发出,因此同一 turn 并行
|
|
570
|
+
// spawn 的自动子代理全部结束后编排者才继续,无需批次聚合、无额外唤醒。
|
|
571
|
+
// 交互式子代理的 pane 归用户驱动,保持异步:watcher 终态后经 steer 消息
|
|
572
|
+
// 回注。ask_question 与 stalled/recovered 状态通知也保持即时通道。
|
|
573
|
+
/**
|
|
574
|
+
* Result from running a single subagent.
|
|
575
|
+
*/
|
|
576
|
+
/** All currently running subagents, keyed by id. */
|
|
577
|
+
const runningSubagents = new Map<string, RunningSubagent>();
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* 运行中的子代理快照(服务注册表句柄 `subagents.running` 用)。
|
|
581
|
+
* 与状态 widget 同源:同一份进程内 Map。
|
|
582
|
+
*/
|
|
583
|
+
export function listRunningSubagents(): readonly RunningSubagent[] {
|
|
584
|
+
return Array.from(runningSubagents.values());
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* 会话作用域的运行态登记文件路径(沿用 runtime-registry 的既有布局:
|
|
589
|
+
* <sessionDir>/artifacts/<sessionId>/subagent-runtime.json)。
|
|
590
|
+
*/
|
|
591
|
+
export function runtimeRegistryPathForSession(sessionDir: string, sessionId: string): string {
|
|
592
|
+
return runtimeRegistryPath(getArtifactDir(sessionDir, sessionId));
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// When this extension is loaded inside a subagent that itself spawns children
|
|
596
|
+
// (e.g. a worker delegating to scout/researcher), `subagent-done.ts` runs in the
|
|
597
|
+
// same process and needs to know whether this session still has children in
|
|
598
|
+
// flight — so it can suppress auto-exit and keep the session open until they all
|
|
599
|
+
// report back. Expose a live count through a process-global symbol that both
|
|
600
|
+
// modules share. (subagent-done.ts reads it; if absent it assumes zero.)
|
|
601
|
+
const RUNNING_CHILDREN_COUNT_KEY = Symbol.for("pi-subagents/running-children-count");
|
|
602
|
+
(globalThis as any)[RUNNING_CHILDREN_COUNT_KEY] = () => runningSubagents.size;
|
|
603
|
+
|
|
604
|
+
// ── Widget management ──
|
|
605
|
+
|
|
606
|
+
/** Latest ExtensionContext from session_start, used for widget updates. */
|
|
607
|
+
let latestCtx: ExtensionContext | null = null;
|
|
608
|
+
/** Latest ExtensionAPI, used to deliver ask_question notifications from the watcher. */
|
|
609
|
+
let latestPi: ExtensionAPI | null = null;
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* Expand every profile skill for a headless RPC prompt.
|
|
613
|
+
*
|
|
614
|
+
* Pane launches can pass several `/skill:name` positional messages and let the
|
|
615
|
+
* CLI expand them independently. RPC accepts one JSON prompt, and Pi treats
|
|
616
|
+
* everything after the first skill command as that command's arguments. Read
|
|
617
|
+
* the same skill files here and emit Pi's normal `<skill>` blocks instead of
|
|
618
|
+
* silently making the second and later skills arguments to the first one.
|
|
619
|
+
*
|
|
620
|
+
* The resolver is injectable for tests and for hosts that expose a resource
|
|
621
|
+
* registry. In the normal extension host, `getCommands()` carries each skill's
|
|
622
|
+
* canonical source path, so no ad-hoc filesystem scan is needed.
|
|
623
|
+
*/
|
|
624
|
+
export function buildHeadlessPrompt(
|
|
625
|
+
effectiveSkills: string | undefined,
|
|
626
|
+
task: string,
|
|
627
|
+
resolveSkill: (name: string) => { path: string } | null = (name) => {
|
|
628
|
+
try {
|
|
629
|
+
const command = latestPi?.getCommands().find(
|
|
630
|
+
(candidate) => candidate.source === "skill" && candidate.name === `skill:${name}`,
|
|
631
|
+
);
|
|
632
|
+
const path = command?.sourceInfo?.path;
|
|
633
|
+
return typeof path === "string" && existsSync(path) ? { path } : null;
|
|
634
|
+
} catch (error) {
|
|
635
|
+
debugLog(`Could not resolve skill ${name} for headless prompt`, error);
|
|
636
|
+
return null;
|
|
637
|
+
}
|
|
638
|
+
},
|
|
639
|
+
): string {
|
|
640
|
+
const names = (effectiveSkills ?? "")
|
|
641
|
+
.split(",")
|
|
642
|
+
.map((name) => name.trim().replace(/^\/skill:/, ""))
|
|
643
|
+
.filter(Boolean);
|
|
644
|
+
const blocks: string[] = [];
|
|
645
|
+
const unresolved: string[] = [];
|
|
646
|
+
|
|
647
|
+
for (const name of names) {
|
|
648
|
+
const resolved = resolveSkill(name);
|
|
649
|
+
if (!resolved) {
|
|
650
|
+
unresolved.push(name);
|
|
651
|
+
continue;
|
|
652
|
+
}
|
|
653
|
+
try {
|
|
654
|
+
const raw = readFileSync(resolved.path, "utf8");
|
|
655
|
+
const body = raw.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n*/, "").trim();
|
|
656
|
+
if (!body) {
|
|
657
|
+
unresolved.push(name);
|
|
658
|
+
continue;
|
|
659
|
+
}
|
|
660
|
+
blocks.push(
|
|
661
|
+
`<skill name="${name}" location="${resolved.path}">\n` +
|
|
662
|
+
`References are relative to ${dirname(resolved.path)}.\n\n` +
|
|
663
|
+
`${body}\n</skill>`,
|
|
664
|
+
);
|
|
665
|
+
} catch (error) {
|
|
666
|
+
unresolved.push(name);
|
|
667
|
+
debugLog(`Could not read skill ${name} at ${resolved.path}`, error);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
if (unresolved.length > 0) {
|
|
672
|
+
// Do not put unresolved `/skill:` commands into a multi-skill prompt: Pi
|
|
673
|
+
// would interpret the remainder as arguments to the first command. The
|
|
674
|
+
// child still receives its normal available-skills system section and can
|
|
675
|
+
// read these named files itself, while the omission remains explicit.
|
|
676
|
+
blocks.push(
|
|
677
|
+
`The following configured skills could not be expanded by the orchestrator: ` +
|
|
678
|
+
`${unresolved.join(", ")}. Before acting, locate and read each skill's ` +
|
|
679
|
+
`SKILL.md from the available skills list if it is present.`,
|
|
680
|
+
);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
return [...blocks, task].filter((part) => part.length > 0).join("\n\n");
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/** Interval timer for widget re-renders. */
|
|
687
|
+
let widgetInterval: ReturnType<typeof setInterval> | null = null;
|
|
688
|
+
|
|
689
|
+
/** Interval timer for status transition checks. */
|
|
690
|
+
let statusInterval: ReturnType<typeof setInterval> | null = null;
|
|
691
|
+
|
|
692
|
+
function formatElapsedMMSS(startTime: number): string {
|
|
693
|
+
const seconds = Math.floor((Date.now() - startTime) / 1000);
|
|
694
|
+
const m = Math.floor(seconds / 60);
|
|
695
|
+
const s = seconds % 60;
|
|
696
|
+
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function renderSubagentWidgetLines(agents: RunningSubagent[], width: number): string[] {
|
|
700
|
+
const count = agents.length;
|
|
701
|
+
const title = "Subagents";
|
|
702
|
+
const info = `${count} running`;
|
|
703
|
+
|
|
704
|
+
const lines: string[] = [borderTop(title, info, width)];
|
|
705
|
+
|
|
706
|
+
for (const agent of agents) {
|
|
707
|
+
const elapsed = formatElapsedMMSS(agent.startTime);
|
|
708
|
+
const agentTag = agent.agent ? ` (${agent.agent})` : "";
|
|
709
|
+
const snapshot = classifyStatus(agent.statusState, Date.now());
|
|
710
|
+
const icon = widgetIcon(snapshot.kind);
|
|
711
|
+
const left = ` ${icon} ${elapsed} ${agent.name}${agentTag} `;
|
|
712
|
+
const right = isStatusEnabled()
|
|
713
|
+
? formatWidgetRightLabel(snapshot)
|
|
714
|
+
: " starting… ";
|
|
715
|
+
|
|
716
|
+
lines.push(borderLine(left, right, width));
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
lines.push(borderBottom(width));
|
|
720
|
+
return lines;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
function updateWidget() {
|
|
724
|
+
if (!latestCtx?.hasUI) return;
|
|
725
|
+
|
|
726
|
+
if (runningSubagents.size === 0) {
|
|
727
|
+
latestCtx.ui.setWidget("subagent-status", undefined);
|
|
728
|
+
if (widgetInterval) {
|
|
729
|
+
clearInterval(widgetInterval);
|
|
730
|
+
widgetInterval = null;
|
|
731
|
+
(globalThis as any)[WIDGET_INTERVAL_KEY] = null;
|
|
732
|
+
}
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
latestCtx.ui.setWidget(
|
|
737
|
+
"subagent-status",
|
|
738
|
+
(_tui: any, _theme: any) => {
|
|
739
|
+
return {
|
|
740
|
+
invalidate() {},
|
|
741
|
+
render(width: number) {
|
|
742
|
+
return renderSubagentWidgetLines(Array.from(runningSubagents.values()), width);
|
|
743
|
+
},
|
|
744
|
+
};
|
|
745
|
+
},
|
|
746
|
+
{ placement: "aboveEditor" },
|
|
747
|
+
);
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/**
|
|
751
|
+
* Apply a loadout snapshot's sandbox to a pi command's `parts` array: model,
|
|
752
|
+
* identity (system prompt), and the profile's tool restriction. Pi's normal
|
|
753
|
+
* extension discovery remains enabled so the child inherits configured plugins.
|
|
754
|
+
*
|
|
755
|
+
* This is the single source of truth for reconstructing a subagent's sandbox,
|
|
756
|
+
* used both by the initial `launchSubagent` and by the `subagent_message`
|
|
757
|
+
* resume path so the two can never drift. Env vars (PI_SUBAGENT_AGENT /
|
|
758
|
+
* PI_SUBAGENT_ALLOWED / PI_CODING_AGENT_DIR) and cwd are the caller's
|
|
759
|
+
* responsibility since they differ slightly between launch and resume.
|
|
760
|
+
*/
|
|
761
|
+
function applySandboxToParts(
|
|
762
|
+
parts: string[],
|
|
763
|
+
loadout: SubagentLoadout,
|
|
764
|
+
opts: { artifactDir: string; name: string },
|
|
765
|
+
/** headless 路径传恒等函数:参数走 argv 不经 shell,不做 shellEscape。 */
|
|
766
|
+
options?: { escape?: (value: string) => string },
|
|
767
|
+
): string[] {
|
|
768
|
+
const escape = options?.escape ?? shellEscape;
|
|
769
|
+
// 本次调用写入 artifactDir/context/ 的文件路径,返回给调用方记入
|
|
770
|
+
// running.contextFiles(retention 清理用);resume 路径忽略返回值。
|
|
771
|
+
if (loadout.model) {
|
|
772
|
+
// 思考等级优先级:thinkingOverride(spawn 显式参数)> model 自带 ":level" 后缀 >
|
|
773
|
+
// frontmatter thinking。不无条件追加,避免 "model:max" + frontmatter "low"
|
|
774
|
+
// 拼成 ":max:low" 导致 max 被覆盖。
|
|
775
|
+
let model = loadout.model;
|
|
776
|
+
const suffixMatch = /^(.+):([a-z]+)$/.exec(model);
|
|
777
|
+
const modelThinking = suffixMatch && THINKING_LEVELS.has(suffixMatch[2]) ? suffixMatch[2] : null;
|
|
778
|
+
if (loadout.thinkingOverride) {
|
|
779
|
+
model = (modelThinking ? suffixMatch![1] : model) + ":" + loadout.thinkingOverride;
|
|
780
|
+
} else if (!modelThinking && loadout.thinking) {
|
|
781
|
+
model = model + ":" + loadout.thinking;
|
|
782
|
+
}
|
|
783
|
+
parts.push("--model", escape(model));
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
const contextArtifacts: string[] = [];
|
|
787
|
+
if (loadout.identity) {
|
|
788
|
+
const flag = loadout.systemPromptMode === "replace" ? "--system-prompt" : "--append-system-prompt";
|
|
789
|
+
const spTimestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
|
790
|
+
const spSafeName = sanitizeSubagentFileName(opts.name);
|
|
791
|
+
const spPath = join(opts.artifactDir, `context/${spSafeName || "subagent"}-sysprompt-${spTimestamp}.md`);
|
|
792
|
+
mkdirSync(dirname(spPath), { recursive: true });
|
|
793
|
+
writeFileSync(spPath, loadout.identity, "utf8");
|
|
794
|
+
contextArtifacts.push(spPath);
|
|
795
|
+
parts.push(flag, escape(spPath));
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// Keep the profile's tool allowlist, but leave Pi's normal extension
|
|
799
|
+
// discovery enabled so the child inherits the parent's configured plugins.
|
|
800
|
+
// Explicit backing paths below preserve custom tool extensions that are not
|
|
801
|
+
// discoverable from the child's cwd/config directory.
|
|
802
|
+
// An empty string is an intentional empty allowlist and must not be treated
|
|
803
|
+
// like null. Using a truthiness check here would silently widen a restricted
|
|
804
|
+
// profile back to Pi's default tool set during resume.
|
|
805
|
+
if (typeof loadout.toolAllowlist === "string") {
|
|
806
|
+
parts.push("--tools", escape(loadout.toolAllowlist));
|
|
807
|
+
|
|
808
|
+
const extPaths = new Set<string>();
|
|
809
|
+
for (const tool of loadout.toolAllowlist.split(",")) {
|
|
810
|
+
const extPath = getToolExtensionPath(tool);
|
|
811
|
+
if (extPath && existsSync(extPath)) extPaths.add(extPath);
|
|
812
|
+
}
|
|
813
|
+
// -e 注入的路径 containment:只允许插件自身目录与宿主 extensions 目录下的
|
|
814
|
+
// 扩展被回装,防止注册表被注入任意路径后把不可信代码带进子代理沙箱。
|
|
815
|
+
const trustedRoots = [resolve(SUBAGENTS_DIR), resolve(join(getAgentConfigDir(), "extensions"))];
|
|
816
|
+
for (const extPath of extPaths) {
|
|
817
|
+
const resolved = resolve(extPath);
|
|
818
|
+
if (!trustedRoots.some((root) => resolved === root || resolved.startsWith(root + sep))) continue;
|
|
819
|
+
parts.push("-e", escape(resolved));
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// Normal extension discovery is intentionally preserved, but delegation is
|
|
824
|
+
// a separate capability. When the profile did not explicitly grant
|
|
825
|
+
// subagent_agents, exclude all three control tools even if the child has no
|
|
826
|
+
// `--tools` allowlist (or a stale loadout tried to include one).
|
|
827
|
+
const canSpawnChildren = Array.isArray(loadout.spawnable) && loadout.spawnable.length > 0;
|
|
828
|
+
if (!canSpawnChildren) {
|
|
829
|
+
parts.push("--exclude-tools", escape(SPAWNING_TOOLS.join(",")));
|
|
830
|
+
}
|
|
831
|
+
return contextArtifacts;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
function activityLabel(activity: SubagentActivityState): string | undefined {
|
|
835
|
+
if (activity.phase !== "active") return undefined;
|
|
836
|
+
if (activity.activeScope === "tool") return activity.toolName ?? "tool";
|
|
837
|
+
if (activity.activeScope === "provider") return "provider";
|
|
838
|
+
if (activity.activeScope === "streaming") return "streaming";
|
|
839
|
+
return activity.activeScope;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
function observeRunningSubagent(running: RunningSubagent, observedAt = Date.now()) {
|
|
843
|
+
const activityFile = running.activityFile;
|
|
844
|
+
const read: ActivityReadResult = activityFile
|
|
845
|
+
? readSubagentActivityFile(activityFile, running.id)
|
|
846
|
+
: { ok: false, reason: "missing" };
|
|
847
|
+
|
|
848
|
+
running.activityRead = read.ok
|
|
849
|
+
? { ok: true }
|
|
850
|
+
: { ok: false, reason: read.reason, error: read.error };
|
|
851
|
+
|
|
852
|
+
if (read.ok) {
|
|
853
|
+
running.activity = read.activity;
|
|
854
|
+
running.statusState = observeStatus(running.statusState, {
|
|
855
|
+
snapshot: "present",
|
|
856
|
+
updatedAt: read.activity.updatedAt,
|
|
857
|
+
sequence: read.activity.sequence,
|
|
858
|
+
phase: read.activity.phase,
|
|
859
|
+
active: read.activity.phase === "active",
|
|
860
|
+
activeScope: read.activity.activeScope,
|
|
861
|
+
activeSince: read.activity.activeSince,
|
|
862
|
+
waitingSince: read.activity.waitingSince,
|
|
863
|
+
latestEvent: read.activity.latestEvent,
|
|
864
|
+
activityLabel: activityLabel(read.activity),
|
|
865
|
+
}, observedAt);
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
running.statusState = observeStatus(running.statusState, {
|
|
870
|
+
snapshot: read.reason,
|
|
871
|
+
snapshotError: read.error,
|
|
872
|
+
}, observedAt);
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
/**
|
|
876
|
+
* Names claimed by spawns that are mid-launch but not yet registered in
|
|
877
|
+
* `runningSubagents`. Parallel `subagent` tool calls run their synchronous
|
|
878
|
+
* prefix (name defaulting) before any of them finishes `launchSubagent` and
|
|
879
|
+
* registers, so without this they'd all see an empty map and pick the same
|
|
880
|
+
* name. Reserved synchronously when a default name is chosen and released once
|
|
881
|
+
* the subagent registers (or its launch fails).
|
|
882
|
+
*/
|
|
883
|
+
const reservedNames = new Set<string>();
|
|
884
|
+
|
|
885
|
+
/**
|
|
886
|
+
* Return `base`, or `base-2`, `base-3`, … so the result is unique within this
|
|
887
|
+
* spawner session. Considers (a) currently-running subagents, (b) names
|
|
888
|
+
* reserved by parallel in-flight spawns, and (c) every name already recorded in
|
|
889
|
+
* the spawner's persistent registry — so a defaulted name never collides with a
|
|
890
|
+
* finished subagent either. This lets `subagent_message({ name })` address any
|
|
891
|
+
* subagent of this session unambiguously, running or finished.
|
|
892
|
+
*
|
|
893
|
+
* `registryNames` is the set of names already taken in the registry (empty when
|
|
894
|
+
* there is no session file / artifact dir yet).
|
|
895
|
+
*/
|
|
896
|
+
function uniqueRunningName(base: string, registryNames?: Set<string>): string {
|
|
897
|
+
const taken = new Set(Array.from(runningSubagents.values()).map((r) => r.name));
|
|
898
|
+
for (const reserved of reservedNames) taken.add(reserved);
|
|
899
|
+
if (registryNames) for (const n of registryNames) taken.add(n);
|
|
900
|
+
if (!taken.has(base)) return base;
|
|
901
|
+
let n = 2;
|
|
902
|
+
while (taken.has(`${base}-${n}`)) n++;
|
|
903
|
+
return `${base}-${n}`;
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
function resolveRunningByName(name: string):
|
|
907
|
+
| { running: RunningSubagent }
|
|
908
|
+
| { error: string } {
|
|
909
|
+
// 与 spawn/resume 同規则 normalize,多余空白/大小写差异不再导致寻址失败;
|
|
910
|
+
// 空名仍报错(fallback 传空串,不默认成 "subagent")。
|
|
911
|
+
const requestedName = normalizeSubagentName(name, "");
|
|
912
|
+
if (!requestedName) {
|
|
913
|
+
return { error: "Provide the exact display name of a running subagent." };
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
const matches = Array.from(runningSubagents.values()).filter((running) => running.name === requestedName);
|
|
917
|
+
if (matches.length === 1) return { running: matches[0] };
|
|
918
|
+
if (matches.length === 0) {
|
|
919
|
+
const names = Array.from(runningSubagents.values()).map((r) => r.name);
|
|
920
|
+
const hint = names.length
|
|
921
|
+
? ` Currently running: ${[...new Set(names)].join(", ")}.`
|
|
922
|
+
: " No subagents are currently running.";
|
|
923
|
+
return { error: `No running subagent named "${requestedName}".${hint}` };
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
const candidates = matches.map((running) => `${running.name} [${running.id}]`).join(", ");
|
|
927
|
+
return { error: `Ambiguous subagent name "${requestedName}". Matches: ${candidates}` };
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
/**
|
|
931
|
+
* Type a follow-up message into a running subagent's live pane. Newlines are
|
|
932
|
+
* collapsed to spaces because each newline submits a turn in the child's TUI
|
|
933
|
+
* editor; a multi-line message would otherwise fire as several partial turns.
|
|
934
|
+
*/
|
|
935
|
+
function steerSubagent(
|
|
936
|
+
running: RunningSubagent,
|
|
937
|
+
message: string,
|
|
938
|
+
send: (surface: string, command: string) => void = sendCommand,
|
|
939
|
+
): { ok: true } | { error: string } {
|
|
940
|
+
const flattened = message.replace(/\s*\n\s*/g, " ").trim();
|
|
941
|
+
// headless 子代理:消息经 RPC stdin 投递(prompt + streamingBehavior:steer,
|
|
942
|
+
// 运行中排队、空闲开启新 run),不经 pane 键入。
|
|
943
|
+
if (running.kind === "headless") {
|
|
944
|
+
if (running.stdinLost) {
|
|
945
|
+
return {
|
|
946
|
+
error:
|
|
947
|
+
`Cannot deliver message to headless sub-agent "${running.name}": the host was ` +
|
|
948
|
+
`reloaded and its stdin can no longer be reached. Its result will still be ` +
|
|
949
|
+
`delivered when the process exits; spawn a fresh sub-agent if you need to steer it now.`,
|
|
950
|
+
};
|
|
951
|
+
}
|
|
952
|
+
const child = running.headlessChild;
|
|
953
|
+
if (!child) {
|
|
954
|
+
return { error: `Headless sub-agent "${running.name}" has no live process handle.` };
|
|
955
|
+
}
|
|
956
|
+
if (child.exited) {
|
|
957
|
+
return {
|
|
958
|
+
error:
|
|
959
|
+
`Headless sub-agent "${running.name}" has already exited; its result is being delivered. ` +
|
|
960
|
+
`Use subagent_message again after the result arrives to resume its session.`,
|
|
961
|
+
};
|
|
962
|
+
}
|
|
963
|
+
child.steer(flattened);
|
|
964
|
+
return { ok: true };
|
|
965
|
+
}
|
|
966
|
+
try {
|
|
967
|
+
send(running.surface, flattened);
|
|
968
|
+
return { ok: true };
|
|
969
|
+
} catch (error: any) {
|
|
970
|
+
// 表面可能是 herdr 或 tmux,错误文案按实际检测结果命名,不写死 herdr。
|
|
971
|
+
const muxLabel = detectSurface() === "tmux" ? "tmux" : detectSurface() === "herdr" ? "herdr" : "terminal multiplexer";
|
|
972
|
+
return {
|
|
973
|
+
error:
|
|
974
|
+
`Failed to deliver message to subagent "${running.name}" via ${muxLabel}: ` +
|
|
975
|
+
`${error?.message ?? String(error)}`,
|
|
976
|
+
};
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
function handleSubagentSteer(
|
|
981
|
+
params: { name?: string; message?: string },
|
|
982
|
+
send: (surface: string, command: string) => void = sendCommand,
|
|
983
|
+
) {
|
|
984
|
+
const message = params.message?.trim();
|
|
985
|
+
if (!message) {
|
|
986
|
+
const err = "`message` is required to steer a running subagent.";
|
|
987
|
+
return { content: [{ type: "text" as const, text: err }], details: { error: err } };
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
const resolved = resolveRunningByName(params.name ?? "");
|
|
991
|
+
if ("error" in resolved) {
|
|
992
|
+
return {
|
|
993
|
+
content: [{ type: "text" as const, text: resolved.error }],
|
|
994
|
+
details: { error: resolved.error },
|
|
995
|
+
};
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
const running = resolved.running;
|
|
999
|
+
const now = Date.now();
|
|
1000
|
+
observeRunningSubagent(running, now);
|
|
1001
|
+
|
|
1002
|
+
const steer = steerSubagent(running, message, send);
|
|
1003
|
+
if ("error" in steer) {
|
|
1004
|
+
return {
|
|
1005
|
+
content: [{ type: "text" as const, text: steer.error }],
|
|
1006
|
+
details: {
|
|
1007
|
+
error: steer.error,
|
|
1008
|
+
id: running.id,
|
|
1009
|
+
name: running.name,
|
|
1010
|
+
...(running.cohortId ? { cohortId: running.cohortId } : {}),
|
|
1011
|
+
},
|
|
1012
|
+
};
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
running.statusState = forceStatusAfterInterrupt(running.statusState, now);
|
|
1016
|
+
updateWidget();
|
|
1017
|
+
|
|
1018
|
+
return {
|
|
1019
|
+
content: [{
|
|
1020
|
+
type: "text" as const,
|
|
1021
|
+
text:
|
|
1022
|
+
`Message delivered to running subagent "${running.name}". It picks this up at its next ` +
|
|
1023
|
+
`turn boundary. If it exits, its result still arrives as a steer message.`,
|
|
1024
|
+
}],
|
|
1025
|
+
details: {
|
|
1026
|
+
id: running.id,
|
|
1027
|
+
name: running.name,
|
|
1028
|
+
...(running.cohortId ? { cohortId: running.cohortId } : {}),
|
|
1029
|
+
status: "steered",
|
|
1030
|
+
},
|
|
1031
|
+
};
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
function startStatusRefresh(pi: ExtensionAPI) {
|
|
1035
|
+
if (!isStatusEnabled() || statusInterval) return;
|
|
1036
|
+
|
|
1037
|
+
statusInterval = setInterval(() => {
|
|
1038
|
+
if (runningSubagents.size === 0) {
|
|
1039
|
+
if (statusInterval) {
|
|
1040
|
+
clearInterval(statusInterval);
|
|
1041
|
+
statusInterval = null;
|
|
1042
|
+
(globalThis as any)[STATUS_INTERVAL_KEY] = null;
|
|
1043
|
+
}
|
|
1044
|
+
return;
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
const transitionLines: string[] = [];
|
|
1048
|
+
const now = Date.now();
|
|
1049
|
+
let shouldRefreshWidget = false;
|
|
1050
|
+
|
|
1051
|
+
for (const running of runningSubagents.values()) {
|
|
1052
|
+
observeRunningSubagent(running, now);
|
|
1053
|
+
const { nextState, snapshot, transition } = advanceStatusState(running.statusState, now);
|
|
1054
|
+
if (nextState.currentKind !== running.statusState.currentKind) {
|
|
1055
|
+
shouldRefreshWidget = true;
|
|
1056
|
+
}
|
|
1057
|
+
running.statusState = nextState;
|
|
1058
|
+
|
|
1059
|
+
// Interactive subagents (long-running, user-driven) intentionally don't
|
|
1060
|
+
// wake the parent session on stalled/recovered transitions — the user is
|
|
1061
|
+
// working in the subagent's pane, and a steer message here would burn an
|
|
1062
|
+
// orchestrator turn on a no-op "still waiting" ping. Widget still updates.
|
|
1063
|
+
if (transition && !running.interactive) {
|
|
1064
|
+
transitionLines.push(formatTransitionLine(running.name, snapshot, transition));
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
if (shouldRefreshWidget) updateWidget();
|
|
1069
|
+
|
|
1070
|
+
if (transitionLines.length > 0) {
|
|
1071
|
+
const capped = capStatusLines(transitionLines, statusConfig.lineLimit);
|
|
1072
|
+
pi.sendMessage(
|
|
1073
|
+
{
|
|
1074
|
+
customType: "subagent_status",
|
|
1075
|
+
content: formatStatusAggregate(transitionLines, statusConfig.lineLimit),
|
|
1076
|
+
display: true,
|
|
1077
|
+
details: { lines: capped.visibleLines, overflow: capped.overflow },
|
|
1078
|
+
},
|
|
1079
|
+
{ triggerTurn: true, deliverAs: "steer" },
|
|
1080
|
+
);
|
|
1081
|
+
}
|
|
1082
|
+
}, 1000);
|
|
1083
|
+
|
|
1084
|
+
(globalThis as any)[STATUS_INTERVAL_KEY] = statusInterval;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
// Resuming a finished session is always autonomous: the relaunched agent runs
|
|
1088
|
+
// its follow-up task to completion and the result is returned directly from
|
|
1089
|
+
// the blocking subagent_message tool call. An interactive resume would park
|
|
1090
|
+
// the pane waiting for the user, contradicting that result-delivery model.
|
|
1091
|
+
function resolveResumeLaunchBehavior(): { autoExit: boolean; interactive: boolean } {
|
|
1092
|
+
return { autoExit: true, interactive: false };
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
export const __test__ = {
|
|
1096
|
+
borderLine,
|
|
1097
|
+
isStatusEnabled,
|
|
1098
|
+
getShellReadyDelayMs,
|
|
1099
|
+
renderSubagentWidgetLines,
|
|
1100
|
+
loadAgentDefaults,
|
|
1101
|
+
discoverAgentDefinitions,
|
|
1102
|
+
validateResumeTarget,
|
|
1103
|
+
resolveEffectiveSessionMode,
|
|
1104
|
+
resolveLaunchBehavior,
|
|
1105
|
+
resolveEffectiveAutoExit,
|
|
1106
|
+
resolveEffectiveInteractive,
|
|
1107
|
+
resolveSurfaceChoice,
|
|
1108
|
+
validateAgentLifecycleConfig,
|
|
1109
|
+
buildSubagentToolAllowlist,
|
|
1110
|
+
applySandboxToParts,
|
|
1111
|
+
buildPiPromptArgs,
|
|
1112
|
+
formatWidgetRightLabel,
|
|
1113
|
+
observeRunningSubagent,
|
|
1114
|
+
getToolExtensionPath,
|
|
1115
|
+
resolveRunningByName,
|
|
1116
|
+
uniqueRunningName,
|
|
1117
|
+
reservedNames,
|
|
1118
|
+
steerSubagent,
|
|
1119
|
+
handleSubagentSteer,
|
|
1120
|
+
classifyWatcherFailure,
|
|
1121
|
+
watchMemberRound,
|
|
1122
|
+
dispatchMarkerInSession,
|
|
1123
|
+
canReuseMemberName,
|
|
1124
|
+
markMemberOffline,
|
|
1125
|
+
drainMemberMailbox,
|
|
1126
|
+
resolveResultPresentation,
|
|
1127
|
+
resolveResumeLaunchBehavior,
|
|
1128
|
+
buildHeadlessPrompt,
|
|
1129
|
+
runningSubagents,
|
|
1130
|
+
watchSubagent,
|
|
1131
|
+
waitForHeadlessExit,
|
|
1132
|
+
watchHeadlessSubagent,
|
|
1133
|
+
formatElapsed,
|
|
1134
|
+
formatTokens,
|
|
1135
|
+
formatContextUsage,
|
|
1136
|
+
contextWindowFor,
|
|
1137
|
+
formatUsageSegments,
|
|
1138
|
+
widgetIcon,
|
|
1139
|
+
};
|
|
1140
|
+
|
|
1141
|
+
function startWidgetRefresh() {
|
|
1142
|
+
if (widgetInterval) return;
|
|
1143
|
+
updateWidget(); // immediate first render
|
|
1144
|
+
widgetInterval = setInterval(() => {
|
|
1145
|
+
updateWidget();
|
|
1146
|
+
}, 1000);
|
|
1147
|
+
(globalThis as any)[WIDGET_INTERVAL_KEY] = widgetInterval;
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
/**
|
|
1151
|
+
* Launch a subagent: creates the multiplexer pane, builds the command, and
|
|
1152
|
+
* sends it. Returns a RunningSubagent — does NOT poll.
|
|
1153
|
+
*
|
|
1154
|
+
* Call watchSubagent() on the returned object to observe completion.
|
|
1155
|
+
*/
|
|
1156
|
+
async function launchSubagent(
|
|
1157
|
+
params: typeof SubagentParams.static,
|
|
1158
|
+
ctx: { sessionManager: { getSessionFile(): string | null | undefined; getSessionId(): string; getSessionDir(): string }; cwd: string },
|
|
1159
|
+
options?: { surface?: string },
|
|
1160
|
+
): Promise<RunningSubagent> {
|
|
1161
|
+
const startTime = Date.now();
|
|
1162
|
+
const id = Math.random().toString(16).slice(2, 10);
|
|
1163
|
+
const sentinelToken = `__PI_SUBAGENT_DONE_${randomUUID()}__`;
|
|
1164
|
+
const cohortError = validateCohortId(params.cohortId);
|
|
1165
|
+
if (cohortError) throw new Error(cohortError);
|
|
1166
|
+
const cohortId = normalizeCohortId(params.cohortId);
|
|
1167
|
+
|
|
1168
|
+
const agentDefs = params.agent ? loadAgentDefaults(params.agent) : null;
|
|
1169
|
+
// Resolve the target before tier lookup so a project-local
|
|
1170
|
+
// .pi/agent/pi-subagents.json is honored when the caller selected a
|
|
1171
|
+
// different cwd. The concrete model is then captured in the loadout below,
|
|
1172
|
+
// so resume does not drift if the tier configuration changes later.
|
|
1173
|
+
const { effectiveCwd, localAgentDir, effectiveAgentDir } = resolveSubagentPaths(
|
|
1174
|
+
params,
|
|
1175
|
+
agentDefs,
|
|
1176
|
+
getAgentConfigDir,
|
|
1177
|
+
);
|
|
1178
|
+
const targetCwdForSession = effectiveCwd ?? ctx.cwd;
|
|
1179
|
+
|
|
1180
|
+
// tier 解析先于 loadout 写入:显式 params.model 优先 tier;tier 无法归一化
|
|
1181
|
+
// 或缺映射时直接抛错拒绝启动,绝不静默换模型。loadout.model 存具体模型,
|
|
1182
|
+
// tier 仅作记录,resume 不随配置漂移。
|
|
1183
|
+
const tierResolution = resolveTierForParams(params, () =>
|
|
1184
|
+
loadTierRouteConfig({
|
|
1185
|
+
cwd: targetCwdForSession,
|
|
1186
|
+
agentConfigDir: effectiveAgentDir,
|
|
1187
|
+
injected: resolveHostTierSources(),
|
|
1188
|
+
}));
|
|
1189
|
+
if ("error" in tierResolution) throw new Error(tierResolution.error);
|
|
1190
|
+
const effectiveModel = params.model ?? tierResolution.model ?? agentDefs?.model;
|
|
1191
|
+
const effectiveTools = agentDefs?.tools;
|
|
1192
|
+
const effectiveSkills = agentDefs?.skills;
|
|
1193
|
+
const effectiveThinking = params.thinking ?? agentDefs?.thinking;
|
|
1194
|
+
if (effectiveThinking != null && !THINKING_LEVELS.has(effectiveThinking)) {
|
|
1195
|
+
throw new Error(
|
|
1196
|
+
`Invalid thinking level "${effectiveThinking}". Use: ${[...THINKING_LEVELS].join(", ")}.`,
|
|
1197
|
+
);
|
|
1198
|
+
}
|
|
1199
|
+
const lifecycleError = validateAgentLifecycleConfig(agentDefs);
|
|
1200
|
+
if (lifecycleError) throw new Error(lifecycleError);
|
|
1201
|
+
const effectiveInteractive = resolveEffectiveInteractive(params, agentDefs);
|
|
1202
|
+
const effectiveAutoExit = resolveEffectiveAutoExit(agentDefs);
|
|
1203
|
+
// 持久团队成员(member):常驻 headless 进程,不走 auto-exit/barrier。
|
|
1204
|
+
// 工具层已拒绝 pane/interactive/discard/timeoutMs/dependsOn 组合;这里
|
|
1205
|
+
// 强制 headless 表面,不改变非 member 的任何默认行为。
|
|
1206
|
+
const memberMode = params.member === true;
|
|
1207
|
+
|
|
1208
|
+
// 表面选择:pane(现有 herdr/tmux 路径)或 headless(独立后台 pi 进程)。
|
|
1209
|
+
// interactive 请求 background 在此直接报错,不静默建 pane。
|
|
1210
|
+
const surfaceChoice = resolveSurfaceChoice(
|
|
1211
|
+
memberMode ? "background" : params.surface,
|
|
1212
|
+
memberMode ? false : effectiveInteractive,
|
|
1213
|
+
);
|
|
1214
|
+
if ("error" in surfaceChoice) throw new Error(surfaceChoice.error);
|
|
1215
|
+
const useHeadless = surfaceChoice.choice === "headless";
|
|
1216
|
+
|
|
1217
|
+
const sessionFile = ctx.sessionManager.getSessionFile();
|
|
1218
|
+
if (!sessionFile) throw new Error("No session file");
|
|
1219
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
1220
|
+
const artifactDir = getArtifactDir(ctx.sessionManager.getSessionDir(), sessionId);
|
|
1221
|
+
|
|
1222
|
+
// fork 模式拦截必须先于 createSurface/spawnHeadless(否则被拒绝的 spawn 会泄漏 pane 或进程)。
|
|
1223
|
+
// fork 的任务文本直传 argv,在 Windows pane 的 PowerShell 引号规则下无法安全
|
|
1224
|
+
// 转义,pane 表面不支持;standalone/lineage-only 的任务走 @artifact 文件,不受影响。
|
|
1225
|
+
const launchBehavior = resolveLaunchBehavior(params, agentDefs);
|
|
1226
|
+
if (launchBehavior.inheritsConversationContext) {
|
|
1227
|
+
throw new Error(
|
|
1228
|
+
'session-mode: fork is not supported by this extension. Use "standalone" or "lineage-only".',
|
|
1229
|
+
);
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
const sessionDir = getDefaultSessionDirFor(targetCwdForSession, effectiveAgentDir);
|
|
1233
|
+
|
|
1234
|
+
// Generate a deterministic session file path for this subagent.
|
|
1235
|
+
// This eliminates race conditions when multiple agents launch simultaneously —
|
|
1236
|
+
// each agent knows exactly which file is theirs.
|
|
1237
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 23) + "Z";
|
|
1238
|
+
const uuid = [
|
|
1239
|
+
id,
|
|
1240
|
+
Math.random().toString(16).slice(2, 10),
|
|
1241
|
+
Math.random().toString(16).slice(2, 10),
|
|
1242
|
+
Math.random().toString(16).slice(2, 6),
|
|
1243
|
+
].join("-");
|
|
1244
|
+
const subagentSessionFile = join(sessionDir, `${timestamp}_${uuid}.jsonl`);
|
|
1245
|
+
|
|
1246
|
+
// Use pre-created surface (parallel mode) or create a new one.
|
|
1247
|
+
// For new surfaces, pause briefly so the shell is ready before sending the command.
|
|
1248
|
+
const surfacePreCreated = !!options?.surface;
|
|
1249
|
+
// 与 steer/resume 的寻址规则一致:入口处统一 normalize,保证 pane、widget、
|
|
1250
|
+
// registry 与运行态 map 里的名称同源(幂等,已干净的名称不变)。
|
|
1251
|
+
const surfaceName = normalizeSubagentName(params.name ?? params.agent ?? "subagent");
|
|
1252
|
+
const activityFile = getSubagentActivityFile(artifactDir, id);
|
|
1253
|
+
mkdirSync(dirname(activityFile), { recursive: true });
|
|
1254
|
+
// An agent with a non-empty subagent_agents list is granted the spawning
|
|
1255
|
+
// toolset and may only spawn the listed agents (enforced via PI_SUBAGENT_ALLOWED).
|
|
1256
|
+
const grantSpawning = !!(agentDefs?.subagentAgents && agentDefs.subagentAgents.length > 0);
|
|
1257
|
+
// Resolve the config dir the child sees: a target-local .pi/agent/ wins,
|
|
1258
|
+
// else the propagated global dir. Captured once so the launch env and the
|
|
1259
|
+
// resume snapshot agree.
|
|
1260
|
+
const resolvedAgentDir =
|
|
1261
|
+
localAgentDir && existsSync(localAgentDir)
|
|
1262
|
+
? localAgentDir
|
|
1263
|
+
: process.env.PI_CODING_AGENT_DIR ?? null;
|
|
1264
|
+
// herdr 版:环境变量在 split 时注入(pane split --env),因此 splitEnv 的构造
|
|
1265
|
+
// 必须在 createSurface 之前;pane id 相关变量不再注入(子进程自带 HERDR_PANE_ID)。
|
|
1266
|
+
// (activityFile / grantSpawning / resolvedAgentDir 的声明相应提前到此处。)
|
|
1267
|
+
const splitEnv: Record<string, string> = createSubagentLaunchEnv();
|
|
1268
|
+
if (resolvedAgentDir) {
|
|
1269
|
+
splitEnv.PI_CODING_AGENT_DIR = resolvedAgentDir;
|
|
1270
|
+
}
|
|
1271
|
+
if (grantSpawning && agentDefs?.subagentAgents) {
|
|
1272
|
+
splitEnv.PI_SUBAGENT_ALLOWED = agentDefs.subagentAgents.join(",");
|
|
1273
|
+
}
|
|
1274
|
+
splitEnv.PI_SUBAGENT_NAME = surfaceName;
|
|
1275
|
+
if (params.agent) {
|
|
1276
|
+
splitEnv.PI_SUBAGENT_AGENT = params.agent;
|
|
1277
|
+
}
|
|
1278
|
+
if (effectiveAutoExit && !memberMode) {
|
|
1279
|
+
splitEnv.PI_SUBAGENT_AUTO_EXIT = "1";
|
|
1280
|
+
}
|
|
1281
|
+
// Automatic subagent tool calls are hard barriers. If such a child needs
|
|
1282
|
+
// input, subagent-done converts ask_question into a durable checkpoint and
|
|
1283
|
+
// exits so the parent can resume it; leaving the old live-parking behavior
|
|
1284
|
+
// here would deadlock the blocking tool call.
|
|
1285
|
+
// member 例外:无 barrier——ask_question 保持可回复的实时等待(成员常驻,
|
|
1286
|
+
// 父侧 watcher 轮询 .ask 并即时投递)。
|
|
1287
|
+
if (!effectiveInteractive && !memberMode) {
|
|
1288
|
+
splitEnv.PI_SUBAGENT_BARRIER = "1";
|
|
1289
|
+
}
|
|
1290
|
+
if (memberMode) {
|
|
1291
|
+
// 成员身份与团队协议目录:team_send 只写 mailbox,roster 父侧独写。
|
|
1292
|
+
splitEnv.PI_SUBAGENT_MEMBER = "1";
|
|
1293
|
+
splitEnv.PI_SUBAGENT_TEAM_DIR = artifactDir;
|
|
1294
|
+
if (agentDefs?.peerSend?.length) {
|
|
1295
|
+
splitEnv.PI_SUBAGENT_PEER_SEND = agentDefs.peerSend.join(",");
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
splitEnv.PI_SUBAGENT_SESSION = subagentSessionFile;
|
|
1299
|
+
splitEnv.PI_SUBAGENT_ID = id;
|
|
1300
|
+
splitEnv.PI_SUBAGENT_ACTIVITY_FILE = activityFile;
|
|
1301
|
+
if (launchBehavior.seededSessionMode) {
|
|
1302
|
+
seedSubagentSessionFile({
|
|
1303
|
+
mode: launchBehavior.seededSessionMode,
|
|
1304
|
+
parentSessionFile: sessionFile,
|
|
1305
|
+
childSessionFile: subagentSessionFile,
|
|
1306
|
+
childCwd: targetCwdForSession,
|
|
1307
|
+
});
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
const { inheritsConversationContext } = launchBehavior;
|
|
1311
|
+
|
|
1312
|
+
// Build the task message
|
|
1313
|
+
// Only full-context fork mode inherits prior conversation state.
|
|
1314
|
+
// Blank-session modes need the wrapper instructions and artifact-backed handoff.
|
|
1315
|
+
const modeHint = memberMode
|
|
1316
|
+
? "You are a PERSISTENT TEAM MEMBER running your CURRENT round. Complete this round autonomously, then simply stop — your process stays alive for the next dispatched round (never call shutdown yourself). Follow-up work arrives as new messages; teammate fire-and-forget notes arrive prefixed with [team message from …]."
|
|
1317
|
+
: effectiveAutoExit
|
|
1318
|
+
? "Complete your task autonomously. When you are finished, simply stop — your session ends automatically."
|
|
1319
|
+
: "Complete your task. The user can interact with you at any time, and the session ends when the user exits the pane.";
|
|
1320
|
+
const summaryInstruction = memberMode
|
|
1321
|
+
? "Your FINAL assistant message of THIS round is delivered to the orchestrator as the round's result — make it a complete, self-contained summary."
|
|
1322
|
+
: effectiveAutoExit
|
|
1323
|
+
? "Your FINAL assistant message should summarize what you accomplished."
|
|
1324
|
+
: "Your FINAL assistant message (before the user exits) should summarize what you accomplished.";
|
|
1325
|
+
const identity = agentDefs?.body ?? null;
|
|
1326
|
+
const systemPromptMode = agentDefs?.systemPromptMode;
|
|
1327
|
+
const identityInSystemPrompt = systemPromptMode && identity;
|
|
1328
|
+
const roleBlock = identity && !identityInSystemPrompt ? `\n\n${identity}` : "";
|
|
1329
|
+
const fullTask = inheritsConversationContext
|
|
1330
|
+
? params.task
|
|
1331
|
+
: `${roleBlock}\n\n${modeHint}\n\n${params.task}\n\n${summaryInstruction}`;
|
|
1332
|
+
// ── Pi CLI path ──
|
|
1333
|
+
|
|
1334
|
+
// Build pi command
|
|
1335
|
+
// pane 路径参数要过 pane 内 shell,统一 shellEscape;headless 路径用纯 argv
|
|
1336
|
+
// 直传子进程,同一数组但 esc 为恒等——两条路径共用同一参数构造,不漂移。
|
|
1337
|
+
const esc = useHeadless ? (value: string) => value : shellEscape;
|
|
1338
|
+
const parts: string[] = ["pi"];
|
|
1339
|
+
parts.push("--session", esc(subagentSessionFile));
|
|
1340
|
+
|
|
1341
|
+
// Load subagent-done extension so the agent can self-terminate if needed
|
|
1342
|
+
const subagentDonePath = join(SUBAGENTS_DIR, "subagent-done.ts");
|
|
1343
|
+
parts.push("-e", esc(subagentDonePath));
|
|
1344
|
+
|
|
1345
|
+
// Keep the profile's tool allowlist while allowing Pi to discover the same
|
|
1346
|
+
// configured extensions as the parent. The loadout still records the
|
|
1347
|
+
// resolved tool policy so resume does not widen callable tools.
|
|
1348
|
+
// member 注入 team_send(profile 不能靠 tools 自授,同 spawning 的授权模型)。
|
|
1349
|
+
const toolAllowlist = buildSubagentToolAllowlist(effectiveTools, {
|
|
1350
|
+
grantSpawning,
|
|
1351
|
+
...(memberMode ? { grantTeamSend: true } : {}),
|
|
1352
|
+
});
|
|
1353
|
+
|
|
1354
|
+
// Snapshot the fully-resolved sandbox beside the session file so a later
|
|
1355
|
+
// `subagent_message({ name })` resume can replay the same tool policy,
|
|
1356
|
+
// model, identity, and spawn permissions.
|
|
1357
|
+
const loadout: SubagentLoadout = {
|
|
1358
|
+
snapshotVersion: SUBAGENT_LOADOUT_VERSION,
|
|
1359
|
+
agent: params.agent ?? null,
|
|
1360
|
+
toolAllowlist,
|
|
1361
|
+
model: effectiveModel ?? null,
|
|
1362
|
+
thinking: agentDefs?.thinking ?? null,
|
|
1363
|
+
thinkingOverride: params.thinking ?? null,
|
|
1364
|
+
tier: tierResolution.tier,
|
|
1365
|
+
...(cohortId ? { cohortId } : {}),
|
|
1366
|
+
systemPromptMode: systemPromptMode ?? null,
|
|
1367
|
+
identity: identityInSystemPrompt ? identity : null,
|
|
1368
|
+
spawnable: agentDefs?.subagentAgents ?? null,
|
|
1369
|
+
autoExit: memberMode ? false : effectiveAutoExit,
|
|
1370
|
+
...(memberMode ? { member: true } : {}),
|
|
1371
|
+
// Store the actual cwd used by the child. For an omitted `cwd`, this is
|
|
1372
|
+
// the parent session cwd rather than the extension host's process.cwd();
|
|
1373
|
+
// resume/headless launches must reproduce that directory exactly.
|
|
1374
|
+
cwd: targetCwdForSession,
|
|
1375
|
+
agentDir: resolvedAgentDir,
|
|
1376
|
+
};
|
|
1377
|
+
writeSubagentLoadout(subagentSessionFile, loadout);
|
|
1378
|
+
// 父侧锚定副本:resume 授权的单一真源(见 session.ts AnchoredSubagentLoadout)。
|
|
1379
|
+
// 写失败不阻断本次 spawn;但新版快照带强制锚定标记,后续 resume 会拒绝
|
|
1380
|
+
// 缺失的父侧副本,不会静默退回 legacy;路径计入 contextFiles,随 retention
|
|
1381
|
+
// 清理一起回收。
|
|
1382
|
+
let anchoredLoadoutFile: string | null = null;
|
|
1383
|
+
try {
|
|
1384
|
+
anchoredLoadoutFile = writeAnchoredLoadout(artifactDir, subagentSessionFile, loadout);
|
|
1385
|
+
} catch (error) {
|
|
1386
|
+
debugLog(`Could not persist anchored loadout for ${surfaceName}`, error);
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
// Apply model, identity, and the tool policy via the shared helper (same
|
|
1390
|
+
// code path resume uses — they can't drift).
|
|
1391
|
+
// 返回值是写入 context/ 的文件路径,记入 running.contextFiles 供 retention
|
|
1392
|
+
// 清理;resume 路径不消费返回值。
|
|
1393
|
+
const contextFiles: string[] = applySandboxToParts(
|
|
1394
|
+
parts,
|
|
1395
|
+
loadout,
|
|
1396
|
+
{ artifactDir, name: surfaceName },
|
|
1397
|
+
{ escape: esc },
|
|
1398
|
+
);
|
|
1399
|
+
if (anchoredLoadoutFile) contextFiles.push(anchoredLoadoutFile);
|
|
1400
|
+
|
|
1401
|
+
// applySandboxToParts(parts, loadout, ...);
|
|
1402
|
+
// 环境变量已在 createSurface 时经 pane split --env 注入(splitEnv),不再拼 shell 前缀;
|
|
1403
|
+
// PI_SUBAGENT_SURFACE 不再注入:子进程在 herdr pane 内,自带 HERDR_PANE_ID。
|
|
1404
|
+
|
|
1405
|
+
// Pass task and skill prompts to the sub-agent.
|
|
1406
|
+
// Only full-context fork mode gets a direct task argument because it already
|
|
1407
|
+
// inherits the parent conversation. Blank-session modes use artifact-backed
|
|
1408
|
+
// handoff so the wrapper instructions arrive as the initial user message.
|
|
1409
|
+
// RPC/headless mode cannot accept positional file arguments, so the prompt
|
|
1410
|
+
// arguments are kept separate from the base argv and are delivered over
|
|
1411
|
+
// stdin below.
|
|
1412
|
+
const basePartsLength = parts.length;
|
|
1413
|
+
let taskArg: string;
|
|
1414
|
+
if (launchBehavior.taskDelivery === "direct") {
|
|
1415
|
+
taskArg = fullTask;
|
|
1416
|
+
} else {
|
|
1417
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
|
1418
|
+
const safeName = sanitizeSubagentFileName(surfaceName);
|
|
1419
|
+
const artifactName = `context/${safeName || "subagent"}-${timestamp}.md`;
|
|
1420
|
+
const artifactPath = join(artifactDir, artifactName);
|
|
1421
|
+
mkdirSync(dirname(artifactPath), { recursive: true });
|
|
1422
|
+
writeFileSync(artifactPath, fullTask, "utf8");
|
|
1423
|
+
contextFiles.push(artifactPath);
|
|
1424
|
+
taskArg = `@${artifactPath}`;
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
for (const promptArg of buildPiPromptArgs({
|
|
1428
|
+
effectiveSkills,
|
|
1429
|
+
taskDelivery: launchBehavior.taskDelivery,
|
|
1430
|
+
taskArg,
|
|
1431
|
+
})) {
|
|
1432
|
+
parts.push(esc(promptArg));
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
// 创建 pane / 启动 headless 进程延后到所有 session、artifact 和启动参数准备
|
|
1436
|
+
// 完成之后,避免准备阶段失败时留下无法管理的孤儿 pane 或孤儿进程。
|
|
1437
|
+
// 持久成员首轮关联 nonce:初始 prompt 带 marker,首轮 .round 的关联判定
|
|
1438
|
+
// 与后续 team_dispatch 轮同一套语义(见 watchMemberRound)。
|
|
1439
|
+
const memberFirstRoundId = memberMode ? randomUUID() : null;
|
|
1440
|
+
let child: HeadlessChild | null = null;
|
|
1441
|
+
let surface: string;
|
|
1442
|
+
if (useHeadless) {
|
|
1443
|
+
// RPC argv:参数数组直传子进程,不经 shell拼接;初始任务经 stdin prompt
|
|
1444
|
+
// 投递(下方),不进 argv。--mode rpc 必须在最前。
|
|
1445
|
+
// Do not pass buildPiPromptArgs() output here: RPC mode rejects positional
|
|
1446
|
+
// file arguments (including the pane path's @artifact.md handoff). The
|
|
1447
|
+
// complete task and skill prompts are sent as the first stdin prompt below.
|
|
1448
|
+
const rpcArgs = ["--mode", "rpc", ...parts.slice(1, basePartsLength)];
|
|
1449
|
+
const initialPromptId = `initial-${id}`;
|
|
1450
|
+
let initialPromptPending = true;
|
|
1451
|
+
child = spawnHeadlessPi(
|
|
1452
|
+
{ args: rpcArgs, cwd: targetCwdForSession, env: splitEnv },
|
|
1453
|
+
(event) => {
|
|
1454
|
+
// Only the first prompt is a launch preflight. A later prompt may
|
|
1455
|
+
// legitimately fail after a steer/reply and must not make the watcher
|
|
1456
|
+
// kill an otherwise live child with a misleading "initial" error.
|
|
1457
|
+
if (
|
|
1458
|
+
initialPromptPending &&
|
|
1459
|
+
event.type === "response" &&
|
|
1460
|
+
event.command === "prompt" &&
|
|
1461
|
+
(event.id === initialPromptId || event.id == null)
|
|
1462
|
+
) {
|
|
1463
|
+
initialPromptPending = false;
|
|
1464
|
+
if (event.success === false) {
|
|
1465
|
+
child!.promptError = typeof event.error === "string" && event.error
|
|
1466
|
+
? event.error
|
|
1467
|
+
: "sub-agent rejected its initial prompt";
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
},
|
|
1471
|
+
);
|
|
1472
|
+
if (child.pid == null) {
|
|
1473
|
+
child.kill();
|
|
1474
|
+
throw new Error("Failed to spawn headless sub-agent process (check PI_SUBAGENT_PI_ENTRY / pi installation).");
|
|
1475
|
+
}
|
|
1476
|
+
surface = formatHeadlessSurface(child.pid);
|
|
1477
|
+
// headless 初始 prompt:skill 前缀 + 任务全文直接经 stdin 投递。
|
|
1478
|
+
// artifact 文件仍写盘(与 pane 路径一致的 handoff 审计),但 RPC prompt
|
|
1479
|
+
// 不再用 @file 引用——stdin 无 shell 转义问题,发全文更可靠。
|
|
1480
|
+
child.send({
|
|
1481
|
+
id: initialPromptId,
|
|
1482
|
+
type: "prompt",
|
|
1483
|
+
message: memberFirstRoundId
|
|
1484
|
+
? buildTeamRoundPrompt(memberFirstRoundId, buildHeadlessPrompt(effectiveSkills, fullTask))
|
|
1485
|
+
: buildHeadlessPrompt(effectiveSkills, fullTask),
|
|
1486
|
+
});
|
|
1487
|
+
} else {
|
|
1488
|
+
surface = options?.surface ?? createSurface(surfaceName, { cwd: effectiveCwd ?? undefined, env: splitEnv });
|
|
1489
|
+
if (!surfacePreCreated) {
|
|
1490
|
+
await new Promise<void>((resolve) => setTimeout(resolve, getShellReadyDelayMs()));
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
// herdr:整行命令直发 pane 内 shell。任务与身份都走文件参数,命令行仅含
|
|
1494
|
+
// 固定 flag 与路径,长度可控;cwd 已由 pane split --cwd 设定。sentinel 由
|
|
1495
|
+
// surface.ts 按 pane shell 语法生成(Windows/herdr=PowerShell $LASTEXITCODE,
|
|
1496
|
+
// POSIX=tmux 及 herdr on Linux/macOS 用 $?)。
|
|
1497
|
+
const command = `${parts.join(" ")}${sentinelSuffix(sentinelToken)}`;
|
|
1498
|
+
try {
|
|
1499
|
+
sendCommand(surface, command);
|
|
1500
|
+
} catch (err) {
|
|
1501
|
+
// 命令未能送达:pane 留着只会成为孤儿,关闭并上抛
|
|
1502
|
+
try { closeSurface(surface); } catch (closeError) {
|
|
1503
|
+
debugLog(`Could not close failed launch pane ${surface}`, closeError);
|
|
1504
|
+
}
|
|
1505
|
+
throw err;
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
const running: RunningSubagent = {
|
|
1510
|
+
id,
|
|
1511
|
+
name: surfaceName,
|
|
1512
|
+
task: params.task,
|
|
1513
|
+
...(cohortId ? { cohortId } : {}),
|
|
1514
|
+
agent: params.agent,
|
|
1515
|
+
model: effectiveModel ?? null,
|
|
1516
|
+
parentId: process.env.PI_SUBAGENT_ID ?? null,
|
|
1517
|
+
...(params.timeoutMs != null ? { timeoutMs: params.timeoutMs } : {}),
|
|
1518
|
+
waitMode: memberMode ? "member-round" : effectiveInteractive ? "interactive" : "hard-barrier",
|
|
1519
|
+
surface,
|
|
1520
|
+
startTime,
|
|
1521
|
+
sessionFile: subagentSessionFile,
|
|
1522
|
+
activityFile,
|
|
1523
|
+
interactive: effectiveInteractive,
|
|
1524
|
+
sentinelToken,
|
|
1525
|
+
runtimeFile: runtimeRegistryPath(artifactDir),
|
|
1526
|
+
statusState: createStatusState({
|
|
1527
|
+
source: "pi",
|
|
1528
|
+
startTimeMs: startTime,
|
|
1529
|
+
}),
|
|
1530
|
+
kind: useHeadless ? "headless" : "pane",
|
|
1531
|
+
...(anchoredLoadoutFile ? { anchoredLoadout: true } : {}),
|
|
1532
|
+
contextFiles,
|
|
1533
|
+
...(memberMode ? { member: true, rosterFile: rosterPath(artifactDir), dispatchedRoundId: memberFirstRoundId! } : {}),
|
|
1534
|
+
...(child && child.pid != null ? { pid: child.pid, headlessChild: child } : {}),
|
|
1535
|
+
};
|
|
1536
|
+
|
|
1537
|
+
runningSubagents.set(id, running);
|
|
1538
|
+
try {
|
|
1539
|
+
upsertRuntimeRecord(running.runtimeFile, createRuntimeRecord(running));
|
|
1540
|
+
} catch (error) {
|
|
1541
|
+
// 运行态记录失败不应撤销已经启动的子代理;watcher 仍负责当前进程。
|
|
1542
|
+
debugLog(`Could not persist runtime record for ${running.name}`, error);
|
|
1543
|
+
}
|
|
1544
|
+
// member 名册登记:首轮即第一个在途轮次。父进程是 roster 唯一写者。
|
|
1545
|
+
if (memberMode) {
|
|
1546
|
+
try {
|
|
1547
|
+
upsertRosterMember(running.rosterFile!, {
|
|
1548
|
+
name: running.name,
|
|
1549
|
+
...(running.agent ? { agent: running.agent } : {}),
|
|
1550
|
+
sessionFile: running.sessionFile,
|
|
1551
|
+
...(running.pid != null ? { pid: running.pid } : {}),
|
|
1552
|
+
status: "dispatched",
|
|
1553
|
+
dispatchedAt: Date.now(),
|
|
1554
|
+
});
|
|
1555
|
+
} catch (error) {
|
|
1556
|
+
debugLog(`Could not register team member ${running.name} in roster`, error);
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
return running;
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
/**
|
|
1563
|
+
* Watch a launched subagent until it exits. Polls for completion, extracts
|
|
1564
|
+
* the summary from the session file, cleans up the surface,
|
|
1565
|
+
* and removes the entry from runningSubagents.
|
|
1566
|
+
*/
|
|
1567
|
+
|
|
1568
|
+
/**
|
|
1569
|
+
* Detect an `ask_question` signal from a still-running subagent and notify the
|
|
1570
|
+
* orchestrator without ending the subagent. Each subagent has its own
|
|
1571
|
+
* `${sessionFile}.ask` file and its own watcher, so parallel questions from
|
|
1572
|
+
* multiple subagents are delivered independently. The file is deleted after
|
|
1573
|
+
* delivery so it fires once per question (a subagent may ask again later).
|
|
1574
|
+
*/
|
|
1575
|
+
interface PendingQuestion {
|
|
1576
|
+
question?: unknown;
|
|
1577
|
+
name?: unknown;
|
|
1578
|
+
agent?: unknown;
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
// ── 持久团队成员的父侧生命周期 ─────────────────────────────────────
|
|
1582
|
+
|
|
1583
|
+
/** 终止成员进程(有句柄用句柄,否则按 PID 树杀;Windows 兼容 killHeadlessProcessTree)。 */
|
|
1584
|
+
function killMemberProcess(running: RunningSubagent): void {
|
|
1585
|
+
const child = running.headlessChild;
|
|
1586
|
+
try {
|
|
1587
|
+
if (child) {
|
|
1588
|
+
if (!child.exited) child.abort();
|
|
1589
|
+
child.kill();
|
|
1590
|
+
} else if (running.pid != null) {
|
|
1591
|
+
terminateHeadlessProcess(running.pid);
|
|
1592
|
+
}
|
|
1593
|
+
} catch (error) {
|
|
1594
|
+
debugLog(`Could not terminate team member ${running.name}`, error);
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
/** 同名重建判定:仅当名字属于 roster 中 offline 的成员且当前无同名运行中/
|
|
1599
|
+
* 保留中的子代理时,member spawn 可安全复用该名字(覆盖 registry 登记,
|
|
1600
|
+
* 与 team_dispatch/subagent_stop 的重启指引一致;旧 session 文件仍在磁盘,
|
|
1601
|
+
* 但不再按该名字寻址)。非 member spawn 一律不复用。 */
|
|
1602
|
+
function canReuseMemberName(name: string, artifactDir: string): boolean {
|
|
1603
|
+
const normalized = normalizeSubagentName(name, "");
|
|
1604
|
+
if (!normalized) return false;
|
|
1605
|
+
try {
|
|
1606
|
+
const roster = findRosterMember(rosterPath(artifactDir), normalized);
|
|
1607
|
+
if (!roster || roster.status !== "offline") return false;
|
|
1608
|
+
} catch (error) {
|
|
1609
|
+
debugLog(`Could not read team roster for member name reuse of ${normalized}`, error);
|
|
1610
|
+
return false;
|
|
1611
|
+
}
|
|
1612
|
+
if (Array.from(runningSubagents.values()).some((running) => running.name === normalized)) {
|
|
1613
|
+
return false;
|
|
1614
|
+
}
|
|
1615
|
+
if (reservedNames.has(normalized)) return false;
|
|
1616
|
+
return true;
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
/** roster 标记 offline(幂等:重复 upsert 同一 offline 状态无害)。 */
|
|
1620
|
+
function markMemberOffline(running: RunningSubagent, reason: string): void {
|
|
1621
|
+
if (!running.rosterFile) return;
|
|
1622
|
+
try {
|
|
1623
|
+
upsertRosterMember(running.rosterFile, {
|
|
1624
|
+
name: running.name,
|
|
1625
|
+
...(running.agent ? { agent: running.agent } : {}),
|
|
1626
|
+
sessionFile: running.sessionFile,
|
|
1627
|
+
...(running.pid != null ? { pid: running.pid } : {}),
|
|
1628
|
+
status: "offline",
|
|
1629
|
+
offlineReason: reason,
|
|
1630
|
+
});
|
|
1631
|
+
} catch (error) {
|
|
1632
|
+
debugLog(`Could not mark team member ${running.name} offline`, error);
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
/** 认领并注入成员间消息(fire-and-forget;注入文案带来源,不可冒名)。 */
|
|
1637
|
+
function drainMemberMailbox(running: RunningSubagent): void {
|
|
1638
|
+
if (!running.rosterFile) return;
|
|
1639
|
+
const teamDir = dirname(running.rosterFile);
|
|
1640
|
+
const child = running.headlessChild;
|
|
1641
|
+
if (!child || child.exited) return;
|
|
1642
|
+
for (const message of claimTeamMessages(teamDir, running.name)) {
|
|
1643
|
+
// 防御:mailbox 目录名即目标;to 与成员名不符的残留文件直接丢弃。
|
|
1644
|
+
if (normalizeSubagentName(message.to, "") !== normalizeSubagentName(running.name, "")) continue;
|
|
1645
|
+
try {
|
|
1646
|
+
child.steer(`[team message from "${message.from}"] ${message.text}`);
|
|
1647
|
+
} catch (error) {
|
|
1648
|
+
debugLog(`Could not inject team message to ${running.name}`, error);
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
/** 轮次终态回注恰好一次(由 .round 认领/进程死亡互斥保证)。 */async function deliverMemberRoundResult(
|
|
1654
|
+
running: RunningSubagent,
|
|
1655
|
+
outcome: { exitCode: number; errorMessage?: string; roundSeq?: number },
|
|
1656
|
+
): Promise<void> {
|
|
1657
|
+
const pi = latestPi;
|
|
1658
|
+
if (!pi) return;
|
|
1659
|
+
const elapsed = Math.floor((Date.now() - running.startTime) / 1000);
|
|
1660
|
+
let summary = "";
|
|
1661
|
+
let stats: NonNullable<SubagentResult["stats"]> | undefined;
|
|
1662
|
+
let sessionId: string | null = null;
|
|
1663
|
+
try {
|
|
1664
|
+
const extracted = await extractSubagentResult(
|
|
1665
|
+
running.sessionFile,
|
|
1666
|
+
{ exitCode: outcome.exitCode, ...(outcome.errorMessage ? { errorMessage: outcome.errorMessage } : {}) },
|
|
1667
|
+
running.roundEntryBaseline ?? 0,
|
|
1668
|
+
"Team member round",
|
|
1669
|
+
);
|
|
1670
|
+
summary = extracted.summary;
|
|
1671
|
+
stats = extracted.stats ?? undefined;
|
|
1672
|
+
sessionId = extracted.sessionId;
|
|
1673
|
+
} catch (error) {
|
|
1674
|
+
debugLog(`Could not extract team member round result for ${running.name}`, error);
|
|
1675
|
+
summary = outcome.errorMessage ?? `Team member "${running.name}" finished its round (no extractable summary).`;
|
|
1676
|
+
}
|
|
1677
|
+
const failed = outcome.exitCode !== 0 || !!outcome.errorMessage;
|
|
1678
|
+
const routeException = failed ? routeExceptionFromResult({ errorMessage: outcome.errorMessage }, running.model) : undefined;
|
|
1679
|
+
pi.sendMessage(
|
|
1680
|
+
{
|
|
1681
|
+
customType: "subagent_result",
|
|
1682
|
+
content:
|
|
1683
|
+
`Team member "${running.name}" finished a round:\n\n${summary}`,
|
|
1684
|
+
display: true,
|
|
1685
|
+
details: {
|
|
1686
|
+
name: running.name,
|
|
1687
|
+
task: "(team member round)",
|
|
1688
|
+
agent: running.agent,
|
|
1689
|
+
exitCode: outcome.exitCode,
|
|
1690
|
+
elapsed,
|
|
1691
|
+
sessionFile: running.sessionFile,
|
|
1692
|
+
...(sessionId ? { sessionId } : {}),
|
|
1693
|
+
status: failed ? "failed" : "completed",
|
|
1694
|
+
roundDelivery: "member-round",
|
|
1695
|
+
...(outcome.roundSeq != null ? { roundSeq: outcome.roundSeq } : {}),
|
|
1696
|
+
...(outcome.errorMessage ? { errorMessage: outcome.errorMessage } : {}),
|
|
1697
|
+
...(stats ? { stats } : {}),
|
|
1698
|
+
...(routeException ? { routeException } : {}),
|
|
1699
|
+
...(running.cohortId ? { cohortId: running.cohortId } : {}),
|
|
1700
|
+
},
|
|
1701
|
+
},
|
|
1702
|
+
{ triggerTurn: true, deliverAs: "steer" },
|
|
1703
|
+
);
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
type DispatchMarkerAssociation = "matched" | "absent" | "unreadable";
|
|
1707
|
+
type MemberRoundConsumption = "delivered" | "not-associated" | "association-error";
|
|
1708
|
+
|
|
1709
|
+
/** 连续读不到会话 marker 时的最后兜底次数;到达后明确终止成员。 */
|
|
1710
|
+
const MAX_ROUND_ASSOCIATION_RETRIES = 3;
|
|
1711
|
+
|
|
1712
|
+
/**
|
|
1713
|
+
* 读取派单 marker。将 JSON 解析失败与“没有 marker”分开,避免 atomic
|
|
1714
|
+
* claim 后把暂时不可读的 .round 当成自发轮而永久留下 busy 状态。
|
|
1715
|
+
*/
|
|
1716
|
+
function readDispatchMarkerAssociation(
|
|
1717
|
+
sessionFile: string,
|
|
1718
|
+
baseline: number,
|
|
1719
|
+
roundId: string,
|
|
1720
|
+
): DispatchMarkerAssociation {
|
|
1721
|
+
const marker = teamRoundMarker(roundId);
|
|
1722
|
+
try {
|
|
1723
|
+
return getNewEntries(sessionFile, baseline).some((entry) => {
|
|
1724
|
+
if (entry.type !== "message") return false;
|
|
1725
|
+
const msg = (entry as MessageEntry).message;
|
|
1726
|
+
if (!msg || msg.role !== "user") return false;
|
|
1727
|
+
return (msg.content ?? []).some(
|
|
1728
|
+
(block) => typeof block.text === "string" && block.text.includes(marker),
|
|
1729
|
+
);
|
|
1730
|
+
}) ? "matched" : "absent";
|
|
1731
|
+
} catch (error) {
|
|
1732
|
+
debugLog(`Could not read session entries for round association of ${sessionFile}`, error);
|
|
1733
|
+
return "unreadable";
|
|
1734
|
+
}
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
/** 成员启动/轮次关联失败时统一清理并回注明确失败,不留下永久 busy。 */
|
|
1738
|
+
async function finalizeMemberFailure(
|
|
1739
|
+
running: RunningSubagent,
|
|
1740
|
+
reason: string,
|
|
1741
|
+
errorMessage: string,
|
|
1742
|
+
roundSeq?: number,
|
|
1743
|
+
): Promise<void> {
|
|
1744
|
+
const hadRound = running.dispatchedRound === true;
|
|
1745
|
+
const baseline = running.roundEntryBaseline;
|
|
1746
|
+
running.dispatchedRound = false;
|
|
1747
|
+
running.dispatchedRoundId = undefined;
|
|
1748
|
+
running.pendingRoundSignal = undefined;
|
|
1749
|
+
running.roundAssociationAttempts = 0;
|
|
1750
|
+
killMemberProcess(running);
|
|
1751
|
+
markMemberOffline(running, reason);
|
|
1752
|
+
runningSubagents.delete(running.id);
|
|
1753
|
+
removeRuntimeRecord(running.runtimeFile, running.id);
|
|
1754
|
+
settleCompletionFromResult(running.name, {
|
|
1755
|
+
exitCode: 1,
|
|
1756
|
+
errorMessage,
|
|
1757
|
+
sessionFile: running.sessionFile,
|
|
1758
|
+
});
|
|
1759
|
+
updateWidget();
|
|
1760
|
+
if (hadRound) {
|
|
1761
|
+
running.roundEntryBaseline = baseline;
|
|
1762
|
+
await deliverMemberRoundResult(running, {
|
|
1763
|
+
exitCode: 1,
|
|
1764
|
+
errorMessage,
|
|
1765
|
+
...(roundSeq != null ? { roundSeq } : {}),
|
|
1766
|
+
});
|
|
1767
|
+
}
|
|
1768
|
+
running.roundEntryBaseline = undefined;
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
/** 自 baseline 起的会话用户消息里是否出现过该派单轮的 marker(轮次关联判定)。
|
|
1772
|
+
*
|
|
1773
|
+
* 时序不变量:成员在 agent_end 写 .round 时,本轮的用户消息(含派单
|
|
1774
|
+
* marker)已落盘——run 处理过派单消息才会结束。因此 marker 缺失只可能是
|
|
1775
|
+
* “派单 prompt 仍在队列里,这个 .round 属于自发轮”(消费但不回注),
|
|
1776
|
+
* 不存在“派单轮结束而 marker 尚未落盘”的窗口。
|
|
1777
|
+
*/
|
|
1778
|
+
function dispatchMarkerInSession(sessionFile: string, baseline: number, roundId: string): boolean {
|
|
1779
|
+
return readDispatchMarkerAssociation(sessionFile, baseline, roundId) === "matched";
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
/** 认领一个已落盘的派单轮结果;非当前派单轮的信号只消费、不回注。 */
|
|
1783
|
+
async function consumeDispatchedMemberRound(
|
|
1784
|
+
running: RunningSubagent,
|
|
1785
|
+
round: { seq: number },
|
|
1786
|
+
): Promise<MemberRoundConsumption> {
|
|
1787
|
+
if (!running.dispatchedRound) return "not-associated";
|
|
1788
|
+
// 轮次关联(nonce marker):交错的成员自发轮不能误清当前派单状态。
|
|
1789
|
+
const association = running.dispatchedRoundId
|
|
1790
|
+
? readDispatchMarkerAssociation(
|
|
1791
|
+
running.sessionFile,
|
|
1792
|
+
running.roundEntryBaseline ?? 0,
|
|
1793
|
+
running.dispatchedRoundId,
|
|
1794
|
+
)
|
|
1795
|
+
: "matched" as const;
|
|
1796
|
+
if (association === "unreadable") return "association-error";
|
|
1797
|
+
if (association === "absent") return "not-associated";
|
|
1798
|
+
|
|
1799
|
+
running.dispatchedRound = false;
|
|
1800
|
+
running.dispatchedRoundId = undefined;
|
|
1801
|
+
if (running.rosterFile) {
|
|
1802
|
+
try {
|
|
1803
|
+
upsertRosterMember(running.rosterFile, {
|
|
1804
|
+
name: running.name,
|
|
1805
|
+
...(running.agent ? { agent: running.agent } : {}),
|
|
1806
|
+
sessionFile: running.sessionFile,
|
|
1807
|
+
...(running.pid != null ? { pid: running.pid } : {}),
|
|
1808
|
+
status: "idle",
|
|
1809
|
+
lastRoundAt: Date.now(),
|
|
1810
|
+
});
|
|
1811
|
+
} catch (error) {
|
|
1812
|
+
debugLog(`Could not mark team member ${running.name} idle`, error);
|
|
1813
|
+
}
|
|
1814
|
+
}
|
|
1815
|
+
await deliverMemberRoundResult(running, { exitCode: 0, roundSeq: round.seq });
|
|
1816
|
+
return "delivered";
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1819
|
+
/** 处理已认领的轮次信号:短暂读失败时保留信号,最终明确失败。 */
|
|
1820
|
+
async function handleMemberRoundSignal(
|
|
1821
|
+
running: RunningSubagent,
|
|
1822
|
+
round: { name: string; seq: number; at: number },
|
|
1823
|
+
): Promise<"handled" | "retry" | "failed"> {
|
|
1824
|
+
const consumption = await consumeDispatchedMemberRound(running, round);
|
|
1825
|
+
if (consumption !== "association-error") {
|
|
1826
|
+
running.pendingRoundSignal = undefined;
|
|
1827
|
+
running.roundAssociationAttempts = 0;
|
|
1828
|
+
return "handled";
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
const attempts = (running.roundAssociationAttempts ?? 0) + 1;
|
|
1832
|
+
if (attempts < MAX_ROUND_ASSOCIATION_RETRIES) {
|
|
1833
|
+
running.pendingRoundSignal = round;
|
|
1834
|
+
running.roundAssociationAttempts = attempts;
|
|
1835
|
+
debugLog(
|
|
1836
|
+
`Deferring team member ${running.name} round ${round.seq} after unreadable session association ` +
|
|
1837
|
+
`(attempt ${attempts}/${MAX_ROUND_ASSOCIATION_RETRIES})`,
|
|
1838
|
+
);
|
|
1839
|
+
return "retry";
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
const errorMessage =
|
|
1843
|
+
`Could not reliably associate team member "${running.name}" round ${round.seq} with its ` +
|
|
1844
|
+
`dispatch marker because the session file could not be read. The member was taken offline ` +
|
|
1845
|
+
`instead of silently dropping the round; resume or respawn it if the work still matters.`;
|
|
1846
|
+
await finalizeMemberFailure(running, "round-association-failed", errorMessage, round.seq);
|
|
1847
|
+
return "failed";
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
/**
|
|
1851
|
+
* 成员 round watcher(fire-and-forget):常驻轮询 .ask/.round/mailbox 与进程
|
|
1852
|
+
* 存活。轮次结束仅在「有在途 dispatch 且 .round 确实属于该派单轮」时回注
|
|
1853
|
+
* 一次(idle 期的自发轮、与在途派单交错的自发轮都不回注、不打扰主 pane);
|
|
1854
|
+
* 进程死亡 → 先认领已落盘的 .round,再把仍未完成的在途轮次报错;模块替换
|
|
1855
|
+
* (/reload)→ 终止成员并降级 offline(stdin 无法重接,不留永活孤儿)。
|
|
1856
|
+
* watcher 由 running.abortController(subagent_stop/session_shutdown)终止,
|
|
1857
|
+
* 终止责任在各自入口(stop/shutdown 直接 kill+offline)。
|
|
1858
|
+
*/
|
|
1859
|
+
async function watchMemberRound(running: RunningSubagent, signal: AbortSignal): Promise<void> {
|
|
1860
|
+
const combined = AbortSignal.any([signal, getModuleAbortSignal()]);
|
|
1861
|
+
for (;;) {
|
|
1862
|
+
if (combined.aborted) return; // stop/session_shutdown 入口已负责 kill+offline
|
|
1863
|
+
const moduleWasReplaced = (globalThis as any)[MODULE_INSTANCE_KEY] !== moduleInstanceId;
|
|
1864
|
+
if (moduleWasReplaced) {
|
|
1865
|
+
killMemberProcess(running);
|
|
1866
|
+
markMemberOffline(running, "host-reload");
|
|
1867
|
+
runningSubagents.delete(running.id);
|
|
1868
|
+
removeRuntimeRecord(running.runtimeFile, running.id);
|
|
1869
|
+
return;
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
const child = running.headlessChild;
|
|
1873
|
+
if (child?.promptError) {
|
|
1874
|
+
const errorMessage =
|
|
1875
|
+
`Headless team member "${running.name}" rejected its prompt: ${child.promptError}`;
|
|
1876
|
+
await finalizeMemberFailure(running, "prompt-rejected", errorMessage);
|
|
1877
|
+
return;
|
|
1878
|
+
}
|
|
1879
|
+
const alive = child ? !child.exited : running.pid != null && isPidAlive(running.pid);
|
|
1880
|
+
if (!alive) {
|
|
1881
|
+
// 进程可能在写完 .round 与会话结果后才退出。若上一次 marker 读取
|
|
1882
|
+
// 正在重试,优先使用内存保留的信号,再认领新信号。
|
|
1883
|
+
const round = running.pendingRoundSignal ?? claimRoundSignal(running.sessionFile);
|
|
1884
|
+
if (round) {
|
|
1885
|
+
const consumption = await consumeDispatchedMemberRound(running, round);
|
|
1886
|
+
running.pendingRoundSignal = undefined;
|
|
1887
|
+
running.roundAssociationAttempts = 0;
|
|
1888
|
+
if (consumption === "association-error") {
|
|
1889
|
+
const errorMessage =
|
|
1890
|
+
`Could not reliably associate team member "${running.name}" round ${round.seq} with its ` +
|
|
1891
|
+
`dispatch marker because the session file could not be read. The member was taken offline ` +
|
|
1892
|
+
`instead of silently dropping the round; resume or respawn it if the work still matters.`;
|
|
1893
|
+
await finalizeMemberFailure(running, "round-association-failed", errorMessage, round.seq);
|
|
1894
|
+
return;
|
|
1895
|
+
}
|
|
1896
|
+
}
|
|
1897
|
+
const unfinishedRound = running.dispatchedRound === true;
|
|
1898
|
+
const baseline = running.roundEntryBaseline;
|
|
1899
|
+
running.dispatchedRound = false;
|
|
1900
|
+
running.dispatchedRoundId = undefined;
|
|
1901
|
+
running.pendingRoundSignal = undefined;
|
|
1902
|
+
running.roundAssociationAttempts = 0;
|
|
1903
|
+
|
|
1904
|
+
markMemberOffline(running, "process-exited");
|
|
1905
|
+
runningSubagents.delete(running.id);
|
|
1906
|
+
removeRuntimeRecord(running.runtimeFile, running.id);
|
|
1907
|
+
if (unfinishedRound) {
|
|
1908
|
+
running.roundEntryBaseline = baseline;
|
|
1909
|
+
await deliverMemberRoundResult(running, {
|
|
1910
|
+
exitCode: 1,
|
|
1911
|
+
errorMessage: `Team member "${running.name}" process exited before finishing its in-flight round. ` +
|
|
1912
|
+
`Its session is preserved; resume or respawn it if the work still matters.`,
|
|
1913
|
+
});
|
|
1914
|
+
}
|
|
1915
|
+
running.roundEntryBaseline = undefined;
|
|
1916
|
+
return;
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
deliverPendingQuestion(running);
|
|
1920
|
+
drainMemberMailbox(running);
|
|
1921
|
+
|
|
1922
|
+
// 读失败时保留已认领的信号并有限重试;超过上限转为明确 offline/失败,
|
|
1923
|
+
// 不把成员留在永久 busy 状态,也不静默吞掉真实轮次。
|
|
1924
|
+
const round = running.pendingRoundSignal ?? claimRoundSignal(running.sessionFile);
|
|
1925
|
+
if (round) {
|
|
1926
|
+
const outcome = await handleMemberRoundSignal(running, round);
|
|
1927
|
+
if (outcome === "failed") return;
|
|
1928
|
+
// 无在途 dispatch 的 .round(如成员间消息触发的自发轮):不回注、
|
|
1929
|
+
// 不打扰主 pane——成员间通信的结果属于成员自己的对话流。
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
await new Promise<void>((resolve, reject) => {
|
|
1933
|
+
if (combined.aborted) return reject(new Error("Aborted"));
|
|
1934
|
+
const timer = setTimeout(() => {
|
|
1935
|
+
combined.removeEventListener("abort", onAbort);
|
|
1936
|
+
resolve();
|
|
1937
|
+
}, 1000);
|
|
1938
|
+
function onAbort() {
|
|
1939
|
+
clearTimeout(timer);
|
|
1940
|
+
reject(new Error("Aborted"));
|
|
1941
|
+
}
|
|
1942
|
+
combined.addEventListener("abort", onAbort, { once: true });
|
|
1943
|
+
}).catch(() => {});
|
|
1944
|
+
if (combined.aborted) return;
|
|
1945
|
+
}
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
function deliverPendingQuestion(running: RunningSubagent): void {
|
|
1949
|
+
const askFile = `${running.sessionFile}.ask`;
|
|
1950
|
+
const claimedAskFile = `${askFile}.${process.pid}.${randomUUID()}.processing`;
|
|
1951
|
+
let payload: PendingQuestion | null = null;
|
|
1952
|
+
try {
|
|
1953
|
+
// Atomic rename claims the signal before reading it. If the host dies after
|
|
1954
|
+
// reading, the original .ask file is already gone and a newer signal can
|
|
1955
|
+
// still be written to the original path for the next poll.
|
|
1956
|
+
renameSync(askFile, claimedAskFile);
|
|
1957
|
+
payload = JSON.parse(readFileSync(claimedAskFile, "utf-8")) as PendingQuestion;
|
|
1958
|
+
} catch (error) {
|
|
1959
|
+
if (existsSync(askFile)) {
|
|
1960
|
+
// The writer may still be replacing the sidecar; leave it for the next tick.
|
|
1961
|
+
debugLog(`Could not claim question sidecar for ${running.name}`, error);
|
|
1962
|
+
}
|
|
1963
|
+
} finally {
|
|
1964
|
+
try {
|
|
1965
|
+
unlinkSync(claimedAskFile);
|
|
1966
|
+
} catch (error) {
|
|
1967
|
+
if (existsSync(claimedAskFile)) debugLog(`Could not remove claimed question sidecar for ${running.name}`, error);
|
|
1968
|
+
}
|
|
1969
|
+
}
|
|
1970
|
+
if (typeof payload?.question !== "string" || !payload.question.trim()) return;
|
|
1971
|
+
|
|
1972
|
+
const name = running.name; // unique per session (deduped at spawn) — targets the reply
|
|
1973
|
+
const sessionId = existsSync(running.sessionFile) ? getSessionId(running.sessionFile) : null;
|
|
1974
|
+
const elapsed = Math.floor((Date.now() - running.startTime) / 1000);
|
|
1975
|
+
const replyHint = `\n\nReply with subagent_message({ name: "${name}", message: "…" }) — the same name works whether it is still running or has since exited. It stays open until you reply.`;
|
|
1976
|
+
|
|
1977
|
+
latestPi?.sendMessage(
|
|
1978
|
+
{
|
|
1979
|
+
customType: "subagent_question",
|
|
1980
|
+
content: `Sub-agent "${name}" asks (${formatElapsed(elapsed)}):\n\n${payload.question}${replyHint}`,
|
|
1981
|
+
display: true,
|
|
1982
|
+
details: {
|
|
1983
|
+
name,
|
|
1984
|
+
agent: running.agent,
|
|
1985
|
+
question: payload.question,
|
|
1986
|
+
...(sessionId ? { sessionId } : {}),
|
|
1987
|
+
},
|
|
1988
|
+
},
|
|
1989
|
+
{ triggerTurn: true, deliverAs: "steer" },
|
|
1990
|
+
);
|
|
1991
|
+
}
|
|
1992
|
+
|
|
1993
|
+
/**
|
|
1994
|
+
* headless watcher:等待独立 RPC 进程到终态。与 pane watcher(pollForExit
|
|
1995
|
+
* 读屏 + sentinel)不同,终态信号是进程退出——auto-exit 子代理完成任务后
|
|
1996
|
+
* subagent-done 置 shutdown → RPC 进程 exit;ask 等待与等待孙代理结果时
|
|
1997
|
+
* shutdown 被抑制,进程保持存活,与 pane 的 sentinel 等待语义对齐。
|
|
1998
|
+
* 每 tick 同时观察 activity 快照与 .ask sidecar,与 pane 路径一致。
|
|
1999
|
+
*/
|
|
2000
|
+
async function waitForHeadlessExit(
|
|
2001
|
+
running: RunningSubagent,
|
|
2002
|
+
signal: AbortSignal,
|
|
2003
|
+
): Promise<PollResult> {
|
|
2004
|
+
const start = Date.now();
|
|
2005
|
+
for (;;) {
|
|
2006
|
+
if (signal.aborted) {
|
|
2007
|
+
throw new Error("Aborted while waiting for subagent to finish");
|
|
2008
|
+
}
|
|
2009
|
+
|
|
2010
|
+
const child = running.headlessChild;
|
|
2011
|
+
if (child) {
|
|
2012
|
+
if (child.promptError) {
|
|
2013
|
+
child.kill();
|
|
2014
|
+
await child.exitPromise.catch(() => {});
|
|
2015
|
+
deliverPendingQuestion(running);
|
|
2016
|
+
return {
|
|
2017
|
+
reason: "error",
|
|
2018
|
+
exitCode: 1,
|
|
2019
|
+
errorMessage: `Headless sub-agent "${running.name}" rejected its initial prompt: ${child.promptError}`,
|
|
2020
|
+
};
|
|
2021
|
+
}
|
|
2022
|
+
if (child.exited) {
|
|
2023
|
+
const info = await child.exitPromise;
|
|
2024
|
+
// 终态返回前最后消费一次 .ask:子代理可能在退出前夕刚写入问题
|
|
2025
|
+
// (如 barrier checkpoint 后被外部 kill),不消费就永远丢失。
|
|
2026
|
+
deliverPendingQuestion(running);
|
|
2027
|
+
// 先查 .exit sidecar(subagent-done 错误路径),再按退出码判定。
|
|
2028
|
+
const sidecar = readExitSidecar(running.sessionFile);
|
|
2029
|
+
if (sidecar) return sidecar;
|
|
2030
|
+
if ((info.code ?? 1) !== 0) {
|
|
2031
|
+
return {
|
|
2032
|
+
reason: "error",
|
|
2033
|
+
exitCode: info.code ?? 1,
|
|
2034
|
+
errorMessage:
|
|
2035
|
+
`Headless sub-agent process exited with code ${info.code ?? "null"}` +
|
|
2036
|
+
`${info.signal ? ` (signal ${info.signal})` : ""}.`,
|
|
2037
|
+
};
|
|
2038
|
+
}
|
|
2039
|
+
return { reason: "done", exitCode: 0 };
|
|
2040
|
+
}
|
|
2041
|
+
} else {
|
|
2042
|
+
// /reload 降级模式:stdin 已丢失,收不到 RPC 事件,只能轮询 PID。
|
|
2043
|
+
// 此刻退出码不可知:优先 .exit sidecar,否则按正常完成处理(结果从
|
|
2044
|
+
// session 文件提取;若实际是崩溃,最后 assistant 消息会反映实况)。
|
|
2045
|
+
const pid = running.pid ?? parseHeadlessSurface(running.surface);
|
|
2046
|
+
if (pid == null || !isPidAlive(pid)) {
|
|
2047
|
+
// 降级终态返回前同样最后消费一次 .ask(此前 tick 消费之后写入的
|
|
2048
|
+
// 问题在进程消失后不会再有机会投递)。
|
|
2049
|
+
deliverPendingQuestion(running);
|
|
2050
|
+
const sidecar = readExitSidecar(running.sessionFile);
|
|
2051
|
+
return sidecar ?? { reason: "done", exitCode: 0 };
|
|
2052
|
+
}
|
|
2053
|
+
}
|
|
2054
|
+
|
|
2055
|
+
observeRunningSubagent(running);
|
|
2056
|
+
deliverPendingQuestion(running);
|
|
2057
|
+
|
|
2058
|
+
await new Promise<void>((resolve, reject) => {
|
|
2059
|
+
if (signal.aborted) return reject(new Error("Aborted"));
|
|
2060
|
+
const timer = setTimeout(() => {
|
|
2061
|
+
signal.removeEventListener("abort", onAbort);
|
|
2062
|
+
resolve();
|
|
2063
|
+
}, 1000);
|
|
2064
|
+
function onAbort() {
|
|
2065
|
+
clearTimeout(timer);
|
|
2066
|
+
reject(new Error("Aborted"));
|
|
2067
|
+
}
|
|
2068
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
2069
|
+
});
|
|
2070
|
+
}
|
|
2071
|
+
}
|
|
2072
|
+
|
|
2073
|
+
// ── watcher 失败分类 ─────────────────────────────────────────────────────
|
|
2074
|
+
// 把“等待循环被中止”按来源分到四种处置,是杀进程/回注/保留记录的唯一决策点:
|
|
2075
|
+
// reload-handoff :模块被 /reload 替换——进程与 runtime record 全保留,
|
|
2076
|
+
// 由新模块的恢复路径接管(真实结果稍后回注)。
|
|
2077
|
+
// session-detach :宿主会话关闭(/new、/resume 切走、退出 pi)——同样保留
|
|
2078
|
+
// 进程与 record,不杀不删;回到本会话时 recover 接管。
|
|
2079
|
+
// 切换期间结果不会自动注入其它会话,这是诚实的降级边界。
|
|
2080
|
+
// cancelled :watcher 自身 signal 被显式 abort——现在只来自
|
|
2081
|
+
// subagent_stop(唯一显式停止入口):杀进程/关 pane、
|
|
2082
|
+
// 删 runtime record、兑 cancelled 终态。
|
|
2083
|
+
// error :其它意外错误:杀进程/关 pane、删 record、兑失败结果。
|
|
2084
|
+
// 工具层的 Escape 中止不 abort watcher(见 subagent-tool.ts),不会落到
|
|
2085
|
+
// cancelled——子代理继续运行,等待路径转为 detached 迟到回注。
|
|
2086
|
+
export type WatcherFailureKind = "reload-handoff" | "session-detach" | "cancelled" | "error";
|
|
2087
|
+
|
|
2088
|
+
export function classifyWatcherFailure(input: {
|
|
2089
|
+
moduleReplaced: boolean;
|
|
2090
|
+
moduleAbortAborted: boolean;
|
|
2091
|
+
watcherSignalAborted: boolean;
|
|
2092
|
+
}): WatcherFailureKind {
|
|
2093
|
+
if (input.moduleReplaced) return "reload-handoff";
|
|
2094
|
+
if (input.moduleAbortAborted) return "session-detach";
|
|
2095
|
+
if (input.watcherSignalAborted) return "cancelled";
|
|
2096
|
+
return "error";
|
|
2097
|
+
}
|
|
2098
|
+
|
|
2099
|
+
async function watchHeadlessSubagent(
|
|
2100
|
+
running: RunningSubagent,
|
|
2101
|
+
signal: AbortSignal,
|
|
2102
|
+
): Promise<SubagentResult> {
|
|
2103
|
+
const { name, task, startTime, sessionFile } = running;
|
|
2104
|
+
const cohortDetails = running.cohortId ? { cohortId: running.cohortId } : {};
|
|
2105
|
+
|
|
2106
|
+
try {
|
|
2107
|
+
const result = await waitForHeadlessExit(running, AbortSignal.any([signal, getModuleAbortSignal()]));
|
|
2108
|
+
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
|
2109
|
+
|
|
2110
|
+
const extracted = await extractSubagentResult(sessionFile, result);
|
|
2111
|
+
|
|
2112
|
+
runningSubagents.delete(running.id);
|
|
2113
|
+
removeRuntimeRecord(running.runtimeFile, running.id);
|
|
2114
|
+
// 进程通常已自行退出;降级/竞态残留的句柄 best-effort 回收。
|
|
2115
|
+
try {
|
|
2116
|
+
running.headlessChild?.kill();
|
|
2117
|
+
} catch (error) {
|
|
2118
|
+
debugLog(`Could not dispose completed headless child ${running.surface}`, error);
|
|
2119
|
+
}
|
|
2120
|
+
|
|
2121
|
+
return {
|
|
2122
|
+
name,
|
|
2123
|
+
task,
|
|
2124
|
+
summary: extracted.summary,
|
|
2125
|
+
sessionFile,
|
|
2126
|
+
...cohortDetails,
|
|
2127
|
+
...(extracted.sessionId ? { sessionId: extracted.sessionId } : {}),
|
|
2128
|
+
exitCode: result.exitCode,
|
|
2129
|
+
elapsed,
|
|
2130
|
+
...(result.errorMessage ? { errorMessage: result.errorMessage } : {}),
|
|
2131
|
+
...(extracted.stats ? { stats: extracted.stats } : {}),
|
|
2132
|
+
};
|
|
2133
|
+
} catch (err: any) {
|
|
2134
|
+
const moduleWasReplaced = (globalThis as any)[MODULE_INSTANCE_KEY] !== moduleInstanceId;
|
|
2135
|
+
const failureKind = classifyWatcherFailure({
|
|
2136
|
+
moduleReplaced: moduleWasReplaced,
|
|
2137
|
+
moduleAbortAborted: getModuleAbortSignal().aborted,
|
|
2138
|
+
watcherSignalAborted: signal.aborted,
|
|
2139
|
+
});
|
|
2140
|
+
runningSubagents.delete(running.id);
|
|
2141
|
+
if (failureKind === "reload-handoff" || failureKind === "session-detach") {
|
|
2142
|
+
// /reload 移交或宿主会话关闭:进程还在跑,保留 runtime record 交给
|
|
2143
|
+
// 恢复逻辑接管。绝不伪造 cancelled/failed 结果,显式 handed-off 语义:
|
|
2144
|
+
// 调用方只报“已移交,真实结果稍后回注”,不重复回注。
|
|
2145
|
+
return {
|
|
2146
|
+
name,
|
|
2147
|
+
task,
|
|
2148
|
+
summary:
|
|
2149
|
+
failureKind === "reload-handoff"
|
|
2150
|
+
? "Subagent handed off to recovery after host reload; it is still running and " +
|
|
2151
|
+
"its real result will be delivered when it finishes."
|
|
2152
|
+
: "Subagent detached because the host session was closed or switched away; it is " +
|
|
2153
|
+
"still running and its real result will be recovered when this session returns.",
|
|
2154
|
+
exitCode: 0,
|
|
2155
|
+
elapsed: Math.floor((Date.now() - startTime) / 1000),
|
|
2156
|
+
sessionFile,
|
|
2157
|
+
...cohortDetails,
|
|
2158
|
+
handedOff: true,
|
|
2159
|
+
};
|
|
2160
|
+
}
|
|
2161
|
+
// cancelled(显式 stop)与 error:headless 全是自动子代理,两种都要
|
|
2162
|
+
// 终止进程并移除 runtime record;区别只在结果语义。
|
|
2163
|
+
const child = running.headlessChild;
|
|
2164
|
+
try {
|
|
2165
|
+
if (child) {
|
|
2166
|
+
if (!child.exited) child.abort();
|
|
2167
|
+
child.kill();
|
|
2168
|
+
} else if (running.pid != null) {
|
|
2169
|
+
terminateHeadlessProcess(running.pid);
|
|
2170
|
+
}
|
|
2171
|
+
} catch (error) {
|
|
2172
|
+
debugLog(`Could not kill ${failureKind === "cancelled" ? "stopped" : "failed"} headless child ${running.surface}`, error);
|
|
2173
|
+
}
|
|
2174
|
+
removeRuntimeRecord(running.runtimeFile, running.id);
|
|
2175
|
+
if (failureKind === "cancelled") {
|
|
2176
|
+
return {
|
|
2177
|
+
name,
|
|
2178
|
+
task,
|
|
2179
|
+
summary: "Subagent stopped.",
|
|
2180
|
+
exitCode: 1,
|
|
2181
|
+
elapsed: Math.floor((Date.now() - startTime) / 1000),
|
|
2182
|
+
sessionFile,
|
|
2183
|
+
...cohortDetails,
|
|
2184
|
+
stopped: true,
|
|
2185
|
+
};
|
|
2186
|
+
}
|
|
2187
|
+
return {
|
|
2188
|
+
name,
|
|
2189
|
+
task,
|
|
2190
|
+
summary: `Subagent error: ${err?.message ?? String(err)}`,
|
|
2191
|
+
exitCode: 1,
|
|
2192
|
+
elapsed: Math.floor((Date.now() - startTime) / 1000),
|
|
2193
|
+
...cohortDetails,
|
|
2194
|
+
errorMessage: err?.message ?? String(err),
|
|
2195
|
+
};
|
|
2196
|
+
}
|
|
2197
|
+
}
|
|
2198
|
+
|
|
2199
|
+
async function watchSubagent(
|
|
2200
|
+
running: RunningSubagent,
|
|
2201
|
+
signal: AbortSignal,
|
|
2202
|
+
): Promise<SubagentResult> {
|
|
2203
|
+
if (running.kind === "headless") return watchHeadlessSubagent(running, signal);
|
|
2204
|
+
const { name, task, surface, startTime, sessionFile } = running;
|
|
2205
|
+
const cohortDetails = running.cohortId ? { cohortId: running.cohortId } : {};
|
|
2206
|
+
|
|
2207
|
+
try {
|
|
2208
|
+
const result = await pollForExit(surface, AbortSignal.any([signal, getModuleAbortSignal()]), {
|
|
2209
|
+
interval: 1000,
|
|
2210
|
+
sessionFile,
|
|
2211
|
+
sentinelToken: running.sentinelToken,
|
|
2212
|
+
onTick() {
|
|
2213
|
+
observeRunningSubagent(running);
|
|
2214
|
+
deliverPendingQuestion(running);
|
|
2215
|
+
},
|
|
2216
|
+
});
|
|
2217
|
+
|
|
2218
|
+
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
|
2219
|
+
|
|
2220
|
+
const extracted = await extractSubagentResult(sessionFile, result);
|
|
2221
|
+
const summary = extracted.summary;
|
|
2222
|
+
const stats = extracted.stats;
|
|
2223
|
+
const subagentSessionId = extracted.sessionId;
|
|
2224
|
+
|
|
2225
|
+
runningSubagents.delete(running.id);
|
|
2226
|
+
removeRuntimeRecord(running.runtimeFile, running.id);
|
|
2227
|
+
// 结果已经从 session 文件取得,pane 清理失败不应覆盖真实结果。用户
|
|
2228
|
+
// 直接关闭 pane(user_closed)时 closeSurface 抛错是预期路径,同样不影响。
|
|
2229
|
+
try {
|
|
2230
|
+
closeSurface(surface);
|
|
2231
|
+
} catch (error) {
|
|
2232
|
+
// pane 可能已被外部关闭;不会影响结果回注。
|
|
2233
|
+
debugLog(`Could not close completed pane ${surface}`, error);
|
|
2234
|
+
}
|
|
2235
|
+
|
|
2236
|
+
// 显式停止后 pane 消失:按显式 cancelled 终态处理,不误报 user_closed。
|
|
2237
|
+
if (running.stopRequested && result.reason === "user_closed") {
|
|
2238
|
+
return {
|
|
2239
|
+
name,
|
|
2240
|
+
task,
|
|
2241
|
+
summary: "Subagent stopped.",
|
|
2242
|
+
exitCode: 1,
|
|
2243
|
+
elapsed,
|
|
2244
|
+
sessionFile,
|
|
2245
|
+
...cohortDetails,
|
|
2246
|
+
stopped: true,
|
|
2247
|
+
};
|
|
2248
|
+
}
|
|
2249
|
+
return {
|
|
2250
|
+
name,
|
|
2251
|
+
task,
|
|
2252
|
+
summary,
|
|
2253
|
+
sessionFile,
|
|
2254
|
+
...cohortDetails,
|
|
2255
|
+
...(subagentSessionId ? { sessionId: subagentSessionId } : {}),
|
|
2256
|
+
exitCode: result.exitCode,
|
|
2257
|
+
elapsed,
|
|
2258
|
+
...(result.reason === "user_closed" ? { userClosed: true } : {}),
|
|
2259
|
+
...(result.errorMessage ? { errorMessage: result.errorMessage } : {}),
|
|
2260
|
+
...(stats ? { stats } : {}),
|
|
2261
|
+
};
|
|
2262
|
+
} catch (err: any) {
|
|
2263
|
+
const moduleWasReplaced = (globalThis as any)[MODULE_INSTANCE_KEY] !== moduleInstanceId;
|
|
2264
|
+
const failureKind = classifyWatcherFailure({
|
|
2265
|
+
moduleReplaced: moduleWasReplaced,
|
|
2266
|
+
moduleAbortAborted: getModuleAbortSignal().aborted,
|
|
2267
|
+
watcherSignalAborted: signal.aborted,
|
|
2268
|
+
});
|
|
2269
|
+
runningSubagents.delete(running.id);
|
|
2270
|
+
if (failureKind === "reload-handoff" || failureKind === "session-detach") {
|
|
2271
|
+
// /reload 移交或宿主会话关闭:pane 与 runtime record 全保留,交给恢复
|
|
2272
|
+
// 逻辑接管;不伪造 cancelled/failed,也不在此回注,显式 handed-off。
|
|
2273
|
+
return {
|
|
2274
|
+
name,
|
|
2275
|
+
task,
|
|
2276
|
+
summary:
|
|
2277
|
+
failureKind === "reload-handoff"
|
|
2278
|
+
? "Subagent handed off to recovery after host reload; it is still running and " +
|
|
2279
|
+
"its real result will be delivered when it finishes."
|
|
2280
|
+
: "Subagent detached because the host session was closed or switched away; it is " +
|
|
2281
|
+
"still running and its real result will be recovered when this session returns.",
|
|
2282
|
+
exitCode: 0,
|
|
2283
|
+
elapsed: Math.floor((Date.now() - startTime) / 1000),
|
|
2284
|
+
sessionFile,
|
|
2285
|
+
...cohortDetails,
|
|
2286
|
+
handedOff: true,
|
|
2287
|
+
};
|
|
2288
|
+
}
|
|
2289
|
+
// cancelled(显式 stop)与 error:自动 pane 由 watcher 统一关闭并移除
|
|
2290
|
+
// record;交互式 pane 归用户驱动——但 subagent_stop 的显式停止已在 stop
|
|
2291
|
+
// 入口直接关闭 pane,这里不再重复关闭(且归属护栏在 closeSurface 内)。
|
|
2292
|
+
if (!running.interactive) {
|
|
2293
|
+
try {
|
|
2294
|
+
closeSurface(surface);
|
|
2295
|
+
} catch (error) {
|
|
2296
|
+
debugLog(`Could not close cancelled pane ${surface}`, error);
|
|
2297
|
+
}
|
|
2298
|
+
}
|
|
2299
|
+
if (!(signal.aborted && running.interactive)) {
|
|
2300
|
+
removeRuntimeRecord(running.runtimeFile, running.id);
|
|
2301
|
+
}
|
|
2302
|
+
if (failureKind === "cancelled") {
|
|
2303
|
+
return {
|
|
2304
|
+
name,
|
|
2305
|
+
task,
|
|
2306
|
+
summary: "Subagent stopped.",
|
|
2307
|
+
exitCode: 1,
|
|
2308
|
+
elapsed: Math.floor((Date.now() - startTime) / 1000),
|
|
2309
|
+
sessionFile,
|
|
2310
|
+
...cohortDetails,
|
|
2311
|
+
stopped: true,
|
|
2312
|
+
};
|
|
2313
|
+
}
|
|
2314
|
+
return {
|
|
2315
|
+
name,
|
|
2316
|
+
task,
|
|
2317
|
+
summary: `Subagent error: ${err?.message ?? String(err)}`,
|
|
2318
|
+
exitCode: 1,
|
|
2319
|
+
elapsed: Math.floor((Date.now() - startTime) / 1000),
|
|
2320
|
+
...cohortDetails,
|
|
2321
|
+
errorMessage: err?.message ?? String(err),
|
|
2322
|
+
};
|
|
2323
|
+
}
|
|
2324
|
+
}
|
|
2325
|
+
|
|
2326
|
+
/** 重载后从运行态记录恢复 watcher,避免同一 session 被重复启动。 */
|
|
2327
|
+
async function recoverRuntimeSubagents(
|
|
2328
|
+
ctx: { sessionManager: { getSessionId(): string; getSessionDir(): string } },
|
|
2329
|
+
pi: ExtensionAPI,
|
|
2330
|
+
): Promise<void> {
|
|
2331
|
+
const runtimeFile = runtimeRegistryPath(
|
|
2332
|
+
getArtifactDir(ctx.sessionManager.getSessionDir(), ctx.sessionManager.getSessionId()),
|
|
2333
|
+
);
|
|
2334
|
+
for (const record of readRuntimeRecords(runtimeFile)) {
|
|
2335
|
+
if (runningSubagents.has(record.id)) continue;
|
|
2336
|
+
// 单条隔离:某条记录恢复失败(如 pane adoptSurface 在无复用器环境下
|
|
2337
|
+
// 抛错、或会话文件损坏)只跳过该记录,不中断后续记录的恢复。
|
|
2338
|
+
try {
|
|
2339
|
+
|
|
2340
|
+
if ((record.kind ?? "pane") === "headless") {
|
|
2341
|
+
// headless 恢复:/reload 后 RPC stdin 已丢失,无法重新接管——这是显式
|
|
2342
|
+
// 降级边界。进程已退出:直接提取结果回注;仍在运行:标记 stdinLost,
|
|
2343
|
+
// watcher 轮询 PID 直到进程消失,期间 steer 与 ask 回复不可达(报错提示)。
|
|
2344
|
+
const pid = record.pid ?? parseHeadlessSurface(record.surface);
|
|
2345
|
+
// 持久成员:stdin 无法重接(常驻进程不退出,PID 轮询会永久空转)——
|
|
2346
|
+
// 诚实降级:终止进程、移除运行态、roster 标记 offline,session 保留
|
|
2347
|
+
// 供显式 resume(subagent_message)或重新 spawn(member: true)。
|
|
2348
|
+
if (record.member) {
|
|
2349
|
+
if (pid != null && isPidAlive(pid)) {
|
|
2350
|
+
terminateHeadlessProcess(pid);
|
|
2351
|
+
}
|
|
2352
|
+
try {
|
|
2353
|
+
const memberRosterFile = rosterPath(dirname(runtimeFile));
|
|
2354
|
+
const existingRoster = findRosterMember(memberRosterFile, record.name);
|
|
2355
|
+
const offlineReason =
|
|
2356
|
+
existingRoster?.status === "offline" &&
|
|
2357
|
+
existingRoster.sessionFile === record.sessionFile &&
|
|
2358
|
+
existingRoster.offlineReason
|
|
2359
|
+
? existingRoster.offlineReason
|
|
2360
|
+
: "host-reload";
|
|
2361
|
+
upsertRosterMember(memberRosterFile, {
|
|
2362
|
+
name: record.name,
|
|
2363
|
+
...(record.agent ? { agent: record.agent } : {}),
|
|
2364
|
+
sessionFile: record.sessionFile,
|
|
2365
|
+
...(pid != null ? { pid } : {}),
|
|
2366
|
+
status: "offline",
|
|
2367
|
+
offlineReason,
|
|
2368
|
+
});
|
|
2369
|
+
} catch (error) {
|
|
2370
|
+
debugLog(`Could not mark recovered member ${record.name} offline`, error);
|
|
2371
|
+
}
|
|
2372
|
+
removeRuntimeRecord(runtimeFile, record.id);
|
|
2373
|
+
continue;
|
|
2374
|
+
}
|
|
2375
|
+
if (pid == null || !isPidAlive(pid)) {
|
|
2376
|
+
removeRuntimeRecord(runtimeFile, record.id);
|
|
2377
|
+
const exit = readExitSidecar(record.sessionFile) ?? { reason: "done" as const, exitCode: 0 };
|
|
2378
|
+
const routeModel = readSubagentLoadout(record.sessionFile)?.model ?? null;
|
|
2379
|
+
try {
|
|
2380
|
+
const extracted = await extractSubagentResult(
|
|
2381
|
+
record.sessionFile,
|
|
2382
|
+
{ exitCode: exit.exitCode, ...(exit.errorMessage ? { errorMessage: exit.errorMessage } : {}) },
|
|
2383
|
+
);
|
|
2384
|
+
const elapsedForRecord = Math.max(0, Math.floor((Date.now() - record.startTime) / 1000));
|
|
2385
|
+
const exitResult = {
|
|
2386
|
+
summary: extracted.summary,
|
|
2387
|
+
sessionFile: record.sessionFile,
|
|
2388
|
+
...(record.cohortId ? { cohortId: record.cohortId } : {}),
|
|
2389
|
+
...(extracted.sessionId ? { sessionId: extracted.sessionId } : {}),
|
|
2390
|
+
exitCode: exit.exitCode,
|
|
2391
|
+
elapsed: elapsedForRecord,
|
|
2392
|
+
...(exit.errorMessage ? { errorMessage: exit.errorMessage } : {}),
|
|
2393
|
+
...(extracted.stats ? { stats: extracted.stats } : {}),
|
|
2394
|
+
};
|
|
2395
|
+
const routeException = routeExceptionFromResult(exitResult, routeModel);
|
|
2396
|
+
// reload 前登记的完成记录(若有等待者)在此兑为终态。
|
|
2397
|
+
settleCompletionFromResult(record.name, exitResult);
|
|
2398
|
+
const presentation = resolveResultPresentation(exitResult, record.name);
|
|
2399
|
+
pi.sendMessage(
|
|
2400
|
+
{
|
|
2401
|
+
customType: "subagent_result",
|
|
2402
|
+
content: presentation,
|
|
2403
|
+
display: true,
|
|
2404
|
+
details: {
|
|
2405
|
+
name: record.name,
|
|
2406
|
+
task: record.task,
|
|
2407
|
+
agent: record.agent,
|
|
2408
|
+
exitCode: exit.exitCode,
|
|
2409
|
+
elapsed: elapsedForRecord,
|
|
2410
|
+
sessionFile: record.sessionFile,
|
|
2411
|
+
...(record.cohortId ? { cohortId: record.cohortId } : {}),
|
|
2412
|
+
recovered: "headless-exited",
|
|
2413
|
+
...(extracted.sessionId ? { sessionId: extracted.sessionId } : {}),
|
|
2414
|
+
...(exit.errorMessage ? { errorMessage: exit.errorMessage } : {}),
|
|
2415
|
+
...(extracted.stats ? { stats: extracted.stats } : {}),
|
|
2416
|
+
...(routeException ? { routeException } : {}),
|
|
2417
|
+
},
|
|
2418
|
+
},
|
|
2419
|
+
{ triggerTurn: true, deliverAs: "steer" },
|
|
2420
|
+
);
|
|
2421
|
+
} catch (error) {
|
|
2422
|
+
debugLog(`Could not recover finished headless subagent ${record.name}`, error);
|
|
2423
|
+
}
|
|
2424
|
+
continue;
|
|
2425
|
+
}
|
|
2426
|
+
const running: RunningSubagent = {
|
|
2427
|
+
id: record.id,
|
|
2428
|
+
name: record.name,
|
|
2429
|
+
task: record.task,
|
|
2430
|
+
...(record.cohortId ? { cohortId: record.cohortId } : {}),
|
|
2431
|
+
agent: record.agent,
|
|
2432
|
+
model: readSubagentLoadout(record.sessionFile)?.model ?? null,
|
|
2433
|
+
parentId: record.parentId ?? null,
|
|
2434
|
+
...(record.timeoutMs != null ? { timeoutMs: record.timeoutMs } : {}),
|
|
2435
|
+
...(record.waitReleased ? { waitReleased: record.waitReleased } : {}),
|
|
2436
|
+
waitMode: "recovered",
|
|
2437
|
+
surface: record.surface,
|
|
2438
|
+
startTime: record.startTime,
|
|
2439
|
+
sessionFile: record.sessionFile,
|
|
2440
|
+
activityFile: record.activityFile ?? getSubagentActivityFile(dirname(runtimeFile), record.id),
|
|
2441
|
+
interactive: record.interactive,
|
|
2442
|
+
sentinelToken: record.sentinelToken,
|
|
2443
|
+
runtimeFile,
|
|
2444
|
+
statusState: createStatusState({ source: "pi", startTimeMs: record.startTime }),
|
|
2445
|
+
kind: "headless",
|
|
2446
|
+
pid,
|
|
2447
|
+
stdinLost: true,
|
|
2448
|
+
};
|
|
2449
|
+
runningSubagents.set(running.id, running);
|
|
2450
|
+
const watcherAbort = new AbortController();
|
|
2451
|
+
running.abortController = watcherAbort;
|
|
2452
|
+
watchSubagent(running, watcherAbort.signal)
|
|
2453
|
+
.then((result) => {
|
|
2454
|
+
updateWidget();
|
|
2455
|
+
settleCompletionFromResult(running.name, result);
|
|
2456
|
+
// 恢复 watcher 自己又赶上 /reload:再次移交,真实结果由更新后的
|
|
2457
|
+
// 模块在下次 session_start 恢复接管,本次不回注,避免伪造/重复结果。
|
|
2458
|
+
if (result.handedOff) return;
|
|
2459
|
+
const routeException = routeExceptionFromResult(result, running.model);
|
|
2460
|
+
const presentation = resolveResultPresentation(result, running.name);
|
|
2461
|
+
pi.sendMessage(
|
|
2462
|
+
{
|
|
2463
|
+
customType: "subagent_result",
|
|
2464
|
+
content: presentation,
|
|
2465
|
+
display: true,
|
|
2466
|
+
details: {
|
|
2467
|
+
name: running.name,
|
|
2468
|
+
task: running.task,
|
|
2469
|
+
agent: running.agent,
|
|
2470
|
+
exitCode: result.exitCode,
|
|
2471
|
+
elapsed: result.elapsed,
|
|
2472
|
+
sessionFile: result.sessionFile,
|
|
2473
|
+
...(running.cohortId ? { cohortId: running.cohortId } : {}),
|
|
2474
|
+
recovered: "headless-degraded",
|
|
2475
|
+
...(result.sessionId ? { sessionId: result.sessionId } : {}),
|
|
2476
|
+
...(result.errorMessage ? { errorMessage: result.errorMessage } : {}),
|
|
2477
|
+
...(result.stats ? { stats: result.stats } : {}),
|
|
2478
|
+
...(routeException ? { routeException } : {}),
|
|
2479
|
+
},
|
|
2480
|
+
},
|
|
2481
|
+
{ triggerTurn: true, deliverAs: "steer" },
|
|
2482
|
+
);
|
|
2483
|
+
})
|
|
2484
|
+
.catch((error) => {
|
|
2485
|
+
updateWidget();
|
|
2486
|
+
settleCompletionFromResult(running.name, { exitCode: 1, errorMessage: error?.message ?? String(error) });
|
|
2487
|
+
pi.sendMessage(
|
|
2488
|
+
{
|
|
2489
|
+
customType: "subagent_result",
|
|
2490
|
+
content: `Recovered headless sub-agent "${running.name}" error: ${error?.message ?? String(error)}`,
|
|
2491
|
+
display: true,
|
|
2492
|
+
details: {
|
|
2493
|
+
name: running.name,
|
|
2494
|
+
task: running.task,
|
|
2495
|
+
...(running.cohortId ? { cohortId: running.cohortId } : {}),
|
|
2496
|
+
error: error?.message,
|
|
2497
|
+
},
|
|
2498
|
+
},
|
|
2499
|
+
{ triggerTurn: true, deliverAs: "steer" },
|
|
2500
|
+
);
|
|
2501
|
+
});
|
|
2502
|
+
continue;
|
|
2503
|
+
}
|
|
2504
|
+
|
|
2505
|
+
if (!adoptSurface(record.surface)) {
|
|
2506
|
+
removeRuntimeRecord(runtimeFile, record.id);
|
|
2507
|
+
continue;
|
|
2508
|
+
}
|
|
2509
|
+
const running: RunningSubagent = {
|
|
2510
|
+
id: record.id,
|
|
2511
|
+
name: record.name,
|
|
2512
|
+
task: record.task,
|
|
2513
|
+
...(record.cohortId ? { cohortId: record.cohortId } : {}),
|
|
2514
|
+
agent: record.agent,
|
|
2515
|
+
model: readSubagentLoadout(record.sessionFile)?.model ?? null,
|
|
2516
|
+
parentId: record.parentId ?? null,
|
|
2517
|
+
...(record.timeoutMs != null ? { timeoutMs: record.timeoutMs } : {}),
|
|
2518
|
+
...(record.waitReleased ? { waitReleased: record.waitReleased } : {}),
|
|
2519
|
+
waitMode: "recovered",
|
|
2520
|
+
surface: record.surface,
|
|
2521
|
+
startTime: record.startTime,
|
|
2522
|
+
sessionFile: record.sessionFile,
|
|
2523
|
+
activityFile: record.activityFile ?? getSubagentActivityFile(dirname(runtimeFile), record.id),
|
|
2524
|
+
interactive: record.interactive,
|
|
2525
|
+
sentinelToken: record.sentinelToken,
|
|
2526
|
+
runtimeFile,
|
|
2527
|
+
statusState: createStatusState({ source: "pi", startTimeMs: record.startTime }),
|
|
2528
|
+
};
|
|
2529
|
+
runningSubagents.set(running.id, running);
|
|
2530
|
+
const watcherAbort = new AbortController();
|
|
2531
|
+
running.abortController = watcherAbort;
|
|
2532
|
+
watchSubagent(running, watcherAbort.signal)
|
|
2533
|
+
.then((result) => {
|
|
2534
|
+
updateWidget();
|
|
2535
|
+
// 用户直接关闭 pane 是稳定终态(user_closed),同样要 settle 依赖
|
|
2536
|
+
// 记录(按 cancelled 语义),等待者不永久挂起。
|
|
2537
|
+
settleCompletionFromResult(running.name, result, {
|
|
2538
|
+
...(result.userClosed ? { status: "cancelled" as const } : {}),
|
|
2539
|
+
});
|
|
2540
|
+
// 恢复 watcher 自己又赶上 /reload:再次移交,本次不回注(同 headless 分支)。
|
|
2541
|
+
if (result.handedOff) return;
|
|
2542
|
+
const routeException = routeExceptionFromResult(result, running.model);
|
|
2543
|
+
const presentation = resolveResultPresentation(result, running.name);
|
|
2544
|
+
pi.sendMessage(
|
|
2545
|
+
{
|
|
2546
|
+
customType: "subagent_result",
|
|
2547
|
+
content: presentation,
|
|
2548
|
+
display: true,
|
|
2549
|
+
details: {
|
|
2550
|
+
name: running.name,
|
|
2551
|
+
task: running.task,
|
|
2552
|
+
agent: running.agent,
|
|
2553
|
+
exitCode: result.exitCode,
|
|
2554
|
+
elapsed: result.elapsed,
|
|
2555
|
+
sessionFile: result.sessionFile,
|
|
2556
|
+
...(running.cohortId ? { cohortId: running.cohortId } : {}),
|
|
2557
|
+
recovered: "pane",
|
|
2558
|
+
...(result.userClosed ? { userClosed: true } : {}),
|
|
2559
|
+
...(result.sessionId ? { sessionId: result.sessionId } : {}),
|
|
2560
|
+
...(result.errorMessage ? { errorMessage: result.errorMessage } : {}),
|
|
2561
|
+
...(result.stats ? { stats: result.stats } : {}),
|
|
2562
|
+
...(routeException ? { routeException } : {}),
|
|
2563
|
+
},
|
|
2564
|
+
},
|
|
2565
|
+
{ triggerTurn: true, deliverAs: "steer" },
|
|
2566
|
+
);
|
|
2567
|
+
})
|
|
2568
|
+
.catch((error) => {
|
|
2569
|
+
updateWidget();
|
|
2570
|
+
settleCompletionFromResult(running.name, { exitCode: 1, errorMessage: error?.message ?? String(error) });
|
|
2571
|
+
pi.sendMessage(
|
|
2572
|
+
{
|
|
2573
|
+
customType: "subagent_result",
|
|
2574
|
+
content: `Recovered sub-agent "${running.name}" error: ${error?.message ?? String(error)}`,
|
|
2575
|
+
display: true,
|
|
2576
|
+
details: {
|
|
2577
|
+
name: running.name,
|
|
2578
|
+
task: running.task,
|
|
2579
|
+
...(running.cohortId ? { cohortId: running.cohortId } : {}),
|
|
2580
|
+
error: error?.message,
|
|
2581
|
+
},
|
|
2582
|
+
},
|
|
2583
|
+
{ triggerTurn: true, deliverAs: "steer" },
|
|
2584
|
+
);
|
|
2585
|
+
});
|
|
2586
|
+
} catch (error) {
|
|
2587
|
+
// 单条隔离兑底:该记录的恢复失败被记录并跳过,循环继续处理后续记录。
|
|
2588
|
+
debugLog(`Could not recover runtime subagent record ${record.id} (${record.name})`, error);
|
|
2589
|
+
}
|
|
2590
|
+
}
|
|
2591
|
+
if (runningSubagents.size > 0) {
|
|
2592
|
+
startWidgetRefresh();
|
|
2593
|
+
startStatusRefresh(pi);
|
|
2594
|
+
}
|
|
2595
|
+
}
|
|
2596
|
+
|
|
2597
|
+
export default function subagentsExtension(pi: ExtensionAPI, options?: SubagentsExtensionOptions) {
|
|
2598
|
+
configureSubagentsExtension(options ?? {});
|
|
2599
|
+
latestPi = pi;
|
|
2600
|
+
// Capture the UI context for widget updates
|
|
2601
|
+
pi.on("session_start", (_event, ctx) => {
|
|
2602
|
+
latestCtx = ctx;
|
|
2603
|
+
// pi runs multiple sessions in one process. A prior session's shutdown
|
|
2604
|
+
// aborts the shared module poll-abort controller; install a fresh one so
|
|
2605
|
+
// subagents spawned in this session aren't watched against a dead signal.
|
|
2606
|
+
// See https://github.com/HazAT/pi-interactive-subagents/issues/5
|
|
2607
|
+
const prevAbort = (globalThis as any)[POLL_ABORT_KEY] as AbortController | undefined;
|
|
2608
|
+
if (!prevAbort || prevAbort.signal.aborted) {
|
|
2609
|
+
(globalThis as any)[POLL_ABORT_KEY] = new AbortController();
|
|
2610
|
+
}
|
|
2611
|
+
recoverRuntimeSubagents(ctx, pi).catch((error) => {
|
|
2612
|
+
debugLog("Runtime subagent recovery failed", error);
|
|
2613
|
+
});
|
|
2614
|
+
});
|
|
2615
|
+
|
|
2616
|
+
// Clean up on session shutdown
|
|
2617
|
+
// 区分关闭来源(与 watcher 的 classifyWatcherFailure 配合):
|
|
2618
|
+
// - /reload:模块替换,reload-handoff——现有恢复路径接管,无特殊处理。
|
|
2619
|
+
// - /new、/resume 切会话、退出 pi:session-detach——自动子代理不杀:
|
|
2620
|
+
// 进程/pane 与 runtime record 全保留;回到本会话时 recover 接管
|
|
2621
|
+
// (已退出 → 提取结果回注;仍在跑 → PID 轮询降级/pane 重新接管)。
|
|
2622
|
+
// 切换期间结果不会自动注入其它会话——这是诚实的降级边界,不伪称跨会话回传。
|
|
2623
|
+
// - 交互式演示 pane 归用户所有:一律不动,留给用户自行处理。
|
|
2624
|
+
// 工具层的 Escape 中止不经过这里(它只解除工具等待,子代理转 detached)。
|
|
2625
|
+
pi.on("session_shutdown", (_event, _ctx) => {
|
|
2626
|
+
if (widgetInterval) {
|
|
2627
|
+
clearInterval(widgetInterval);
|
|
2628
|
+
widgetInterval = null;
|
|
2629
|
+
(globalThis as any)[WIDGET_INTERVAL_KEY] = null;
|
|
2630
|
+
}
|
|
2631
|
+
if (statusInterval) {
|
|
2632
|
+
clearInterval(statusInterval);
|
|
2633
|
+
statusInterval = null;
|
|
2634
|
+
(globalThis as any)[STATUS_INTERVAL_KEY] = null;
|
|
2635
|
+
}
|
|
2636
|
+
const moduleAbort = (globalThis as any)[POLL_ABORT_KEY] as AbortController | undefined;
|
|
2637
|
+
if (moduleAbort) moduleAbort.abort();
|
|
2638
|
+
for (const [_id, agent] of runningSubagents) {
|
|
2639
|
+
if (agent.interactive) {
|
|
2640
|
+
// 交互式子代理归用户所有,宿主会话关闭时不强杀其 pane,留给用户自行处理。
|
|
2641
|
+
continue;
|
|
2642
|
+
}
|
|
2643
|
+
if (agent.member) {
|
|
2644
|
+
// 持久成员是永活进程(不 auto-exit):不能像普通自动子代理那样 detach
|
|
2645
|
+
// 留孤儿——安全终止并 roster 标记 offline;session/loadout 保留供显式 resume。
|
|
2646
|
+
killMemberProcess(agent);
|
|
2647
|
+
markMemberOffline(agent, "host-shutdown");
|
|
2648
|
+
removeRuntimeRecord(agent.runtimeFile, agent.id);
|
|
2649
|
+
agent.abortController?.abort();
|
|
2650
|
+
continue;
|
|
2651
|
+
}
|
|
2652
|
+
// A /reload-recovered headless child has no live stdin handle. Abort the
|
|
2653
|
+
// watcher and explicitly terminate its recorded PID so an ask_question
|
|
2654
|
+
// parked process cannot become an orphan after the host shuts down.
|
|
2655
|
+
// (stdinLost 进程无人可再接管,显式终止防泄漏;有句柄的子进程交给
|
|
2656
|
+
// watcher 的 session-detach 分支保留存活。)
|
|
2657
|
+
if (agent.kind === "headless" && !agent.headlessChild && agent.pid != null) {
|
|
2658
|
+
terminateHeadlessProcess(agent.pid);
|
|
2659
|
+
}
|
|
2660
|
+
// 中止 watcher 的等待循环:watcher 判定 session-detach 后保留进程与
|
|
2661
|
+
// runtime record(不杀不删),交给本会话的恢复路径接管。
|
|
2662
|
+
agent.abortController?.abort();
|
|
2663
|
+
}
|
|
2664
|
+
runningSubagents.clear();
|
|
2665
|
+
});
|
|
2666
|
+
|
|
2667
|
+
// The spawning tools are always registered here. Whether a child process can
|
|
2668
|
+
// actually see/use them is governed by the parent's `--tools` allowlist and
|
|
2669
|
+
// by Pi's normal extension discovery in the child's inherited config/cwd
|
|
2670
|
+
// (+ explicit -e for parent-registered custom tools). See launchSubagent().
|
|
2671
|
+
|
|
2672
|
+
registerSubagentTool(pi, {
|
|
2673
|
+
allowlist: SUBAGENT_ALLOWLIST,
|
|
2674
|
+
discoverAgents: discoverAgentDefinitions,
|
|
2675
|
+
isMuxAvailable,
|
|
2676
|
+
muxUnavailableResult,
|
|
2677
|
+
resolveSurfaceChoice: (params) => {
|
|
2678
|
+
const agentDefs = params.agent ? loadAgentDefaults(params.agent) : null;
|
|
2679
|
+
const lifecycleError = validateAgentLifecycleConfig(agentDefs);
|
|
2680
|
+
if (lifecycleError) return { error: lifecycleError };
|
|
2681
|
+
const interactive = resolveEffectiveInteractive(params as SubagentParams, agentDefs);
|
|
2682
|
+
return resolveSurfaceChoice(params.surface, interactive);
|
|
2683
|
+
},
|
|
2684
|
+
getArtifactDir,
|
|
2685
|
+
runningSubagents,
|
|
2686
|
+
reservedNames,
|
|
2687
|
+
resolveInteractive: (params) => {
|
|
2688
|
+
const agentDefs = params.agent ? loadAgentDefaults(params.agent) : null;
|
|
2689
|
+
return resolveEffectiveInteractive(params as SubagentParams, agentDefs);
|
|
2690
|
+
},
|
|
2691
|
+
uniqueRunningName,
|
|
2692
|
+
launchSubagent,
|
|
2693
|
+
canReuseMemberName: (name: string, artifactDir: string) => canReuseMemberName(name, artifactDir),
|
|
2694
|
+
startWidgetRefresh,
|
|
2695
|
+
startStatusRefresh,
|
|
2696
|
+
watchSubagent,
|
|
2697
|
+
watchMemberRound,
|
|
2698
|
+
updateWidget,
|
|
2699
|
+
resolveResultPresentation,
|
|
2700
|
+
});
|
|
2701
|
+
|
|
2702
|
+
registerSubagentMessageTool(pi, {
|
|
2703
|
+
isMuxAvailable,
|
|
2704
|
+
muxUnavailableResult,
|
|
2705
|
+
resolveSurfaceChoice: (params) => resolveSurfaceChoice(params.surface, false),
|
|
2706
|
+
spawnHeadlessPi,
|
|
2707
|
+
runningSubagents,
|
|
2708
|
+
reservedNames,
|
|
2709
|
+
handleSubagentSteer,
|
|
2710
|
+
getArtifactDir,
|
|
2711
|
+
readNameRegistry,
|
|
2712
|
+
resolveNameInRegistry,
|
|
2713
|
+
getSessionId,
|
|
2714
|
+
readSubagentLoadout,
|
|
2715
|
+
readAnchoredLoadout,
|
|
2716
|
+
getAgentConfigDir,
|
|
2717
|
+
validateResumeTarget,
|
|
2718
|
+
countSessionEntryLines,
|
|
2719
|
+
getSubagentActivityFile,
|
|
2720
|
+
createSurface,
|
|
2721
|
+
shellEscape,
|
|
2722
|
+
subagentsDir: SUBAGENTS_DIR,
|
|
2723
|
+
applySandboxToParts,
|
|
2724
|
+
getShellReadyDelayMs,
|
|
2725
|
+
sendCommand,
|
|
2726
|
+
closeSurface,
|
|
2727
|
+
resolveResumeLaunchBehavior,
|
|
2728
|
+
runtimeRegistryPath,
|
|
2729
|
+
upsertRuntimeRecord,
|
|
2730
|
+
removeRuntimeRecord,
|
|
2731
|
+
startWidgetRefresh,
|
|
2732
|
+
startStatusRefresh,
|
|
2733
|
+
watchSubagent,
|
|
2734
|
+
updateWidget,
|
|
2735
|
+
extractSubagentResult,
|
|
2736
|
+
resolveResultPresentation,
|
|
2737
|
+
});
|
|
2738
|
+
|
|
2739
|
+
registerSubagentsListTool(pi, {
|
|
2740
|
+
discoverAgents: discoverAgentDefinitions,
|
|
2741
|
+
getArtifactDir,
|
|
2742
|
+
runningSubagents,
|
|
2743
|
+
readNameRegistry,
|
|
2744
|
+
readSubagentLoadout,
|
|
2745
|
+
readRuntimeRecords,
|
|
2746
|
+
runtimeRegistryPath,
|
|
2747
|
+
});
|
|
2748
|
+
registerSubagentInspectTool(pi, {
|
|
2749
|
+
runningSubagents,
|
|
2750
|
+
observeRunningSubagent,
|
|
2751
|
+
getArtifactDir,
|
|
2752
|
+
readNameRegistry,
|
|
2753
|
+
readSubagentLoadout,
|
|
2754
|
+
readRuntimeRecords,
|
|
2755
|
+
runtimeRegistryPath,
|
|
2756
|
+
isPidAlive,
|
|
2757
|
+
probeSurface,
|
|
2758
|
+
readRosterMember: (name, artifactDir) => {
|
|
2759
|
+
const member = findRosterMember(rosterPath(artifactDir), name);
|
|
2760
|
+
return member
|
|
2761
|
+
? {
|
|
2762
|
+
status: member.status,
|
|
2763
|
+
...(member.offlineReason ? { offlineReason: member.offlineReason } : {}),
|
|
2764
|
+
...(member.dispatchedAt != null ? { dispatchedAt: member.dispatchedAt } : {}),
|
|
2765
|
+
...(member.lastRoundAt != null ? { lastRoundAt: member.lastRoundAt } : {}),
|
|
2766
|
+
}
|
|
2767
|
+
: null;
|
|
2768
|
+
},
|
|
2769
|
+
});
|
|
2770
|
+
// 旧 `/subagent` 命令:合并决策 5 后产品不再注册,只在显式开启时注册
|
|
2771
|
+
// (保留代码路径与自测覆盖,避免“功能静默消失”)。
|
|
2772
|
+
if (hostOptions.registerCommand === true) {
|
|
2773
|
+
registerSubagentCommand(pi, loadAgentDefaults);
|
|
2774
|
+
}
|
|
2775
|
+
registerSubagentRenderers(pi);
|
|
2776
|
+
registerSubagentStopTool(pi, {
|
|
2777
|
+
resolveRunningByName,
|
|
2778
|
+
closeSurface,
|
|
2779
|
+
updateWidget,
|
|
2780
|
+
stopMember: (running) => {
|
|
2781
|
+
// 持久成员的 watcher 不兑终态(进程常驻):stop 入口直接终止 + offline,
|
|
2782
|
+
// 并移除运行态登记;session/loadout/roster 保留(offline 状态)供追溯。
|
|
2783
|
+
killMemberProcess(running);
|
|
2784
|
+
markMemberOffline(running, "stopped");
|
|
2785
|
+
runningSubagents.delete(running.id);
|
|
2786
|
+
removeRuntimeRecord(running.runtimeFile, running.id);
|
|
2787
|
+
running.abortController?.abort();
|
|
2788
|
+
},
|
|
2789
|
+
});
|
|
2790
|
+
registerTeamDispatchTool(pi, {
|
|
2791
|
+
runningSubagents,
|
|
2792
|
+
getArtifactDir,
|
|
2793
|
+
rosterPath,
|
|
2794
|
+
findRosterMember,
|
|
2795
|
+
upsertRosterMember,
|
|
2796
|
+
countSessionEntryLines,
|
|
2797
|
+
});
|
|
2798
|
+
}
|