@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,246 @@
|
|
|
1
|
+
import { normalizeSubagentName } from "./names.ts";
|
|
2
|
+
|
|
3
|
+
// ── 简单 dependsOn v1:依赖/完成注册表 ─────────────────────────────────────
|
|
4
|
+
// 按规范化 name 为 key,登记"某名字的子代理已确定、即将启动"的 deferred
|
|
5
|
+
// completion promise。同一 assistant message 的 sibling tool call 由 pi 并行
|
|
6
|
+
// 执行(Promise.all),但各 execute 的同步前缀按数组顺序串行运行——因此
|
|
7
|
+
// spawn/resume 在保留名字的同一同步段里登记完成记录,后执行的依赖消费者
|
|
8
|
+
// 一定能找到前置 sibling 的 pending 记录并等待它,不会出现"找不到依赖"或
|
|
9
|
+
// 重复登记。不使用全局 running 数量猜测批次分母,等待只指向显式的完成
|
|
10
|
+
// promise。
|
|
11
|
+
//
|
|
12
|
+
// 终态语义:completed / failed / cancelled / handed-off 都会 settle 记录;
|
|
13
|
+
// launch 失败由调用方 settle 为 failed,等待者绝不永久挂起。
|
|
14
|
+
// 存放在 globalThis symbol 上:/reload 重载模块后 pending 记录不丢,
|
|
15
|
+
// 恢复路径(recoverRuntimeSubagents)完成的子代理仍能唤醒等待者。
|
|
16
|
+
|
|
17
|
+
export type DependencyOutcomeStatus = "completed" | "failed" | "cancelled" | "handed-off";
|
|
18
|
+
|
|
19
|
+
/** 依赖目标的终态结果;settle 时写入记录并广播给所有等待者。 */
|
|
20
|
+
export interface DependencyOutcome {
|
|
21
|
+
name: string;
|
|
22
|
+
status: DependencyOutcomeStatus;
|
|
23
|
+
/** 完成时的最终回复文本;失败时可能为空(错误在 errorMessage)。 */
|
|
24
|
+
summary?: string;
|
|
25
|
+
exitCode?: number;
|
|
26
|
+
sessionFile?: string;
|
|
27
|
+
errorMessage?: string;
|
|
28
|
+
/** 结果来源:watcher 终态 / 历史会话文件 / launch 失败 / 依赖失败连带拦截。 */
|
|
29
|
+
source: "watcher" | "session-file" | "launch-failure" | "dependency-blocked";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface CompletionRecord {
|
|
33
|
+
name: string;
|
|
34
|
+
/** true = 交互式(演示 pane)子代理:没有可等待的硬屏障终态,消费者必须拒绝。 */
|
|
35
|
+
interactive: boolean;
|
|
36
|
+
/** true = 持久团队成员:无进程级终态,同样拒绝(文案区分)。 */
|
|
37
|
+
member?: boolean;
|
|
38
|
+
promise: Promise<DependencyOutcome>;
|
|
39
|
+
settle: (outcome: DependencyOutcome) => void;
|
|
40
|
+
settled: boolean;
|
|
41
|
+
outcome: DependencyOutcome | null;
|
|
42
|
+
settledAt: number;
|
|
43
|
+
/** 本 spawn 当前正在等待的上游依赖名(环检测用;仅 pending 期间有效)。 */
|
|
44
|
+
waitingOn: Set<string>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// 已 settle 记录的缓存期:期间等待者直接命中缓存,不重读磁盘。终态本身
|
|
48
|
+
// 不可变,过期条目只为防 Map 无限增长,到期后照旧能从会话注册表兜底解析。
|
|
49
|
+
const SETTLED_TTL_MS = 10 * 60_000;
|
|
50
|
+
|
|
51
|
+
const COMPLETIONS_KEY = Symbol.for("pi-subagents/dependency-completions");
|
|
52
|
+
const completions: Map<string, CompletionRecord> =
|
|
53
|
+
((globalThis as any)[COMPLETIONS_KEY] ??= new Map<string, CompletionRecord>());
|
|
54
|
+
|
|
55
|
+
/** 名字规范化与 steer/resume/spawn 寻址同一规则。 */
|
|
56
|
+
export function normalizeDependencyName(name: string): string {
|
|
57
|
+
return normalizeSubagentName(name ?? "", "");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 名字确定、真正启动前登记完成记录(spawn/resume 在保留名字的同一同步段
|
|
62
|
+
* 调用)。幂等:同名 pending 记录直接复用;已 settle 的旧记录被新记录替换
|
|
63
|
+
* (同一名字再次 resume)。顺带清理过期 settled 条目防泄漏。
|
|
64
|
+
*/
|
|
65
|
+
export function announceCompletion(
|
|
66
|
+
name: string,
|
|
67
|
+
opts?: { interactive?: boolean; member?: boolean },
|
|
68
|
+
): CompletionRecord {
|
|
69
|
+
const key = normalizeDependencyName(name);
|
|
70
|
+
const existing = completions.get(key);
|
|
71
|
+
if (existing && !existing.settled) return existing;
|
|
72
|
+
|
|
73
|
+
let resolveOutcome!: (outcome: DependencyOutcome) => void;
|
|
74
|
+
const promise = new Promise<DependencyOutcome>((resolve) => {
|
|
75
|
+
resolveOutcome = resolve;
|
|
76
|
+
});
|
|
77
|
+
const record: CompletionRecord = {
|
|
78
|
+
name: key,
|
|
79
|
+
interactive: opts?.interactive ?? false,
|
|
80
|
+
...(opts?.member ? { member: true } : {}),
|
|
81
|
+
promise,
|
|
82
|
+
settle: () => {},
|
|
83
|
+
settled: false,
|
|
84
|
+
outcome: null,
|
|
85
|
+
settledAt: 0,
|
|
86
|
+
waitingOn: new Set<string>(),
|
|
87
|
+
};
|
|
88
|
+
record.settle = (outcome) => {
|
|
89
|
+
if (record.settled) return;
|
|
90
|
+
record.settled = true;
|
|
91
|
+
record.outcome = outcome;
|
|
92
|
+
record.settledAt = Date.now();
|
|
93
|
+
resolveOutcome(outcome);
|
|
94
|
+
};
|
|
95
|
+
completions.set(key, record);
|
|
96
|
+
|
|
97
|
+
const now = Date.now();
|
|
98
|
+
for (const [entryKey, entry] of completions) {
|
|
99
|
+
if (entry.settled && now - entry.settledAt > SETTLED_TTL_MS) completions.delete(entryKey);
|
|
100
|
+
}
|
|
101
|
+
return record;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function getCompletionRecord(name: string): CompletionRecord | null {
|
|
105
|
+
return completions.get(normalizeDependencyName(name)) ?? null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** settle 指定名字的完成记录;记录不存在或已 settle 时为无害 no-op。 */
|
|
109
|
+
export function settleCompletion(name: string, outcome: DependencyOutcome): void {
|
|
110
|
+
const record = completions.get(normalizeDependencyName(name));
|
|
111
|
+
record?.settle(outcome);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* 从 watcher/launch 的结果映射终态并 settle:exitCode≠0 或带 errorMessage →
|
|
116
|
+
* failed;handedOff → handed-off;调用方可用 status 覆盖(如硬屏障取消)。
|
|
117
|
+
* 各条路径(spawn/resume/reload 恢复)共用,避免映射逻辑漂移。
|
|
118
|
+
*/
|
|
119
|
+
export function settleCompletionFromResult(
|
|
120
|
+
name: string,
|
|
121
|
+
result: { exitCode: number; summary?: string; sessionFile?: string; errorMessage?: string; handedOff?: boolean },
|
|
122
|
+
opts?: { status?: DependencyOutcomeStatus; source?: DependencyOutcome["source"] },
|
|
123
|
+
): void {
|
|
124
|
+
const status: DependencyOutcomeStatus =
|
|
125
|
+
opts?.status ??
|
|
126
|
+
(result.handedOff
|
|
127
|
+
? "handed-off"
|
|
128
|
+
: result.exitCode !== 0 || !!result.errorMessage
|
|
129
|
+
? "failed"
|
|
130
|
+
: "completed");
|
|
131
|
+
settleCompletion(name, {
|
|
132
|
+
name,
|
|
133
|
+
status,
|
|
134
|
+
...(result.summary ? { summary: result.summary } : {}),
|
|
135
|
+
exitCode: result.exitCode,
|
|
136
|
+
...(result.sessionFile ? { sessionFile: result.sessionFile } : {}),
|
|
137
|
+
...(result.errorMessage ? { errorMessage: result.errorMessage } : {}),
|
|
138
|
+
source: opts?.source ?? "watcher",
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* 等待指定名字到达终态。settled 记录直接返回缓存结果;pending 记录等待其
|
|
144
|
+
* completion promise。signal 中止时以 Error reject(调用方转结构化异常),
|
|
145
|
+
* 不影响记录本身的后续 settle。
|
|
146
|
+
*/
|
|
147
|
+
export async function waitForCompletion(
|
|
148
|
+
name: string,
|
|
149
|
+
signal?: AbortSignal,
|
|
150
|
+
): Promise<DependencyOutcome> {
|
|
151
|
+
const record = getCompletionRecord(name);
|
|
152
|
+
if (!record) throw new Error(`No completion record registered for "${name}"`);
|
|
153
|
+
if (record.settled && record.outcome) return record.outcome;
|
|
154
|
+
if (signal?.aborted) throw new Error(`Aborted before waiting for dependency "${record.name}"`);
|
|
155
|
+
if (!signal) return record.promise;
|
|
156
|
+
|
|
157
|
+
let onAbort: () => void;
|
|
158
|
+
const abortPromise = new Promise<never>((_, reject) => {
|
|
159
|
+
onAbort = () => reject(new Error(`Aborted while waiting for dependency "${record.name}"`));
|
|
160
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
161
|
+
});
|
|
162
|
+
try {
|
|
163
|
+
return await Promise.race([record.promise, abortPromise]);
|
|
164
|
+
} finally {
|
|
165
|
+
signal.removeEventListener("abort", onAbort!);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** 测试钩子:清空注册表,测试之间互不残留。 */
|
|
170
|
+
export function resetCompletions(): void {
|
|
171
|
+
completions.clear();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ── 同消息 sibling 互相 dependsOn 的环检测 ───────────────────────────────
|
|
175
|
+
// 每条 pending 记录携带 waitingOn(它正在等的上游名);等待前先从目标名沿
|
|
176
|
+
// pending 记录的 waitingOn 边回溯,能回到自己即成环——两条(或多条)sibling
|
|
177
|
+
// 互等会在硬屏障里永久悬挂,必须在等待前结构化失败。settled 记录不再等待
|
|
178
|
+
// 任何上游,不构成环的节点。
|
|
179
|
+
|
|
180
|
+
/** 从 firstDep 沿 pending 记录的 waitingOn 边搜索,回到 ownName 时返回环路径。 */
|
|
181
|
+
export function findDependencyCycle(ownName: string, firstDep: string): string[] | null {
|
|
182
|
+
const visited = new Set<string>();
|
|
183
|
+
const queue: Array<{ name: string; path: string[] }> = [
|
|
184
|
+
{ name: firstDep, path: [ownName, firstDep] },
|
|
185
|
+
];
|
|
186
|
+
while (queue.length > 0) {
|
|
187
|
+
const { name, path } = queue.shift()!;
|
|
188
|
+
if (name === ownName) return path;
|
|
189
|
+
if (visited.has(name)) continue;
|
|
190
|
+
visited.add(name);
|
|
191
|
+
const record = completions.get(name);
|
|
192
|
+
if (!record || record.settled) continue;
|
|
193
|
+
for (const next of record.waitingOn) {
|
|
194
|
+
queue.push({ name: next, path: [...path, next] });
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ── 依赖异常结构化(route_exception 同款 details 风格)──────────────────────
|
|
201
|
+
|
|
202
|
+
export type DependencyExceptionKind =
|
|
203
|
+
| "self_dependency"
|
|
204
|
+
| "unknown_dependency"
|
|
205
|
+
| "interactive_dependency"
|
|
206
|
+
| "member_dependency"
|
|
207
|
+
| "dependency_cycle"
|
|
208
|
+
| "dependency_failed"
|
|
209
|
+
| "dependency_cancelled"
|
|
210
|
+
| "dependency_handed_off"
|
|
211
|
+
| "running_without_waiter"
|
|
212
|
+
| "aborted";
|
|
213
|
+
|
|
214
|
+
export interface DependencyException {
|
|
215
|
+
kind: DependencyExceptionKind;
|
|
216
|
+
/** 触发异常的规范化依赖名。 */
|
|
217
|
+
dependency: string;
|
|
218
|
+
message: string;
|
|
219
|
+
/** 上游结果细节(失败/取消时可带,便于编排者决定重试或放弃)。 */
|
|
220
|
+
upstream?: {
|
|
221
|
+
exitCode?: number;
|
|
222
|
+
errorMessage?: string;
|
|
223
|
+
sessionFile?: string;
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* 把依赖终态映射为异常 kind:非 completed 的 outcome 都不允许下游启动。
|
|
229
|
+
* handed-off(reload 移交)没有可等待的真实结果,同样拒绝。
|
|
230
|
+
*/
|
|
231
|
+
export function exceptionKindForOutcome(status: DependencyOutcomeStatus): DependencyExceptionKind {
|
|
232
|
+
if (status === "failed") return "dependency_failed";
|
|
233
|
+
if (status === "cancelled") return "dependency_cancelled";
|
|
234
|
+
return "dependency_handed_off";
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** 依赖异常 → 模型可读的 tool result(content + details),不启动任何进程。 */
|
|
238
|
+
export function dependencyExceptionResult(exception: DependencyException): {
|
|
239
|
+
content: Array<{ type: "text"; text: string }>;
|
|
240
|
+
details: Record<string, unknown>;
|
|
241
|
+
} {
|
|
242
|
+
return {
|
|
243
|
+
content: [{ type: "text", text: exception.message }],
|
|
244
|
+
details: { error: "dependency_exception", dependencyException: exception },
|
|
245
|
+
};
|
|
246
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 可选诊断日志。默认关闭,避免插件正常使用时污染宿主输出。
|
|
3
|
+
* 设置 PI_SUBAGENTS_DEBUG=1 或 true 后记录尽力而为路径的失败原因。
|
|
4
|
+
*/
|
|
5
|
+
export function debugLog(message: string, error?: unknown): void {
|
|
6
|
+
const enabled = process.env.PI_SUBAGENTS_DEBUG === "1" || process.env.PI_SUBAGENTS_DEBUG === "true";
|
|
7
|
+
if (!enabled) return;
|
|
8
|
+
|
|
9
|
+
const suffix = error === undefined
|
|
10
|
+
? ""
|
|
11
|
+
: `: ${error instanceof Error ? error.message : String(error)}`;
|
|
12
|
+
console.error(`[pi-subagents] ${message}${suffix}`);
|
|
13
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
2
|
+
|
|
3
|
+
const ACCENT = "\x1b[38;2;77;163;255m";
|
|
4
|
+
const RST = "\x1b[0m";
|
|
5
|
+
const ICON_GREEN = "\x1b[38;2;126;186;103m";
|
|
6
|
+
const ICON_YELLOW = "\x1b[38;2;214;181;94m";
|
|
7
|
+
const ICON_RED = "\x1b[38;2;224;108;117m";
|
|
8
|
+
const ICON_DIM = "\x1b[38;2;128;128;128m";
|
|
9
|
+
|
|
10
|
+
export function formatElapsed(seconds: number): string {
|
|
11
|
+
if (seconds < 60) return `${seconds}s`;
|
|
12
|
+
const minutes = Math.floor(seconds / 60);
|
|
13
|
+
return `${minutes}m ${seconds % 60}s`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function formatTokens(value: number): string {
|
|
17
|
+
return value < 1000 ? String(value) : value < 10000 ? `${(value / 1000).toFixed(1)}k` : `${Math.round(value / 1000)}k`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function contextWindowFor(model: string | null | undefined): number | undefined {
|
|
21
|
+
if (!model) return undefined;
|
|
22
|
+
const value = model.toLowerCase();
|
|
23
|
+
if (value.includes("claude")) return 200_000;
|
|
24
|
+
if (value.includes("gpt-4.1") || value.includes("gpt-4o")) return 128_000;
|
|
25
|
+
if (value.includes("gemini")) return 1_000_000;
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function formatContextUsage(tokens: number, contextWindow: number | undefined): string {
|
|
30
|
+
if (!contextWindow) return `${formatTokens(tokens)} ctx`;
|
|
31
|
+
const percent = (tokens / contextWindow) * 100;
|
|
32
|
+
const max = contextWindow >= 1_000_000
|
|
33
|
+
? `${(contextWindow / 1_000_000).toFixed(1)}M`
|
|
34
|
+
: `${Math.round(contextWindow / 1000)}k`;
|
|
35
|
+
return `${percent.toFixed(1)}%/${max}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function formatUsageSegments(stats: {
|
|
39
|
+
inputTokens: number;
|
|
40
|
+
outputTokens: number;
|
|
41
|
+
cacheReadTokens: number;
|
|
42
|
+
cacheWriteTokens: number;
|
|
43
|
+
cost: number;
|
|
44
|
+
}): string[] {
|
|
45
|
+
const segments: string[] = [];
|
|
46
|
+
if (stats.inputTokens) segments.push(`↑${formatTokens(stats.inputTokens)}`);
|
|
47
|
+
if (stats.outputTokens) segments.push(`↓${formatTokens(stats.outputTokens)}`);
|
|
48
|
+
if (stats.cacheReadTokens) segments.push(`R${formatTokens(stats.cacheReadTokens)}`);
|
|
49
|
+
if (stats.cacheWriteTokens) segments.push(`W${formatTokens(stats.cacheWriteTokens)}`);
|
|
50
|
+
if (stats.cost) segments.push(`$${stats.cost.toFixed(3)}`);
|
|
51
|
+
return segments;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function widgetIcon(kind: "active" | "running" | "stalled" | "waiting" | "starting"): string {
|
|
55
|
+
switch (kind) {
|
|
56
|
+
case "active":
|
|
57
|
+
case "running":
|
|
58
|
+
return `${ICON_YELLOW}⟳${RST}`;
|
|
59
|
+
case "stalled":
|
|
60
|
+
return `${ICON_RED}⟳${RST}`;
|
|
61
|
+
default:
|
|
62
|
+
return `${ICON_DIM}○${RST}`;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function borderLine(left: string, right: string, width: number): string {
|
|
67
|
+
if (width <= 0) return "";
|
|
68
|
+
if (width === 1) return `${ACCENT}│${RST}`;
|
|
69
|
+
const contentWidth = Math.max(0, width - 2);
|
|
70
|
+
const rightWidth = visibleWidth(right);
|
|
71
|
+
if (rightWidth >= contentWidth) {
|
|
72
|
+
const truncated = truncateToWidth(right, contentWidth);
|
|
73
|
+
return `${ACCENT}│${RST}${truncated}${" ".repeat(Math.max(0, contentWidth - visibleWidth(truncated)))}${ACCENT}│${RST}`;
|
|
74
|
+
}
|
|
75
|
+
const truncatedLeft = truncateToWidth(left, contentWidth - rightWidth);
|
|
76
|
+
const padding = Math.max(0, contentWidth - visibleWidth(truncatedLeft) - rightWidth);
|
|
77
|
+
return `${ACCENT}│${RST}${truncatedLeft}${" ".repeat(padding)}${right}${ACCENT}│${RST}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function borderTop(title: string, info: string, width: number): string {
|
|
81
|
+
if (width <= 0) return "";
|
|
82
|
+
if (width === 1) return `${ACCENT}╭${RST}`;
|
|
83
|
+
const inner = Math.max(0, width - 2);
|
|
84
|
+
const titlePart = `─ ${title} `;
|
|
85
|
+
const infoPart = ` ${info} ─`;
|
|
86
|
+
const fill = "─".repeat(Math.max(0, inner - titlePart.length - infoPart.length));
|
|
87
|
+
return `${ACCENT}╭${`${titlePart}${fill}${infoPart}`.slice(0, inner).padEnd(inner, "─")}╮${RST}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function borderBottom(width: number): string {
|
|
91
|
+
if (width <= 0) return "";
|
|
92
|
+
if (width === 1) return `${ACCENT}╰${RST}`;
|
|
93
|
+
return `${ACCENT}╰${"─".repeat(Math.max(0, width - 2))}╯${RST}`;
|
|
94
|
+
}
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* headless 表面 —— 独立进程 IO 原语。自动(auto-exit)子代理作为独立
|
|
3
|
+
* `pi --mode rpc` 进程后台运行,不在 herdr/tmux 创建 pane/tab。
|
|
4
|
+
*
|
|
5
|
+
* 与 pane 表面(herdr.ts/tmux.ts,读屏 + sentinel)不同,headless 的
|
|
6
|
+
* 生命周期信号全部来自 RPC 协议与进程状态:
|
|
7
|
+
* - prompt/steer:stdin JSONL 命令(`prompt` + streamingBehavior:"steer"
|
|
8
|
+
* 同时覆盖运行中排队与空闲新 run 两种状态,见 pi docs/rpc.md);
|
|
9
|
+
* - 终态:进程退出。subagent-done.ts 的 auto-exit(agent_end → ctx.shutdown())
|
|
10
|
+
* 在 RPC 模式会置 shutdownRequested 并 process.exit;ask_question 等待
|
|
11
|
+
* 与等待孙代理结果时 shutdown 被抑制,进程保持存活,与 pane 语义对齐;
|
|
12
|
+
* - 错误路径:仍复用 `${sessionFile}.exit` sidecar(subagent-done 写入);
|
|
13
|
+
* - ask_question:仍复用 `${sessionFile}.ask` sidecar(与表面无关)。
|
|
14
|
+
*
|
|
15
|
+
* Windows 注意:直接 spawn("pi") 会 ENOENT、spawn("pi.cmd") 因 CVE-2024-27980
|
|
16
|
+
* 修复抛 EINVAL,因此与官方 RpcClient 一致,用 `spawn(process.execPath, [entry])`
|
|
17
|
+
* 直接以 node 执行 pi 入口脚本;入口默认取宿主 pi 进程自身的 process.argv[1]
|
|
18
|
+
* (子代理与宿主 pi 同版本同入口),允许 PI_SUBAGENT_PI_ENTRY 显式覆盖。
|
|
19
|
+
*
|
|
20
|
+
* RPC stdout 是严格 LF JSONL:不能用 readline(它按 U+2028/U+2029 也分帧,
|
|
21
|
+
* 会撕裂 JSON 字符串),帧解析用下方 attachJsonlReader(语义对齐官方
|
|
22
|
+
* dist/modes/rpc/jsonl.js)。stdout/stderr 全程持续消费,防止 pipe 背压
|
|
23
|
+
* 阻塞子进程。
|
|
24
|
+
*/
|
|
25
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
26
|
+
import { existsSync } from "node:fs";
|
|
27
|
+
import { StringDecoder } from "node:string_decoder";
|
|
28
|
+
import { join } from "node:path";
|
|
29
|
+
import { debugLog } from "./diagnostics.ts";
|
|
30
|
+
|
|
31
|
+
const SUBAGENT_ENV_PREFIX = "PI_SUBAGENT_";
|
|
32
|
+
const PROPAGATED_SUBAGENT_ENV = "PI_SUBAGENT_PI_ENTRY";
|
|
33
|
+
|
|
34
|
+
function isLifecycleSubagentEnvKey(key: string): boolean {
|
|
35
|
+
const normalized = key.toUpperCase();
|
|
36
|
+
return normalized.startsWith(SUBAGENT_ENV_PREFIX) && normalized !== PROPAGATED_SUBAGENT_ENV;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 为新子代理构造表面注入环境。
|
|
41
|
+
*
|
|
42
|
+
* pane 的 shell 会先继承父进程环境,因此不能只在“需要时”设置标志;
|
|
43
|
+
* 先把父子生命周期、团队授权和身份变量显式清空,再叠加本次启动值。
|
|
44
|
+
* `PI_SUBAGENT_PI_ENTRY` 是唯一有意向下传播的变量,用于嵌套 spawn 继续
|
|
45
|
+
* 使用同一个 pi 入口(测试 stub 也依赖此路径)。
|
|
46
|
+
*/
|
|
47
|
+
export function createSubagentLaunchEnv(
|
|
48
|
+
overrides: Record<string, string> = {},
|
|
49
|
+
): Record<string, string> {
|
|
50
|
+
const cleared: Record<string, string> = {};
|
|
51
|
+
for (const key of Object.keys(process.env)) {
|
|
52
|
+
if (isLifecycleSubagentEnvKey(key)) cleared[key] = "";
|
|
53
|
+
}
|
|
54
|
+
return { ...cleared, ...overrides };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 为 headless 子进程构造真正的环境。
|
|
59
|
+
* 与 pane 不同,这里可以直接从继承基线删除生命周期变量,避免空值变量
|
|
60
|
+
* 被孙代理当成父代理授予的 member/team 权限。
|
|
61
|
+
*/
|
|
62
|
+
export function buildSubagentProcessEnv(
|
|
63
|
+
overrides: Record<string, string> = {},
|
|
64
|
+
): NodeJS.ProcessEnv {
|
|
65
|
+
const inherited: NodeJS.ProcessEnv = { ...process.env };
|
|
66
|
+
for (const key of Object.keys(inherited)) {
|
|
67
|
+
if (isLifecycleSubagentEnvKey(key)) delete inherited[key];
|
|
68
|
+
}
|
|
69
|
+
return { ...inherited, ...overrides };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** 严格 LF 分帧的 JSONL 读取器。接受可选 \r\n;不按 Unicode 分隔符分帧。 */
|
|
73
|
+
export function attachJsonlReader(stream: NodeJS.ReadableStream, onLine: (line: string) => void): void {
|
|
74
|
+
const decoder = new StringDecoder("utf8");
|
|
75
|
+
let buffer = "";
|
|
76
|
+
stream.on("data", (chunk: string | Buffer) => {
|
|
77
|
+
buffer += typeof chunk === "string" ? chunk : decoder.write(chunk);
|
|
78
|
+
for (;;) {
|
|
79
|
+
const index = buffer.indexOf("\n");
|
|
80
|
+
if (index === -1) return;
|
|
81
|
+
let line = buffer.slice(0, index);
|
|
82
|
+
buffer = buffer.slice(index + 1);
|
|
83
|
+
if (line.endsWith("\r")) line = line.slice(0, -1);
|
|
84
|
+
onLine(line);
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
stream.on("end", () => {
|
|
88
|
+
buffer += decoder.end();
|
|
89
|
+
if (buffer.length > 0) {
|
|
90
|
+
onLine(buffer.endsWith("\r") ? buffer.slice(0, -1) : buffer);
|
|
91
|
+
buffer = "";
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** 序列化单条 JSONL 记录(写入方保证 LF 结尾;JSON.stringify 不产生裸 LF)。 */
|
|
97
|
+
export function serializeJsonLine(value: unknown): string {
|
|
98
|
+
return `${JSON.stringify(value)}\n`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* 解析 pi 入口脚本路径。优先级:
|
|
103
|
+
* 1. PI_SUBAGENT_PI_ENTRY(显式覆盖,测试注入 stub 也走这里);
|
|
104
|
+
* 2. 宿主 pi 进程自身的 process.argv[1](子代理与宿主同版本同入口,
|
|
105
|
+
* 且恰好绕开 Windows 上 .cmd shim 不可 spawn 的问题);
|
|
106
|
+
* 找不到时返回 null,由调用方报错——不做 PATH 里 spawn "pi" 的尝试,
|
|
107
|
+
* 那在 Windows 上不可靠(ENOENT/EINVAL)。
|
|
108
|
+
*/
|
|
109
|
+
export function resolvePiEntry(): string | null {
|
|
110
|
+
const override = process.env.PI_SUBAGENT_PI_ENTRY?.trim();
|
|
111
|
+
if (override) return override;
|
|
112
|
+
const self = process.argv[1];
|
|
113
|
+
if (self && /\.(c|m)?js$/i.test(self) && existsSync(self)) return self;
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* headless 运行记录存放在 RunningSubagent.surface 的形式:`headless:<pid>`。
|
|
119
|
+
* pane 记录仍是 herdr/tmux 的 pane id,两种前缀天然区分。
|
|
120
|
+
*/
|
|
121
|
+
export const HEADLESS_SURFACE_PREFIX = "headless:";
|
|
122
|
+
|
|
123
|
+
export function formatHeadlessSurface(pid: number): string {
|
|
124
|
+
return `${HEADLESS_SURFACE_PREFIX}${pid}`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function parseHeadlessSurface(surface: string): number | null {
|
|
128
|
+
if (!surface.startsWith(HEADLESS_SURFACE_PREFIX)) return null;
|
|
129
|
+
const pid = Number.parseInt(surface.slice(HEADLESS_SURFACE_PREFIX.length), 10);
|
|
130
|
+
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* PID 存在性探测。ESRCH → 不存在;EPERM → 存在但无权发信号(仍算活着);
|
|
135
|
+
* Windows 上 process.kill(pid, 0) 走 OpenProcess 探测,同样可用。
|
|
136
|
+
*/
|
|
137
|
+
export function isPidAlive(pid: number): boolean {
|
|
138
|
+
try {
|
|
139
|
+
process.kill(pid, 0);
|
|
140
|
+
return true;
|
|
141
|
+
} catch (error: any) {
|
|
142
|
+
return error?.code === "EPERM";
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* 终止一个 headless 子进程:Windows 上先 taskkill /T(树杀,覆盖 RPC 孙进程),
|
|
148
|
+
* 再 direct kill(兑底确保根进程死透)。顺序不能反:direct kill 先杀 node 根
|
|
149
|
+
* 会切断进程树,taskkill /T 随后枚举不到孙进程,留下孤儿 RPC 进程。
|
|
150
|
+
* 非 Windows 只做 direct kill(SIGTERM);treeKill 参数仅供测试注入观察顺序。
|
|
151
|
+
*/
|
|
152
|
+
export function killHeadlessProcessTree(
|
|
153
|
+
child: ChildProcess,
|
|
154
|
+
pid: number | undefined,
|
|
155
|
+
treeKill: (pid: number) => void = terminateHeadlessProcess,
|
|
156
|
+
): void {
|
|
157
|
+
if (pid != null && process.platform === "win32") treeKill(pid);
|
|
158
|
+
try {
|
|
159
|
+
child.kill();
|
|
160
|
+
} catch (error) {
|
|
161
|
+
debugLog(`Could not kill headless child ${pid ?? "unknown"}`, error);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Terminate a recovered headless process when its original ChildProcess
|
|
167
|
+
* handle is unavailable (for example after /reload). On Windows `child.kill`
|
|
168
|
+
* only targets the direct node process, so use the official taskkill tree
|
|
169
|
+
* operation as well to avoid leaving RPC descendants behind.
|
|
170
|
+
*/
|
|
171
|
+
export function terminateHeadlessProcess(pid: number): void {
|
|
172
|
+
if (!Number.isInteger(pid) || pid <= 0 || !isPidAlive(pid)) return;
|
|
173
|
+
if (process.platform === "win32") {
|
|
174
|
+
const systemRoot = process.env.SystemRoot ?? process.env.WINDIR ?? "C:\\Windows";
|
|
175
|
+
const taskkillPath = join(systemRoot, "System32", "taskkill.exe");
|
|
176
|
+
try {
|
|
177
|
+
const killer = spawn(taskkillPath, ["/F", "/T", "/PID", String(pid)], {
|
|
178
|
+
stdio: "ignore",
|
|
179
|
+
windowsHide: true,
|
|
180
|
+
});
|
|
181
|
+
killer.once("error", (error) => {
|
|
182
|
+
debugLog(`Could not taskkill headless process ${pid}`, error);
|
|
183
|
+
});
|
|
184
|
+
killer.unref();
|
|
185
|
+
} catch (error) {
|
|
186
|
+
debugLog(`Could not start taskkill for headless process ${pid}`, error);
|
|
187
|
+
}
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
try {
|
|
191
|
+
process.kill(pid, "SIGTERM");
|
|
192
|
+
} catch (error: any) {
|
|
193
|
+
if (error?.code !== "ESRCH") debugLog(`Could not terminate headless process ${pid}`, error);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export interface HeadlessSpawnOptions {
|
|
198
|
+
/** pi 的 argv(不含入口脚本本身,如 ["--mode","rpc","--session",...]),纯 argv 直传,不经 shell。 */
|
|
199
|
+
args: string[];
|
|
200
|
+
cwd?: string;
|
|
201
|
+
/** 叠加在 process.env 之上的环境变量(PI_SUBAGENT_* 等)。 */
|
|
202
|
+
env?: Record<string, string>;
|
|
203
|
+
/** 覆盖 pi 入口(默认 resolvePiEntry())。 */
|
|
204
|
+
entry?: string;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export interface HeadlessExitInfo {
|
|
208
|
+
code: number | null;
|
|
209
|
+
signal: NodeJS.Signals | null;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* 一个独立 pi --mode rpc 子进程的受控句柄。
|
|
214
|
+
* stdout 持续分帧消费(onEvent 可为空,事件仍被读取丢弃,防背压);
|
|
215
|
+
* stderr 持续消费(限量 debugLog);stdin 写入在进程死后静默丢弃
|
|
216
|
+
* (竞态窗口:调用方在收到 exit 前发的命令)。
|
|
217
|
+
*/
|
|
218
|
+
export class HeadlessChild {
|
|
219
|
+
readonly child: ChildProcess;
|
|
220
|
+
readonly pid: number | undefined;
|
|
221
|
+
exited = false;
|
|
222
|
+
exitInfo: HeadlessExitInfo | null = null;
|
|
223
|
+
/** 初始 prompt 被 RPC 侧拒绝时的错误(preflight 失败,如无可用模型/凭据)。 */
|
|
224
|
+
promptError: string | null = null;
|
|
225
|
+
/** 进程退出(或 spawn 失败)时 resolve。 */
|
|
226
|
+
readonly exitPromise: Promise<HeadlessExitInfo>;
|
|
227
|
+
/** stdin 已断(子进程提前退出)后收到的写入计数,仅供诊断。 */
|
|
228
|
+
private droppedWrites = 0;
|
|
229
|
+
private stderrBytes = 0;
|
|
230
|
+
private readonly onEvent: ((event: Record<string, unknown>) => void) | undefined;
|
|
231
|
+
private static readonly STDERR_LOG_LIMIT = 8 * 1024;
|
|
232
|
+
|
|
233
|
+
constructor(
|
|
234
|
+
options: HeadlessSpawnOptions & { entry: string },
|
|
235
|
+
onEvent?: (event: Record<string, unknown>) => void,
|
|
236
|
+
) {
|
|
237
|
+
this.onEvent = onEvent;
|
|
238
|
+
this.child = spawn(process.execPath, [options.entry, ...options.args], {
|
|
239
|
+
cwd: options.cwd,
|
|
240
|
+
env: buildSubagentProcessEnv(options.env),
|
|
241
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
242
|
+
windowsHide: true,
|
|
243
|
+
});
|
|
244
|
+
this.pid = this.child.pid;
|
|
245
|
+
|
|
246
|
+
// stdout 必须持续消费:即使调用方不关心事件,也不能让 pipe 满阻塞子进程。
|
|
247
|
+
attachJsonlReader(this.child.stdout!, (line) => {
|
|
248
|
+
if (!line.trim()) return;
|
|
249
|
+
if (!this.onEvent) return;
|
|
250
|
+
try {
|
|
251
|
+
this.onEvent(JSON.parse(line) as Record<string, unknown>);
|
|
252
|
+
} catch (error) {
|
|
253
|
+
// 非 JSON 行(理论不应出现)不致命,记录后继续读。
|
|
254
|
+
debugLog(`headless pid=${this.pid}: unparseable stdout line`, error);
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
// stderr 同理必须持续消费,只限量记日志,防止背压死锁。
|
|
259
|
+
this.child.stderr!.on("data", (chunk: string | Buffer) => {
|
|
260
|
+
this.stderrBytes += typeof chunk === "string" ? Buffer.byteLength(chunk) : chunk.length;
|
|
261
|
+
if (this.stderrBytes <= HeadlessChild.STDERR_LOG_LIMIT) {
|
|
262
|
+
debugLog(`headless pid=${this.pid} stderr`, String(chunk).slice(0, 500));
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
this.exitPromise = new Promise<HeadlessExitInfo>((resolve) => {
|
|
267
|
+
this.child.once("exit", (code, signal) => {
|
|
268
|
+
this.exited = true;
|
|
269
|
+
this.exitInfo = { code, signal };
|
|
270
|
+
resolve(this.exitInfo);
|
|
271
|
+
});
|
|
272
|
+
this.child.once("error", (error) => {
|
|
273
|
+
// spawn 失败(如入口不存在):按 exit 兑现,exitInfo.code 置 -1 供上层报错。
|
|
274
|
+
if (!this.exited) {
|
|
275
|
+
debugLog(`headless spawn error (entry=${options.entry})`, error);
|
|
276
|
+
this.exited = true;
|
|
277
|
+
this.exitInfo = { code: -1, signal: null };
|
|
278
|
+
resolve(this.exitInfo);
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
// 子进程退出与写入的竞态会产生 EPIPE(标志位更新前发送的命令);
|
|
282
|
+
// 吞掉异步 error 事件,避免它变成未捕获异常——真实场景中子代理刚
|
|
283
|
+
// 结束时 steer 也会撞上这个窗口。
|
|
284
|
+
this.child.stdin?.on("error", () => {
|
|
285
|
+
this.droppedWrites += 1;
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** 写一条 RPC 命令到 stdin。进程已退出时丢弃并计数,不抛错。 */
|
|
291
|
+
send(command: Record<string, unknown>): void {
|
|
292
|
+
if (this.exited || !this.child.stdin?.writable) {
|
|
293
|
+
this.droppedWrites += 1;
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
this.child.stdin.write(serializeJsonLine(command));
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* 向子代理投递一条消息:运行中排队为 steering,空闲时开启新 run。
|
|
301
|
+
* 用 `prompt` + streamingBehavior:"steer" 而非裸 `steer` 命令,因为后者
|
|
302
|
+
* 只入队不触发,空闲(如等待 ask 回复)时消息会滞留到下一次 prompt。
|
|
303
|
+
*/
|
|
304
|
+
steer(message: string): void {
|
|
305
|
+
this.send({ type: "prompt", message, streamingBehavior: "steer" });
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** 请求中止当前 agent run(best-effort;真正终止由调用方决定是否 kill)。 */
|
|
309
|
+
abort(): void {
|
|
310
|
+
this.send({ type: "abort" });
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** 关闭 stdin 并终止进程。用于取消与会话关闭;对已退出进程是 no-op。 */
|
|
314
|
+
kill(): void {
|
|
315
|
+
if (this.exited) return;
|
|
316
|
+
const pid = this.pid;
|
|
317
|
+
try {
|
|
318
|
+
this.child.stdin?.end();
|
|
319
|
+
} catch {}
|
|
320
|
+
killHeadlessProcessTree(this.child, pid);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** 诊断:被丢弃的 stdin 写入数。 */
|
|
324
|
+
get droppedWriteCount(): number {
|
|
325
|
+
return this.droppedWrites;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** 启动一个 headless pi 子进程。入口解析失败直接抛错(不在 PATH 上碰运气)。 */
|
|
330
|
+
export function spawnHeadlessPi(
|
|
331
|
+
options: HeadlessSpawnOptions,
|
|
332
|
+
onEvent?: (event: Record<string, unknown>) => void,
|
|
333
|
+
): HeadlessChild {
|
|
334
|
+
const entry = options.entry ?? resolvePiEntry();
|
|
335
|
+
if (!entry) {
|
|
336
|
+
throw new Error(
|
|
337
|
+
"Cannot resolve the pi entry script for headless sub-agents. " +
|
|
338
|
+
"Set PI_SUBAGENT_PI_ENTRY to the pi cli .js path.",
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
return new HeadlessChild({ ...options, entry }, onEvent);
|
|
342
|
+
}
|