@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.
Files changed (129) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +45 -0
  3. package/assembler.ts +127 -0
  4. package/config.ts +201 -0
  5. package/i18n.ts +693 -0
  6. package/index.ts +99 -0
  7. package/menu/items.ts +264 -0
  8. package/menu/panels.ts +91 -0
  9. package/menu/settings-list.ts +84 -0
  10. package/menu/theme.ts +31 -0
  11. package/menu/toolkit-menu.ts +139 -0
  12. package/module.ts +156 -0
  13. package/modules/eyes/chain.ts +173 -0
  14. package/modules/eyes/config.ts +319 -0
  15. package/modules/eyes/index.ts +161 -0
  16. package/modules/eyes/menu.ts +828 -0
  17. package/modules/eyes/pi-model-backend.ts +245 -0
  18. package/modules/eyes/resilience.ts +161 -0
  19. package/modules/eyes/vision-bridge.ts +232 -0
  20. package/modules/eyes/vision-cache.ts +86 -0
  21. package/modules/eyes/vision-json.ts +19 -0
  22. package/modules/eyes/vision-preprocess.ts +192 -0
  23. package/modules/eyes/vision-probe.ts +21 -0
  24. package/modules/eyes/vision-prompt.ts +90 -0
  25. package/modules/eyes/vision-tool.ts +59 -0
  26. package/modules/eyes/vision-types.ts +33 -0
  27. package/modules/index.ts +10 -0
  28. package/modules/subagents/agents/researcher.md +51 -0
  29. package/modules/subagents/agents/scout.md +40 -0
  30. package/modules/subagents/agents/worker.md +79 -0
  31. package/modules/subagents/config.json.example +6 -0
  32. package/modules/subagents/config.ts +144 -0
  33. package/modules/subagents/index.ts +91 -0
  34. package/modules/subagents/menu.ts +388 -0
  35. package/modules/subagents/src/activity.ts +511 -0
  36. package/modules/subagents/src/agents.ts +126 -0
  37. package/modules/subagents/src/command.ts +37 -0
  38. package/modules/subagents/src/dependencies.ts +246 -0
  39. package/modules/subagents/src/diagnostics.ts +13 -0
  40. package/modules/subagents/src/display.ts +94 -0
  41. package/modules/subagents/src/headless.ts +342 -0
  42. package/modules/subagents/src/herdr.ts +203 -0
  43. package/modules/subagents/src/index.ts +2798 -0
  44. package/modules/subagents/src/inspect-tool.ts +839 -0
  45. package/modules/subagents/src/launch-config.ts +196 -0
  46. package/modules/subagents/src/layout-budget.ts +26 -0
  47. package/modules/subagents/src/list-tool.ts +292 -0
  48. package/modules/subagents/src/message-tool.ts +808 -0
  49. package/modules/subagents/src/names.ts +17 -0
  50. package/modules/subagents/src/pane-layout.ts +49 -0
  51. package/modules/subagents/src/params.ts +145 -0
  52. package/modules/subagents/src/renderers.ts +181 -0
  53. package/modules/subagents/src/result.ts +43 -0
  54. package/modules/subagents/src/retention.ts +114 -0
  55. package/modules/subagents/src/route-error.ts +212 -0
  56. package/modules/subagents/src/routing.ts +235 -0
  57. package/modules/subagents/src/runtime-registry.ts +159 -0
  58. package/modules/subagents/src/session.ts +801 -0
  59. package/modules/subagents/src/status.ts +513 -0
  60. package/modules/subagents/src/stop-tool.ts +235 -0
  61. package/modules/subagents/src/subagent-done.ts +590 -0
  62. package/modules/subagents/src/subagent-tool.ts +1155 -0
  63. package/modules/subagents/src/surface.ts +355 -0
  64. package/modules/subagents/src/team-dispatch-tool.ts +219 -0
  65. package/modules/subagents/src/team.ts +232 -0
  66. package/modules/subagents/src/tmux.ts +210 -0
  67. package/modules/subagents/src/tools/safe-bash.ts +72 -0
  68. package/modules/subagents/src/types.ts +131 -0
  69. package/modules/tui/adapter/provider-usage.ts +143 -0
  70. package/modules/tui/config.ts +50 -0
  71. package/modules/tui/index.ts +120 -0
  72. package/modules/tui/kernel/pkg/shared/grok-subscription.ts +169 -0
  73. package/modules/tui/kernel/pkg/shared/official-subscription.ts +237 -0
  74. package/modules/tui/kernel/pkg/shared/provider-catalog.ts +367 -0
  75. package/modules/tui/kernel/pkg/shared/provider-contracts.ts +150 -0
  76. package/modules/tui/kernel/pkg/shared/provider-display.ts +55 -0
  77. package/modules/tui/kernel/pkg/shared/provider-parsers.ts +171 -0
  78. package/modules/tui/kernel/pkg/shared/volcengine.ts +191 -0
  79. package/modules/tui/kernel/pkg/shared/zhipu.ts +149 -0
  80. package/modules/tui/kernel/pkg/usage-core/index.ts +335 -0
  81. package/modules/tui/kernel/pkg/usage-core/provider-routes.ts +187 -0
  82. package/modules/tui/kernel/pkg/usage-node/index.ts +635 -0
  83. package/modules/tui/kernel/pkg/usage-node/provider-usage.ts +388 -0
  84. package/modules/tui/kernel/usage-core.ts +2 -0
  85. package/modules/tui/kernel/usage-node.ts +2 -0
  86. package/modules/tui/menu.ts +418 -0
  87. package/modules/tui/plugin/editor.ts +396 -0
  88. package/modules/tui/plugin/footer.ts +161 -0
  89. package/modules/tui/plugin/index.ts +30 -0
  90. package/modules/tui/plugin/lifecycle.ts +637 -0
  91. package/modules/tui/plugin/package-order.ts +169 -0
  92. package/modules/tui/plugin/screen-transition.ts +203 -0
  93. package/modules/tui/plugin/settings-config.ts +297 -0
  94. package/modules/tui/plugin/status-sources.ts +49 -0
  95. package/modules/tui/plugin/transition-gate.ts +261 -0
  96. package/modules/tui/renderer/custom-header.ts +157 -0
  97. package/modules/tui/renderer/editor.ts +70 -0
  98. package/modules/tui/renderer/header.ts +72 -0
  99. package/modules/tui/renderer/icons.ts +149 -0
  100. package/modules/tui/renderer/pi-installer-logo.ts +194 -0
  101. package/modules/tui/status/auto-compaction.ts +67 -0
  102. package/modules/tui/status/project-status.ts +655 -0
  103. package/modules/tui/status/provider-status.ts +120 -0
  104. package/modules/tui/status/runtime-status.ts +307 -0
  105. package/modules/tui/status/session-status.ts +325 -0
  106. package/modules/tui/status/status-config.ts +95 -0
  107. package/modules/tui/status/status-segments.ts +263 -0
  108. package/modules/tui/status/turn-telemetry.ts +466 -0
  109. package/modules/tui/themes/LICENSE.pi-themes-bundle +21 -0
  110. package/modules/tui/themes/UPSTREAM.md +7 -0
  111. package/modules/tui/themes/catppuccin-latte.json +80 -0
  112. package/modules/tui/themes/catppuccin-mocha.json +79 -0
  113. package/modules/tui/themes/crimson-noir.json +85 -0
  114. package/modules/tui/themes/dracula.json +79 -0
  115. package/modules/tui/themes/everforest-dark.json +85 -0
  116. package/modules/tui/themes/gruvbox-dark.json +85 -0
  117. package/modules/tui/themes/gruvbox-light.json +85 -0
  118. package/modules/tui/themes/matrix.json +85 -0
  119. package/modules/tui/themes/nord.json +85 -0
  120. package/modules/tui/themes/one-dark.json +85 -0
  121. package/modules/tui/themes/rose-pine-dawn.json +85 -0
  122. package/modules/tui/themes/rose-pine.json +85 -0
  123. package/modules/tui/themes/solarized-dark.json +85 -0
  124. package/modules/tui/themes/solarized-light.json +85 -0
  125. package/modules/tui/themes/tokyo-night-storm.json +79 -0
  126. package/modules/tui/themes/tokyo-night.json +79 -0
  127. package/package.json +28 -0
  128. package/services.ts +38 -0
  129. package/toolkit.ts +147 -0
@@ -0,0 +1,801 @@
1
+ import {
2
+ appendFileSync,
3
+ closeSync,
4
+ copyFileSync,
5
+ existsSync,
6
+ mkdirSync,
7
+ openSync,
8
+ readFileSync,
9
+ readSync,
10
+ readdirSync,
11
+ renameSync,
12
+ statSync,
13
+ writeFileSync,
14
+ } from "node:fs";
15
+ import { randomBytes, randomUUID } from "node:crypto";
16
+ import { readFile } from "node:fs/promises";
17
+ import { basename, dirname, join } from "node:path";
18
+ import { debugLog } from "./diagnostics.ts";
19
+ import type { ModelTier } from "./routing.ts";
20
+
21
+ export interface SessionEntry {
22
+ type: string;
23
+ id: string;
24
+ parentId?: string;
25
+ [key: string]: unknown;
26
+ }
27
+
28
+ export interface MessageEntry extends SessionEntry {
29
+ type: "message";
30
+ message: {
31
+ role: "user" | "assistant" | "toolResult";
32
+ content: Array<{ type: string; text?: string; [key: string]: unknown }>;
33
+ };
34
+ }
35
+
36
+ export type SeededSubagentSessionMode = "lineage-only" | "fork";
37
+
38
+ /** 当前新建 loadout 的版本;缺少该字段的快照按 legacy 兼容路径处理。 */
39
+ export const SUBAGENT_LOADOUT_VERSION = 2;
40
+
41
+ function getForkContentLines(parentSessionFile: string): string[] {
42
+ const raw = readFileSync(parentSessionFile, "utf8");
43
+ const lines = raw.split("\n").filter((line) => line.trim());
44
+
45
+ let truncateAt = lines.length;
46
+ for (let i = lines.length - 1; i >= 0; i--) {
47
+ try {
48
+ const entry = JSON.parse(lines[i]);
49
+ if (entry.type === "message" && entry.message?.role === "user") {
50
+ truncateAt = i;
51
+ break;
52
+ }
53
+ } catch {
54
+ // ignore malformed lines
55
+ }
56
+ }
57
+
58
+ return lines.slice(0, truncateAt).filter((line) => {
59
+ try {
60
+ return JSON.parse(line).type !== "session";
61
+ } catch {
62
+ return true;
63
+ }
64
+ });
65
+ }
66
+
67
+ export function seedSubagentSessionFile(params: {
68
+ mode: SeededSubagentSessionMode;
69
+ parentSessionFile: string;
70
+ childSessionFile: string;
71
+ childCwd: string;
72
+ }): void {
73
+ const header = {
74
+ type: "session",
75
+ version: 3,
76
+ id: randomUUID(),
77
+ timestamp: new Date().toISOString(),
78
+ cwd: params.childCwd,
79
+ parentSession: params.parentSessionFile,
80
+ };
81
+ const contentLines =
82
+ params.mode === "fork" ? getForkContentLines(params.parentSessionFile) : [];
83
+ const lines = [JSON.stringify(header), ...contentLines];
84
+
85
+ mkdirSync(dirname(params.childSessionFile), { recursive: true });
86
+ writeFileSync(params.childSessionFile, lines.join("\n") + "\n", "utf8");
87
+ }
88
+
89
+ /**
90
+ * A snapshot of everything needed to reconstruct a subagent's sandbox when its
91
+ * session is later resumed via `subagent_message({ sessionId })`.
92
+ *
93
+ * Written next to the session file as `<sessionFile>.loadout.json` at spawn
94
+ * time. Resume replays this exact snapshot so the reincarnated process gets the
95
+ * the same tool restriction, model, identity, and spawn whitelist,
96
+ * cwd, and config dir it originally ran with. Pi's normal extension
97
+ * discovery remains enabled during resume, just as it is at launch.
98
+ * Storing the resolved loadout (rather than re-deriving from the agent `.md`
99
+ * by name) keeps
100
+ * resume faithful even if the agent definition is later edited, moved, or
101
+ * deleted.
102
+ */
103
+ export interface SubagentLoadout {
104
+ /** 新 spawn 写入的版本标记;旧快照缺少该字段,保留 legacy resume 兼容。 */
105
+ snapshotVersion?: number;
106
+ /** Agent profile name (for PI_SUBAGENT_AGENT); null for agentless spawns. */
107
+ agent: string | null;
108
+ /** The `--tools` allowlist string, or null when the spawn was unrestricted. */
109
+ toolAllowlist: string | null;
110
+ /** Model id (without thinking suffix), or null to use the session default. */
111
+ model: string | null;
112
+ /** Thinking level appended to the model as `model:level`, or null. */
113
+ thinking: string | null;
114
+ /** Spawn 显式指定的思考等级,优先于 frontmatter thinking 与 model 自带后缀。 */
115
+ thinkingOverride?: string | null;
116
+ /** How the identity text was applied: append/replace, or null. */
117
+ systemPromptMode: "append" | "replace" | null;
118
+ /** The system-prompt/identity text, only when it lived in the system prompt. */
119
+ identity: string | null;
120
+ /** Agents this subagent was allowed to spawn (for PI_SUBAGENT_ALLOWED). */
121
+ spawnable: string[] | null;
122
+ /** Whether the agent auto-exits (informational; resume forces autonomous). */
123
+ autoExit: boolean;
124
+ /** Working directory the subagent ran in, or null. */
125
+ cwd: string | null;
126
+ /** PI_CODING_AGENT_DIR the subagent resolved config/extensions from, or null. */
127
+ agentDir: string | null;
128
+ /**
129
+ * 请求的模型档位(归一化 fast/balanced/deep),仅作记录;launch 已把 tier
130
+ * 解析成具体 model 存入 model 字段,resume 一律用具体 model,不随配置漂移。
131
+ */
132
+ tier?: ModelTier | null;
133
+ /** 仅用于展示和结果聚合的并行分组标签;resume 时随原快照继承。 */
134
+ cohortId?: string;
135
+ /**
136
+ * 持久团队成员标记(member: true spawn):resume/dependsOn 跨 turn 判定
137
+ * 用——member 无进程级终态,不可作为 dependsOn 目标;旧快照缺省为 false。
138
+ */
139
+ member?: boolean | null;
140
+ }
141
+
142
+ /** Path of the loadout sidecar written next to a subagent session file. */
143
+ export function loadoutSidecarPath(sessionFile: string): string {
144
+ return `${sessionFile}.loadout.json`;
145
+ }
146
+
147
+ /** Persist a subagent's resolved sandbox loadout beside its session file. */
148
+ export function writeSubagentLoadout(sessionFile: string, loadout: SubagentLoadout): void {
149
+ try {
150
+ writeFileSync(loadoutSidecarPath(sessionFile), JSON.stringify(loadout), "utf8");
151
+ } catch {
152
+ // Best-effort: a missing snapshot only means resume will refuse, never that
153
+ // it launches unrestricted.
154
+ }
155
+ }
156
+
157
+ /** Read a subagent's loadout snapshot, or null if absent/unparseable. */
158
+ export function readSubagentLoadout(sessionFile: string): SubagentLoadout | null {
159
+ try {
160
+ const p = loadoutSidecarPath(sessionFile);
161
+ if (!existsSync(p)) return null;
162
+ const parsed = JSON.parse(readFileSync(p, "utf8"));
163
+ if (!parsed || typeof parsed !== "object") return null;
164
+ return parsed as SubagentLoadout;
165
+ } catch {
166
+ return null;
167
+ }
168
+ }
169
+
170
+ // ── 父侧锚定的 loadout 快照(resume 授权单一真源)──────────────────────
171
+ //
172
+ // 信任模型(诚实边界):子代理与扩展宿主运行在同一用户下,任何同用户进程
173
+ // 原则上都写得了双方的目录——锚定快照不提供绝对隔离。它提供的是一致性
174
+ // 交叉校验:子代理可写的 session sidecar(`.loadout.json`)与父侧锚定
175
+ // 副本必须逐字段一致,resume 才放行;以锚定副本为运行配置。篡改者必须
176
+ // 同时一致地改写两份独立位置(tree)的副本才能扩权——这堵死了“只改
177
+ // session sidecar 即可扩权”的廉价路径,并让篡改可检测(不静默放宽)。
178
+
179
+ /** 父侧锚定副本:内容同 SubagentLoadout,额外记录被锚定的 session 路径。 */
180
+ export interface AnchoredSubagentLoadout extends SubagentLoadout {
181
+ /** 被锚定的子代理 session 文件绝对路径(containment 精确匹配基准)。 */
182
+ sessionFile: string;
183
+ }
184
+
185
+ /** 锚定副本存放目录(父会话 artifactDir 下,父进程控制)。 */
186
+ export function anchoredLoadoutPath(artifactDir: string, sessionFile: string): string {
187
+ return join(artifactDir, "loadouts", `${basename(sessionFile)}.loadout.json`);
188
+ }
189
+
190
+ /** 在父会话 artifactDir 写入锚定副本;返回写入路径。 */
191
+ export function writeAnchoredLoadout(
192
+ artifactDir: string,
193
+ sessionFile: string,
194
+ loadout: SubagentLoadout,
195
+ ): string {
196
+ const path = anchoredLoadoutPath(artifactDir, sessionFile);
197
+ mkdirSync(dirname(path), { recursive: true });
198
+ writeFileSync(
199
+ path,
200
+ JSON.stringify({ ...loadout, sessionFile } satisfies AnchoredSubagentLoadout),
201
+ "utf8",
202
+ );
203
+ return path;
204
+ }
205
+
206
+ /** 读取锚定副本;缺失/损坏返回 null,由调用方按父 registry 的 anchored 标记决定是否拒绝 legacy fallback。 */
207
+ export function readAnchoredLoadout(
208
+ artifactDir: string,
209
+ sessionFile: string,
210
+ ): AnchoredSubagentLoadout | null {
211
+ try {
212
+ const p = anchoredLoadoutPath(artifactDir, sessionFile);
213
+ if (!existsSync(p)) return null;
214
+ const parsed = JSON.parse(readFileSync(p, "utf8"));
215
+ if (!parsed || typeof parsed !== "object" || typeof (parsed as any).sessionFile !== "string") {
216
+ return null;
217
+ }
218
+ return parsed as AnchoredSubagentLoadout;
219
+ } catch {
220
+ return null;
221
+ }
222
+ }
223
+
224
+ /**
225
+ * 比较 session sidecar 与锚定副本的安全相关字段,返回不一致的字段名列表。
226
+ * 这些字段共同决定 resume 时的授权面(工具白名单、繁衍白名单、身份、
227
+ * 模型、agentDir、cwd、member 标记):任何被改动都视为篡改/损坏。
228
+ */
229
+ const LOADOUT_SECURITY_FIELDS = [
230
+ "snapshotVersion",
231
+ "agent",
232
+ "toolAllowlist",
233
+ "model",
234
+ "thinking",
235
+ "thinkingOverride",
236
+ "systemPromptMode",
237
+ "identity",
238
+ "spawnable",
239
+ "autoExit",
240
+ "cwd",
241
+ "agentDir",
242
+ "tier",
243
+ "member",
244
+ ] as const;
245
+
246
+ export function diffSubagentLoadouts(
247
+ anchored: AnchoredSubagentLoadout,
248
+ onDisk: SubagentLoadout,
249
+ ): string[] {
250
+ const diffs: string[] = [];
251
+ for (const field of LOADOUT_SECURITY_FIELDS) {
252
+ const a = (anchored as unknown as Record<string, unknown>)[field];
253
+ const b = (onDisk as unknown as Record<string, unknown>)[field];
254
+ const same = Array.isArray(a) && Array.isArray(b)
255
+ ? a.length === b.length && [...a].sort().every((value, index) => value === [...b].sort()[index])
256
+ : a === b;
257
+ if (!same) diffs.push(field);
258
+ }
259
+ return diffs;
260
+ }
261
+
262
+ // ── Name registry ────────────────────────────────────────────────────────────
263
+ // Each spawner session (the top-level pi session, or a worker that spawns its
264
+ // own children) gets a registry mapping a subagent's display name to the
265
+ // session file it ran in. Names are unique per spawner session and persist on
266
+ // disk, so `subagent_message({ name })` can steer a running subagent or resume
267
+ // a finished one by the same handle — even across a pi restart. The registry
268
+ // lives in the spawner's own artifact dir, which is directly addressable from
269
+ // the spawner's session id (no sessions-tree scan, so resume stays fast).
270
+
271
+ export interface NameRegistryEntry {
272
+ /** Absolute path to the subagent's session .jsonl file. */
273
+ sessionFile: string;
274
+ /** Canonical session header id (kept for display/lineage). */
275
+ sessionId: string | null;
276
+ /** 仅用于展示和结果聚合的并行分组标签。 */
277
+ cohortId?: string;
278
+ /**
279
+ * True when the parent wrote an anchored loadout for this spawn. This marker
280
+ * lives in the parent registry so deleting the anchor cannot silently turn a
281
+ * new snapshot into a legacy resume.
282
+ */
283
+ anchored?: boolean;
284
+ }
285
+
286
+ export type NameRegistry = Record<string, NameRegistryEntry>;
287
+
288
+ /** Path of the name registry for a given spawner session's artifact dir. */
289
+ export function nameRegistryPath(artifactDir: string): string {
290
+ return join(artifactDir, "subagent-registry.json");
291
+ }
292
+
293
+ /** Read a spawner session's name registry, or {} if absent/corrupt. */
294
+ export function readNameRegistry(artifactDir: string): NameRegistry {
295
+ try {
296
+ const p = nameRegistryPath(artifactDir);
297
+ if (!existsSync(p)) return {};
298
+ const parsed = JSON.parse(readFileSync(p, "utf8"));
299
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
300
+ return parsed as NameRegistry;
301
+ } catch {
302
+ return {};
303
+ }
304
+ }
305
+
306
+ /**
307
+ * Register (or overwrite) a name → session mapping for a spawner session.
308
+ * Writes atomically (temp file + rename) so a concurrent reader never sees a
309
+ * partial registry.
310
+ */
311
+ // 同一进程内的串行化队列:并发 spawn 的 registerName 是 read-modify-write,
312
+ // 无队列时后写会覆盖先写,导致某个名字无法 resume。
313
+
314
+ // 注:registerName 是全同步函数(无 await),JS 单线程下天然串行,并行 spawn
315
+ // 不会造成 read-modify-write 交错覆盖(2026-08-30 审查复核结论)。
316
+ export function registerName(
317
+ artifactDir: string,
318
+ name: string,
319
+ entry: NameRegistryEntry,
320
+ ): void {
321
+ try {
322
+ mkdirSync(artifactDir, { recursive: true });
323
+ const registry = readNameRegistry(artifactDir);
324
+ registry[name] = entry;
325
+ const p = nameRegistryPath(artifactDir);
326
+ const tmp = `${p}.tmp-${process.pid}-${Math.random().toString(16).slice(2, 8)}`;
327
+ writeFileSync(tmp, JSON.stringify(registry, null, 2), "utf8");
328
+ renameSync(tmp, p);
329
+ } catch (error) {
330
+ // Best-effort: a failed registration only means resume-by-name won't find
331
+ // this subagent later; it never breaks the spawn itself.
332
+ debugLog(`Could not register subagent name ${name}`, error);
333
+ }
334
+ }
335
+
336
+ /** Resolve a name to its registry entry within a spawner session, or null. */
337
+ export function resolveNameInRegistry(
338
+ artifactDir: string,
339
+ name: string,
340
+ ): NameRegistryEntry | null {
341
+ const entry = readNameRegistry(artifactDir)[name];
342
+ return entry && typeof entry.sessionFile === "string" ? entry : null;
343
+ }
344
+
345
+ function readEntries(sessionFile: string): SessionEntry[] {
346
+ const raw = readFileSync(sessionFile, "utf8");
347
+ return raw
348
+ .split("\n")
349
+ .filter((line) => line.trim())
350
+ .map((line) => JSON.parse(line) as SessionEntry);
351
+ }
352
+
353
+ /**
354
+ * Return the id of the last entry in the session file (current branch point / leaf).
355
+ */
356
+ export function getLeafId(sessionFile: string): string | null {
357
+ const entries = readEntries(sessionFile);
358
+ return entries.length > 0 ? entries[entries.length - 1].id : null;
359
+ }
360
+
361
+ /**
362
+ * Read the canonical session id from a session file's header.
363
+ *
364
+ * pi's `--session <id>` flag resolves against this header `id` (exact match,
365
+ * then prefix), NOT the filename — so this is the value to hand back to the
366
+ * orchestrator for follow-ups.
367
+ */
368
+ /**
369
+ * Read only the first line of a file without loading the whole thing into
370
+ * memory. Session files grow to many MB, but the header we need is always the
371
+ * first JSON line, so reading a small prefix keeps header lookups cheap — this
372
+ * is what makes scanning a large session tree fast enough to avoid blocking the
373
+ * event loop. Returns the first line (sans trailing newline), or null.
374
+ */
375
+ function readFirstLine(path: string, maxBytes = 65536): string | null {
376
+ let fd: number | undefined;
377
+ try {
378
+ fd = openSync(path, "r");
379
+ const buf = Buffer.allocUnsafe(maxBytes);
380
+ const bytes = readSync(fd, buf, 0, maxBytes, 0);
381
+ if (bytes <= 0) return null;
382
+ const nl = buf.indexOf(0x0a); // '\n'
383
+ const end = nl === -1 || nl >= bytes ? bytes : nl;
384
+ return buf.toString("utf8", 0, end);
385
+ } catch {
386
+ return null;
387
+ } finally {
388
+ if (fd !== undefined) {
389
+ try {
390
+ closeSync(fd);
391
+ } catch {
392
+ /* ignore */
393
+ }
394
+ }
395
+ }
396
+ }
397
+
398
+ export function getSessionId(sessionFile: string): string | null {
399
+ return readHeaderId(sessionFile);
400
+ }
401
+
402
+ function readHeaderId(sessionFile: string): string | null {
403
+ const firstLine = readFirstLine(sessionFile)?.trim();
404
+ if (!firstLine) return null;
405
+ try {
406
+ const entry = JSON.parse(firstLine) as { type?: string; id?: string };
407
+ return entry.type === "session" && typeof entry.id === "string" ? entry.id : null;
408
+ } catch {
409
+ return null;
410
+ }
411
+ }
412
+
413
+ /**
414
+ * Resolve a session id (or id prefix) to a session file path by scanning every
415
+ * `*.jsonl` under `sessionsRoot` and matching the header `id`. Mirrors pi's own
416
+ * resolution order: exact match first, then prefix match. Most recently
417
+ * modified file wins on ties. Returns null when nothing matches.
418
+ */
419
+ /**
420
+ * In-process index of session id → session file, per sessions root.
421
+ *
422
+ * Resolving a session id naively walks every `.jsonl` under the sessions tree
423
+ * and reads each header. With a few thousand sessions that is thousands of
424
+ * synchronous open/read/stat syscalls — on the extension host's single thread
425
+ * that blocks the entire terminal UI for many seconds (measured ~67s on a
426
+ * 2010-file tree). To avoid that, we build the index once per root and cache
427
+ * it; subsequent lookups are O(1). The cache is validated cheaply (a directory
428
+ * listing plus statSync-only mtime checks) on every call, so new sessions are
429
+ * picked up without re-reading unchanged headers and without ever freezing the
430
+ * UI again.
431
+ */
432
+ interface SessionIndex {
433
+ idToFile: Map<string, { path: string; mtime: number }>;
434
+ /** file path → mtime when indexed (staleness detection). */
435
+ files: Map<string, number>;
436
+ /** top-level dir signature used to detect newly added cwd dirs. */
437
+ topSig: string;
438
+ }
439
+ const sessionIndexCache = new Map<string, SessionIndex>();
440
+
441
+ function topLevelSignature(root: string): string {
442
+ const parts: string[] = [];
443
+ let entries: import("node:fs").Dirent[];
444
+ try {
445
+ entries = readdirSync(root, { withFileTypes: true });
446
+ } catch {
447
+ return "";
448
+ }
449
+ for (const e of entries) {
450
+ const full = join(root, e.name);
451
+ if (e.isDirectory()) {
452
+ let m = 0;
453
+ try {
454
+ m = statSync(full).mtimeMs;
455
+ } catch {
456
+ /* ignore */
457
+ }
458
+ parts.push(`d:${e.name}:${m}`);
459
+ } else if (e.isFile() && e.name.endsWith(".jsonl")) {
460
+ parts.push(`f:${e.name}`);
461
+ }
462
+ }
463
+ parts.sort();
464
+ return parts.join("|");
465
+ }
466
+
467
+ /** Recursively index new/changed .jsonl files under dir into idx. */
468
+ function indexDir(dir: string, idx: SessionIndex): void {
469
+ let entries: import("node:fs").Dirent[];
470
+ try {
471
+ entries = readdirSync(dir, { withFileTypes: true });
472
+ } catch {
473
+ return;
474
+ }
475
+ for (const entry of entries) {
476
+ const full = join(dir, entry.name);
477
+ if (entry.isDirectory()) {
478
+ indexDir(full, idx);
479
+ } else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
480
+ let mtime = 0;
481
+ try {
482
+ mtime = statSync(full).mtimeMs;
483
+ } catch {
484
+ continue;
485
+ }
486
+ const known = idx.files.get(full);
487
+ if (known !== undefined && known === mtime) continue; // unchanged
488
+ const id = readHeaderId(full); // only read headers for new/changed files
489
+ idx.files.set(full, mtime);
490
+ if (!id) continue;
491
+ const prev = idx.idToFile.get(id);
492
+ if (!prev || mtime >= prev.mtime) {
493
+ idx.idToFile.set(id, { path: full, mtime });
494
+ }
495
+ }
496
+ }
497
+ }
498
+
499
+ function getSessionIndex(sessionsRoot: string): SessionIndex {
500
+ let idx = sessionIndexCache.get(sessionsRoot);
501
+ const sig = topLevelSignature(sessionsRoot);
502
+ if (!idx) {
503
+ idx = { idToFile: new Map(), files: new Map(), topSig: sig };
504
+ sessionIndexCache.set(sessionsRoot, idx);
505
+ indexDir(sessionsRoot, idx); // first build: full scan, once per process
506
+ } else if (idx.topSig !== sig) {
507
+ idx.topSig = sig;
508
+ indexDir(sessionsRoot, idx); // a cwd dir was added/changed: incremental rescan
509
+ } else {
510
+ indexDir(sessionsRoot, idx); // cheap: stats files, reads only new/changed headers
511
+ }
512
+ return idx;
513
+ }
514
+
515
+ export function resolveSessionFileById(sessionId: string, sessionsRoot: string): string | null {
516
+ if (!sessionId || !existsSync(sessionsRoot)) return null;
517
+ const idx = getSessionIndex(sessionsRoot);
518
+ return lookupSessionIndex(idx, sessionId);
519
+ }
520
+
521
+ function lookupSessionIndex(
522
+ idx: { idToFile: Map<string, { path: string; mtime: number }> },
523
+ sessionId: string,
524
+ ): string | null {
525
+ // Exact match first.
526
+ const exact = idx.idToFile.get(sessionId);
527
+ if (exact && existsSync(exact.path)) return exact.path;
528
+
529
+ // Prefix match: most recently modified wins (ids are unique in practice, so
530
+ // this is only a convenience for hand-typed short prefixes).
531
+ let best: { path: string; mtime: number } | null = null;
532
+ for (const [id, rec] of idx.idToFile) {
533
+ if (!id.startsWith(sessionId)) continue;
534
+ if (!existsSync(rec.path)) continue;
535
+ if (!best || rec.mtime > best.mtime) best = rec;
536
+ }
537
+ return best ? best.path : null;
538
+ }
539
+
540
+ /**
541
+ * Async variant used by the interactive resume path. Index building/refresh is
542
+ * synchronous I/O, which can take many seconds on a cold OS page cache with a
543
+ * few thousand sessions; running it synchronously would block the extension
544
+ * host's single thread and freeze the terminal UI. Deferring to a macrotask
545
+ * keeps the event loop responsive. The heavy work only happens on the first
546
+ * resolution per process (and incrementally thereafter); warm lookups are ~50ms.
547
+ */
548
+ export async function resolveSessionFileByIdAsync(
549
+ sessionId: string,
550
+ sessionsRoot: string,
551
+ ): Promise<string | null> {
552
+ if (!sessionId || !existsSync(sessionsRoot)) return null;
553
+ // Let the event loop breathe (and the UI repaint) before the sync scan.
554
+ await new Promise<void>((r) => setImmediate(r));
555
+ const idx = getSessionIndex(sessionsRoot);
556
+ return lookupSessionIndex(idx, sessionId);
557
+ }
558
+
559
+ /** Test hook: drop the cached session index so tests start clean. */
560
+ export function resetSessionIndexCache(): void {
561
+ sessionIndexCache.clear();
562
+ }
563
+
564
+ /**
565
+ * Count the number of entry lines in a session file without parsing each line
566
+ * into an object. Used by the resume path, which only needs the *count* of
567
+ * pre-existing entries (so it can later slice out the new ones). Parsing every
568
+ * line of a large resumed transcript synchronously at resume time would block
569
+ * the UI; counting newlines is dramatically cheaper.
570
+ */
571
+ export function countSessionEntryLines(sessionFile: string): number {
572
+ let fd = -1;
573
+ try {
574
+ fd = openSync(sessionFile, "r");
575
+ const buffer = Buffer.allocUnsafe(64 * 1024);
576
+ let count = 0;
577
+ let hasContent = false;
578
+
579
+ for (;;) {
580
+ const bytesRead = readSync(fd, buffer, 0, buffer.length, null);
581
+ if (bytesRead === 0) break;
582
+ for (let i = 0; i < bytesRead; i++) {
583
+ const byte = buffer[i];
584
+ if (byte === 0x0a) {
585
+ if (hasContent) count++;
586
+ hasContent = false;
587
+ } else if (byte !== 0x0d && byte !== 0x20 && byte !== 0x09) {
588
+ hasContent = true;
589
+ }
590
+ }
591
+ }
592
+ if (hasContent) count++;
593
+ return count;
594
+ } catch {
595
+ return 0;
596
+ } finally {
597
+ if (fd !== -1) {
598
+ try { closeSync(fd); } catch {}
599
+ }
600
+ }
601
+ }
602
+
603
+ function parseSessionLines(raw: string, afterLine: number): SessionEntry[] {
604
+ return raw
605
+ .split("\n")
606
+ .filter((line) => line.trim())
607
+ .slice(afterLine)
608
+ .map((line) => JSON.parse(line) as SessionEntry);
609
+ }
610
+
611
+ export function getNewEntries(sessionFile: string, afterLine: number): SessionEntry[] {
612
+ return parseSessionLines(readFileSync(sessionFile, "utf8"), afterLine);
613
+ }
614
+
615
+ export async function getNewEntriesAsync(sessionFile: string, afterLine: number): Promise<SessionEntry[]> {
616
+ return parseSessionLines(await readFile(sessionFile, "utf8"), afterLine);
617
+ }
618
+
619
+ /** 最后一个有意义的 assistant 回合及其终态错误。 */
620
+ export interface LastAssistantOutcome {
621
+ /** 最后回复中的文本;错误回合也可能带部分文本。 */
622
+ summary: string | null;
623
+ /** stopReason=error 时的原始错误;没有字段时使用明确的兜底文案。 */
624
+ errorMessage: string | null;
625
+ }
626
+
627
+ /**
628
+ * 读取最后一个 assistant 回合的结构化结果。
629
+ *
630
+ * 错误回合必须先于文本摘要被识别:provider 在生成部分文本后仍可能以
631
+ * stopReason=error 结束,不能因为存在部分文本就把失败上游当作成功。
632
+ */
633
+ export function findLastAssistantOutcome(entries: SessionEntry[]): LastAssistantOutcome {
634
+ for (let i = entries.length - 1; i >= 0; i--) {
635
+ const entry = entries[i];
636
+ if (entry.type !== "message") continue;
637
+ const msg = entry as MessageEntry;
638
+ if (!msg.message || msg.message.role !== "assistant") continue;
639
+
640
+ const content = Array.isArray(msg.message.content) ? msg.message.content : [];
641
+ const texts = content
642
+ .filter(
643
+ (block) =>
644
+ block && block.type === "text" && typeof block.text === "string" && block.text.trim() !== "",
645
+ )
646
+ .map((block) => block.text as string);
647
+ const summary = texts.length > 0 && texts.join("").trim() ? texts.join("\n") : null;
648
+ const stopReason = (msg.message as { stopReason?: unknown }).stopReason;
649
+ if (stopReason === "error") {
650
+ const rawError = (msg.message as { errorMessage?: unknown }).errorMessage;
651
+ const errorMessage = typeof rawError === "string" && rawError.trim()
652
+ ? rawError.trim()
653
+ : "Subagent agent loop ended with stopReason=error (no errorMessage field).";
654
+ return {
655
+ summary: summary ?? (typeof rawError === "string" && rawError.trim()
656
+ ? `Subagent error: ${rawError.trim()}`
657
+ : null),
658
+ errorMessage,
659
+ };
660
+ }
661
+ if (summary != null) return { summary, errorMessage: null };
662
+ }
663
+ return { summary: null, errorMessage: null };
664
+ }
665
+
666
+ /** 保留历史调用方的文本接口;错误回合的兼容摘要格式不变。 */
667
+ export function findLastAssistantMessage(entries: SessionEntry[]): string | null {
668
+ return findLastAssistantOutcome(entries).summary;
669
+ }
670
+
671
+ /**
672
+ * Append a branch_summary entry to the session file.
673
+ * Returns the new entry's id.
674
+ */
675
+ export function appendBranchSummary(
676
+ sessionFile: string,
677
+ branchPointId: string,
678
+ fromId: string | null,
679
+ summary: string,
680
+ ): string {
681
+ const id = randomBytes(4).toString("hex");
682
+ const entry = {
683
+ type: "branch_summary",
684
+ id,
685
+ parentId: branchPointId,
686
+ timestamp: new Date().toISOString(),
687
+ fromId: fromId ?? branchPointId,
688
+ summary,
689
+ };
690
+ appendFileSync(sessionFile, JSON.stringify(entry) + "\n", "utf8");
691
+ return id;
692
+ }
693
+
694
+ /**
695
+ * Copy the session file to destDir for parallel worker isolation.
696
+ * Returns the path of the copy.
697
+ */
698
+ export function copySessionFile(sessionFile: string, destDir: string): string {
699
+ const id = randomBytes(4).toString("hex");
700
+ const dest = join(destDir, `subagent-${id}.jsonl`);
701
+ copyFileSync(sessionFile, dest);
702
+ return dest;
703
+ }
704
+
705
+ /**
706
+ * Read new entries from sourceFile (after afterLine), append them to targetFile.
707
+ * Returns the appended entries.
708
+ */
709
+ export function mergeNewEntries(
710
+ sourceFile: string,
711
+ targetFile: string,
712
+ afterLine: number,
713
+ ): SessionEntry[] {
714
+ const entries = getNewEntries(sourceFile, afterLine);
715
+ for (const entry of entries) {
716
+ appendFileSync(targetFile, JSON.stringify(entry) + "\n", "utf8");
717
+ }
718
+ return entries;
719
+ }
720
+
721
+ export interface SessionStats {
722
+ model: string | null;
723
+ toolCount: number;
724
+ /** Cumulative token usage across all assistant turns. */
725
+ inputTokens: number;
726
+ outputTokens: number;
727
+ cacheReadTokens: number;
728
+ cacheWriteTokens: number;
729
+ /** Current context size: the last assistant turn's totalTokens. */
730
+ contextTokens: number;
731
+ /** Cumulative cost in USD across all assistant turns. */
732
+ cost: number;
733
+ }
734
+
735
+ /**
736
+ * Parse a completed subagent session JSONL into aggregate stats for display:
737
+ * model, tool-call count, cumulative token usage + cost, and current context
738
+ * size. Cumulative usage fields are summed across every assistant turn; the
739
+ * context size is taken from the last assistant turn's `totalTokens` (the live
740
+ * context window occupancy). Returns null if the file can't be read.
741
+ */
742
+ function summarizeEntries(entries: SessionEntry[]): SessionStats {
743
+ const stats: SessionStats = {
744
+ model: null,
745
+ toolCount: 0,
746
+ inputTokens: 0,
747
+ outputTokens: 0,
748
+ cacheReadTokens: 0,
749
+ cacheWriteTokens: 0,
750
+ contextTokens: 0,
751
+ cost: 0,
752
+ };
753
+
754
+ for (const entry of entries) {
755
+ if (entry.type === "model_change") {
756
+ const modelId = (entry as { modelId?: unknown }).modelId;
757
+ if (typeof modelId === "string" && modelId) stats.model = modelId;
758
+ continue;
759
+ }
760
+ if (entry.type !== "message") continue;
761
+ const msg = (entry as MessageEntry).message;
762
+ if (msg.role !== "assistant") continue;
763
+
764
+ const model = (msg as { model?: unknown }).model;
765
+ if (typeof model === "string" && model) stats.model = model;
766
+ for (const block of msg.content) {
767
+ if (block.type === "toolCall") stats.toolCount++;
768
+ }
769
+
770
+ const usage = (msg as { usage?: Record<string, unknown> }).usage;
771
+ if (usage && typeof usage === "object") {
772
+ const num = (value: unknown): number =>
773
+ typeof value === "number" && Number.isFinite(value) ? value : 0;
774
+ stats.inputTokens += num(usage.input);
775
+ stats.outputTokens += num(usage.output);
776
+ stats.cacheReadTokens += num(usage.cacheRead);
777
+ stats.cacheWriteTokens += num(usage.cacheWrite);
778
+ const total = num(usage.totalTokens);
779
+ if (total > 0) stats.contextTokens = total;
780
+ const cost = usage.cost;
781
+ if (cost && typeof cost === "object") stats.cost += num((cost as Record<string, unknown>).total);
782
+ }
783
+ }
784
+ return stats;
785
+ }
786
+
787
+ export function summarizeSessionStats(sessionFile: string): SessionStats | null {
788
+ try {
789
+ return summarizeEntries(readEntries(sessionFile));
790
+ } catch {
791
+ return null;
792
+ }
793
+ }
794
+
795
+ export async function summarizeSessionStatsAsync(sessionFile: string): Promise<SessionStats | null> {
796
+ try {
797
+ return summarizeEntries(parseSessionLines(await readFile(sessionFile, "utf8"), 0));
798
+ } catch {
799
+ return null;
800
+ }
801
+ }