@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,839 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { Text } from "@earendil-works/pi-tui";
3
+ import { Type } from "@sinclair/typebox";
4
+ import { existsSync, readFileSync } from "node:fs";
5
+ import { parseHeadlessSurface, type HeadlessChild } from "./headless.ts";
6
+ import {
7
+ getSubagentActivityFile,
8
+ readSubagentActivityFile,
9
+ type ActivityReadResult,
10
+ type SubagentActivityState,
11
+ } from "./activity.ts";
12
+ import {
13
+ classifyStatus,
14
+ createStatusState,
15
+ observeStatus,
16
+ type StatusSnapshot,
17
+ type SubagentStatusState,
18
+ } from "./status.ts";
19
+ import {
20
+ type NameRegistry,
21
+ type NameRegistryEntry,
22
+ type SubagentLoadout,
23
+ } from "./session.ts";
24
+ import { normalizeSubagentName as normalizeName } from "./names.ts";
25
+ import { peekExitSidecar } from "./surface.ts";
26
+ import type { RuntimeRecord } from "./runtime-registry.ts";
27
+ import type { RunningSubagent, SubagentWaitMode, SubagentWaitRelease } from "./types.ts";
28
+
29
+ /** 只读状态观测的来源。runtime-registry 是 /reload 后尚未恢复的磁盘记录。 */
30
+ export type SubagentInspectionSource = "live" | "runtime-registry" | "session-registry";
31
+
32
+ export type SubagentInspectionLifecycle =
33
+ | "launching"
34
+ | "running"
35
+ | "waiting"
36
+ | "stalled"
37
+ | "exited"
38
+ | "offline"
39
+ | "finished"
40
+ | "discarded";
41
+
42
+ export type SubagentInspectionTimeoutState = "not-configured" | "pending" | "expired";
43
+
44
+ export interface SubagentInspection {
45
+ name: string;
46
+ id: string | null;
47
+ cohortId: string | null;
48
+ agent: string | null;
49
+ task: string | null;
50
+ model: string | null;
51
+ thinking: string | null;
52
+ tier: string | null;
53
+ source: SubagentInspectionSource;
54
+ lifecycle: SubagentInspectionLifecycle;
55
+ /** activity sidecar 中的原始阶段;没有快照时为 null。 */
56
+ phase: "starting" | "active" | "waiting" | "done" | null;
57
+ /** status.ts 根据快照计算出的即时状态;已结束的历史记录为 null。 */
58
+ status: "starting" | "active" | "waiting" | "stalled" | "running" | null;
59
+ statusLabel: string | null;
60
+ surface: string | null;
61
+ surfaceKind: "headless" | "pane" | null;
62
+ pid: number | null;
63
+ processAlive: boolean | null;
64
+ surfaceAlive: boolean | null;
65
+ processExitCode: number | null;
66
+ processExitSignal: string | null;
67
+ startTime: number | null;
68
+ elapsedMs: number | null;
69
+ lastActivityAt: number | null;
70
+ lastActivityAgeMs: number | null;
71
+ activeScope: string | null;
72
+ activeSince: number | null;
73
+ activeDurationMs: number | null;
74
+ waitingSince: number | null;
75
+ waitingDurationMs: number | null;
76
+ latestEvent: string | null;
77
+ activitySnapshot: "present" | "missing" | "invalid" | "wrong-id" | "unavailable";
78
+ activityError: string | null;
79
+ waitingReason: string | null;
80
+ waitingQuestion?: string;
81
+ member: boolean;
82
+ memberRound: "idle" | "dispatched" | null;
83
+ rosterStatus: "idle" | "dispatched" | "offline" | null;
84
+ rosterReason: string | null;
85
+ parentId: string | null;
86
+ childCount: number;
87
+ childNames: string[];
88
+ waitMode: SubagentWaitMode | null;
89
+ waitReleased: SubagentWaitRelease | null;
90
+ timeoutMs: number | null;
91
+ timeoutAt: number | null;
92
+ timeoutState: SubagentInspectionTimeoutState;
93
+ stdinLost: boolean;
94
+ sessionFile: string | null;
95
+ sessionFileExists: boolean;
96
+ loadoutExists: boolean;
97
+ resumable: boolean;
98
+ canSteer: boolean;
99
+ canResume: boolean;
100
+ controlReason: string | null;
101
+ exitCode: number | null;
102
+ exitError: string | null;
103
+ observedAt: number;
104
+ }
105
+
106
+ export interface InspectRosterMember {
107
+ status: "idle" | "dispatched" | "offline";
108
+ offlineReason?: string;
109
+ dispatchedAt?: number;
110
+ lastRoundAt?: number;
111
+ }
112
+
113
+ export interface SubagentInspectDeps {
114
+ runningSubagents: Map<string, RunningSubagent>;
115
+ /** 读取并刷新当前进程持有的 activity/status 快照。 */
116
+ observeRunningSubagent: (running: RunningSubagent, observedAt?: number) => void;
117
+ getArtifactDir: (sessionDir: string, sessionId: string) => string;
118
+ readNameRegistry: (artifactDir: string) => NameRegistry;
119
+ readSubagentLoadout: (sessionFile: string) => SubagentLoadout | null;
120
+ readRuntimeRecords: (path: string) => RuntimeRecord[];
121
+ runtimeRegistryPath: (artifactDir: string) => string;
122
+ isPidAlive: (pid: number) => boolean;
123
+ /** 没有复用器或表面不支持探测时返回 null。 */
124
+ probeSurface?: (surface: string) => boolean | null;
125
+ readRosterMember?: (name: string, artifactDir: string) => InspectRosterMember | null;
126
+ }
127
+
128
+ interface InspectRecord {
129
+ id: string;
130
+ name: string;
131
+ task: string;
132
+ cohortId?: string;
133
+ agent?: string;
134
+ model?: string | null;
135
+ surface: string;
136
+ startTime: number;
137
+ sessionFile: string;
138
+ activityFile?: string;
139
+ interactive: boolean;
140
+ kind?: "pane" | "headless";
141
+ pid?: number;
142
+ parentId?: string | null;
143
+ timeoutMs?: number;
144
+ waitMode?: SubagentWaitMode;
145
+ waitReleased?: SubagentWaitRelease;
146
+ member?: boolean;
147
+ dispatchedRound?: boolean;
148
+ stdinLost?: boolean;
149
+ headlessChild?: HeadlessChild;
150
+ }
151
+
152
+ const MAX_TASK_LENGTH = 1200;
153
+ const MAX_QUESTION_LENGTH = 1200;
154
+ const THINKING_LEVELS = new Set(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
155
+
156
+ function truncate(value: string | null | undefined, maxLength: number): string {
157
+ const normalized = (value ?? "").replace(/\s+/g, " ").trim();
158
+ if (normalized.length <= maxLength) return normalized;
159
+ return `${normalized.slice(0, Math.max(0, maxLength - 1))}…`;
160
+ }
161
+
162
+ function readPendingQuestion(sessionFile: string): string | null {
163
+ try {
164
+ const raw = readFileSync(`${sessionFile}.ask`, "utf8");
165
+ const parsed = JSON.parse(raw) as { question?: unknown };
166
+ return typeof parsed.question === "string" && parsed.question.trim()
167
+ ? truncate(parsed.question, MAX_QUESTION_LENGTH)
168
+ : null;
169
+ } catch {
170
+ return null;
171
+ }
172
+ }
173
+
174
+ function activityLabel(activity: SubagentActivityState): string | undefined {
175
+ if (activity.phase !== "active") return undefined;
176
+ if (activity.activeScope === "tool") return activity.toolName ?? "tool";
177
+ if (activity.activeScope === "provider") return "provider";
178
+ if (activity.activeScope === "streaming") return "streaming";
179
+ return activity.activeScope;
180
+ }
181
+
182
+ function activityObservation(activity: SubagentActivityState) {
183
+ return {
184
+ snapshot: "present" as const,
185
+ updatedAt: activity.updatedAt,
186
+ sequence: activity.sequence,
187
+ phase: activity.phase,
188
+ active: activity.phase === "active",
189
+ activeScope: activity.activeScope,
190
+ activeSince: activity.activeSince,
191
+ waitingSince: activity.waitingSince,
192
+ latestEvent: activity.latestEvent,
193
+ activityLabel: activityLabel(activity),
194
+ };
195
+ }
196
+
197
+ function freshActivity(
198
+ record: Pick<InspectRecord, "id" | "activityFile">,
199
+ ): ActivityReadResult {
200
+ if (!record.activityFile) return { ok: false, reason: "missing" };
201
+ return readSubagentActivityFile(record.activityFile, record.id);
202
+ }
203
+
204
+ function statusFromActivity(
205
+ startTime: number,
206
+ read: ActivityReadResult,
207
+ now: number,
208
+ ): { state: SubagentStatusState; snapshot: StatusSnapshot; activity?: SubagentActivityState } {
209
+ const state = createStatusState({ source: "pi", startTimeMs: startTime });
210
+ const next = observeStatus(
211
+ state,
212
+ read.ok ? activityObservation(read.activity) : { snapshot: read.reason, snapshotError: read.error },
213
+ now,
214
+ );
215
+ return {
216
+ state: next,
217
+ snapshot: classifyStatus(next, now),
218
+ ...(read.ok ? { activity: read.activity } : {}),
219
+ };
220
+ }
221
+
222
+ function resolveThinking(loadout: SubagentLoadout | null, model: string | null): string | null {
223
+ if (loadout?.thinkingOverride) return loadout.thinkingOverride;
224
+ const suffix = model?.match(/:([a-z]+)$/)?.[1];
225
+ if (suffix && THINKING_LEVELS.has(suffix)) return suffix;
226
+ return loadout?.thinking ?? null;
227
+ }
228
+
229
+ function resolveSurfaceKind(record: Pick<InspectRecord, "kind" | "surface">): "headless" | "pane" {
230
+ if (record.kind) return record.kind;
231
+ return record.surface.startsWith("headless:") ? "headless" : "pane";
232
+ }
233
+
234
+ function inspectProcess(
235
+ record: InspectRecord,
236
+ source: SubagentInspectionSource,
237
+ deps: Pick<SubagentInspectDeps, "isPidAlive" | "probeSurface">,
238
+ ): {
239
+ kind: "headless" | "pane";
240
+ pid: number | null;
241
+ processAlive: boolean | null;
242
+ surfaceAlive: boolean | null;
243
+ exitCode: number | null;
244
+ exitSignal: string | null;
245
+ } {
246
+ const kind = resolveSurfaceKind(record);
247
+ if (kind === "headless") {
248
+ const pid = record.pid ?? parseHeadlessSurface(record.surface);
249
+ let processAlive: boolean | null = null;
250
+ let exitCode: number | null = null;
251
+ let exitSignal: string | null = null;
252
+ if (record.headlessChild?.exited) {
253
+ processAlive = false;
254
+ } else if (pid != null) {
255
+ // ChildProcess 的退出事件可能晚于外部终止。这里同时探测 PID,避免
256
+ // 一次性观测在 watcher 下一轮运行前把已死亡的子进程报告为存活。
257
+ processAlive = deps.isPidAlive(pid);
258
+ } else if (source === "live" && record.headlessChild) {
259
+ processAlive = true;
260
+ }
261
+ if (record.headlessChild?.exitInfo) {
262
+ exitCode = record.headlessChild.exitInfo.code;
263
+ exitSignal = record.headlessChild.exitInfo.signal;
264
+ }
265
+ return {
266
+ kind,
267
+ pid,
268
+ processAlive,
269
+ surfaceAlive: null,
270
+ exitCode,
271
+ exitSignal,
272
+ };
273
+ }
274
+
275
+ let surfaceAlive: boolean | null = null;
276
+ if (deps.probeSurface) {
277
+ try {
278
+ surfaceAlive = deps.probeSurface(record.surface);
279
+ } catch {
280
+ surfaceAlive = null;
281
+ }
282
+ }
283
+ return {
284
+ kind,
285
+ pid: null,
286
+ processAlive: surfaceAlive,
287
+ surfaceAlive,
288
+ exitCode: null,
289
+ exitSignal: null,
290
+ };
291
+ }
292
+
293
+ function classifyLifecycle(
294
+ source: SubagentInspectionSource,
295
+ status: StatusSnapshot | null,
296
+ processAlive: boolean | null,
297
+ member: boolean,
298
+ rosterStatus: InspectRosterMember["status"] | null,
299
+ sessionFileExists: boolean,
300
+ ): SubagentInspectionLifecycle {
301
+ if (source === "session-registry") {
302
+ if (!sessionFileExists) return "discarded";
303
+ return member ? "offline" : "finished";
304
+ }
305
+ if (member && rosterStatus === "offline") return "offline";
306
+ if (processAlive === false) return "exited";
307
+ if (status?.kind === "stalled") return "stalled";
308
+ if (status?.kind === "waiting") return "waiting";
309
+ if (status?.kind === "active" || status?.kind === "running") return "running";
310
+ return "launching";
311
+ }
312
+
313
+ function resolveWaitingReason(params: {
314
+ lifecycle: SubagentInspectionLifecycle;
315
+ status: StatusSnapshot | null;
316
+ activity?: SubagentActivityState;
317
+ pendingQuestion: string | null;
318
+ member: boolean;
319
+ memberRound: "idle" | "dispatched" | null;
320
+ stdinLost: boolean;
321
+ }): string | null {
322
+ if (params.lifecycle === "exited") return "process_exited_pending_cleanup";
323
+ if (params.lifecycle === "offline") return "member_offline";
324
+ if (params.stdinLost) return "stdin_unavailable_after_reload";
325
+ if (params.pendingQuestion) return "ask_question";
326
+ if (
327
+ params.member &&
328
+ params.memberRound === "idle" &&
329
+ params.status?.kind !== "active" &&
330
+ params.status?.kind !== "running"
331
+ ) return "awaiting_dispatch";
332
+ if (params.status?.kind === "stalled") return "activity_stale";
333
+ if (params.status?.kind === "waiting") {
334
+ if (params.activity?.latestEvent === "ask_question") return "ask_question";
335
+ if (params.activity?.latestEvent === "agent_end") return "agent_waiting";
336
+ return "waiting_for_input";
337
+ }
338
+ if (params.status?.kind === "starting") return "starting";
339
+ return null;
340
+ }
341
+
342
+ function defaultWaitMode(record: InspectRecord, source: SubagentInspectionSource): SubagentWaitMode | null {
343
+ if (record.waitMode) return record.waitMode;
344
+ if (record.member) return "member-round";
345
+ if (record.interactive) return "interactive";
346
+ if (source === "runtime-registry") return "recovered";
347
+ if (source === "live") return "hard-barrier";
348
+ return null;
349
+ }
350
+
351
+ function buildInspection(
352
+ record: InspectRecord,
353
+ source: SubagentInspectionSource,
354
+ params: {
355
+ now: number;
356
+ activityRead: ActivityReadResult;
357
+ activity?: SubagentActivityState;
358
+ status: StatusSnapshot | null;
359
+ statusState?: SubagentStatusState;
360
+ process: ReturnType<typeof inspectProcess>;
361
+ loadout: SubagentLoadout | null;
362
+ roster: InspectRosterMember | null;
363
+ childNames: string[];
364
+ },
365
+ ): SubagentInspection {
366
+ const { now, activityRead, activity, status, statusState, process, loadout, roster, childNames } = params;
367
+ const sessionFileExists = Boolean(record.sessionFile) && existsSync(record.sessionFile);
368
+ const member = record.member === true || loadout?.member === true;
369
+ const memberRound = member
370
+ ? record.dispatchedRound != null
371
+ ? record.dispatchedRound ? "dispatched" : "idle"
372
+ : roster?.status === "dispatched" ? "dispatched" : "idle"
373
+ : null;
374
+ const rosterStatus = member ? roster?.status ?? null : null;
375
+ const lifecycle = classifyLifecycle(source, status, process.processAlive, member, rosterStatus, sessionFileExists);
376
+ const pendingQuestion = sessionFileExists ? readPendingQuestion(record.sessionFile) : null;
377
+ const lastActivityAt = statusState?.lastActivityAtMs ?? (activity ? activity.updatedAt : null);
378
+ const lastActivityAgeMs = lastActivityAt == null ? null : Math.max(0, now - lastActivityAt);
379
+ const activeSince = status?.activeSinceMs ?? activity?.activeSince ?? null;
380
+ const waitingSince = status?.waitingSinceMs ?? activity?.waitingSince ?? null;
381
+ const timeoutMs = record.timeoutMs ?? null;
382
+ const timeoutAt = timeoutMs == null ? null : record.startTime + timeoutMs;
383
+ const timeoutState: SubagentInspectionTimeoutState = timeoutMs == null
384
+ ? "not-configured"
385
+ : now >= timeoutAt!
386
+ ? "expired"
387
+ : "pending";
388
+ const waitMode = defaultWaitMode(record, source);
389
+ const stdinLost = record.stdinLost === true;
390
+ const trackedLive = source === "live";
391
+ const canSteer = trackedLive && process.processAlive !== false && (
392
+ process.kind === "pane"
393
+ ? process.surfaceAlive !== false
394
+ : !stdinLost && Boolean(record.headlessChild) && !record.headlessChild!.exited
395
+ );
396
+ const canResume = !trackedLive && process.processAlive !== true && sessionFileExists && Boolean(loadout);
397
+ let controlReason: string | null = null;
398
+ if (canSteer) controlReason = null;
399
+ else if (lifecycle === "exited") controlReason = "process_exited";
400
+ else if (stdinLost) controlReason = "host_reload_lost_stdin";
401
+ else if (source === "runtime-registry") controlReason = "not_owned_by_current_host";
402
+ else if (source === "session-registry") controlReason = "not_running";
403
+ else if (process.kind === "pane" && process.surfaceAlive === false) controlReason = "pane_not_found";
404
+ else controlReason = "no_live_control_handle";
405
+
406
+ let exitCode = process.exitCode;
407
+ let exitError: string | null = null;
408
+ const sidecar = sessionFileExists ? peekExitSidecar(record.sessionFile) : null;
409
+ if (sidecar) {
410
+ exitCode = sidecar.exitCode;
411
+ exitError = sidecar.errorMessage ?? null;
412
+ }
413
+
414
+ const activitySnapshot = source === "session-registry"
415
+ ? "unavailable" as const
416
+ : activityRead.ok
417
+ ? "present" as const
418
+ : activityRead.reason;
419
+ const statusPhase = activity?.phase ?? statusState?.phase ?? null;
420
+ const waitingReason = resolveWaitingReason({
421
+ lifecycle,
422
+ status,
423
+ activity,
424
+ pendingQuestion,
425
+ member,
426
+ memberRound,
427
+ stdinLost,
428
+ });
429
+ const model = loadout?.model ?? record.model ?? null;
430
+ const cohortId = record.cohortId ?? loadout?.cohortId ?? null;
431
+
432
+ return {
433
+ name: record.name,
434
+ id: record.id || null,
435
+ cohortId,
436
+ agent: loadout?.agent ?? record.agent ?? null,
437
+ task: truncate(record.task, MAX_TASK_LENGTH) || null,
438
+ model,
439
+ thinking: resolveThinking(loadout, model),
440
+ tier: loadout?.tier ?? null,
441
+ source,
442
+ lifecycle,
443
+ phase: statusPhase,
444
+ status: status?.kind ?? null,
445
+ statusLabel: status?.statusLabel ?? null,
446
+ surface: record.surface || null,
447
+ surfaceKind: source === "session-registry" ? null : process.kind,
448
+ pid: process.pid,
449
+ processAlive: process.processAlive,
450
+ surfaceAlive: process.surfaceAlive,
451
+ processExitCode: process.exitCode,
452
+ processExitSignal: process.exitSignal,
453
+ startTime: record.startTime,
454
+ elapsedMs: Math.max(0, now - record.startTime),
455
+ lastActivityAt,
456
+ lastActivityAgeMs,
457
+ activeScope: status?.activeScope ?? activity?.activeScope ?? null,
458
+ activeSince,
459
+ activeDurationMs: activeSince == null ? null : Math.max(0, now - activeSince),
460
+ waitingSince,
461
+ waitingDurationMs: waitingSince == null ? null : Math.max(0, now - waitingSince),
462
+ latestEvent: status?.latestEvent ?? activity?.latestEvent ?? null,
463
+ activitySnapshot,
464
+ activityError: activityRead.ok ? null : activityRead.error ?? null,
465
+ waitingReason,
466
+ ...(pendingQuestion ? { waitingQuestion: pendingQuestion } : {}),
467
+ member,
468
+ memberRound,
469
+ rosterStatus,
470
+ rosterReason: roster?.offlineReason ?? null,
471
+ parentId: record.parentId ?? null,
472
+ childCount: childNames.length,
473
+ childNames,
474
+ waitMode,
475
+ waitReleased: record.waitReleased ?? null,
476
+ timeoutMs,
477
+ timeoutAt,
478
+ timeoutState,
479
+ stdinLost,
480
+ sessionFile: record.sessionFile || null,
481
+ sessionFileExists,
482
+ loadoutExists: Boolean(loadout),
483
+ resumable: sessionFileExists && Boolean(loadout),
484
+ canSteer,
485
+ canResume,
486
+ controlReason,
487
+ exitCode,
488
+ exitError,
489
+ observedAt: now,
490
+ };
491
+ }
492
+
493
+ function asInspectRecord(record: RunningSubagent | RuntimeRecord): InspectRecord {
494
+ return record as unknown as InspectRecord;
495
+ }
496
+
497
+ /** 构造当前进程直接持有的实时观测。每次调用都会刷新 activity 快照。 */
498
+ export function buildLiveSubagentInspection(
499
+ running: RunningSubagent,
500
+ deps: Pick<SubagentInspectDeps, "observeRunningSubagent" | "isPidAlive" | "probeSurface" | "readSubagentLoadout"> & {
501
+ readRosterMember?: SubagentInspectDeps["readRosterMember"];
502
+ },
503
+ childNames: string[] = [],
504
+ now = Date.now(),
505
+ ): SubagentInspection {
506
+ deps.observeRunningSubagent(running, now);
507
+ const record = asInspectRecord(running);
508
+ const activityRead = freshActivity(record);
509
+ const activity = activityRead.ok ? activityRead.activity : running.activity;
510
+ // 根据刚读到的文件再核对一次状态。生产 observer 通常已经完成这一步,
511
+ // 但让 builder 自包含可以避免调用方传入轻量 observer 时返回旧状态。
512
+ const baseState = running.statusState ?? createStatusState({ source: "pi", startTimeMs: running.startTime });
513
+ const freshState = observeStatus(
514
+ baseState,
515
+ activityRead.ok
516
+ ? activityObservation(activityRead.activity)
517
+ : { snapshot: activityRead.reason, snapshotError: activityRead.error },
518
+ now,
519
+ );
520
+ running.statusState = freshState;
521
+ const status = classifyStatus(freshState, now);
522
+ const process = inspectProcess(record, "live", deps);
523
+ const loadout = deps.readSubagentLoadout(running.sessionFile);
524
+ const roster = deps.readRosterMember?.(running.name, "") ?? null;
525
+ return buildInspection(record, "live", {
526
+ now,
527
+ activityRead,
528
+ activity,
529
+ status,
530
+ statusState: freshState,
531
+ process,
532
+ loadout,
533
+ roster,
534
+ childNames,
535
+ });
536
+ }
537
+
538
+ /** 构造 /reload 后 runtime registry 中尚未被当前模块接管的记录。 */
539
+ export function buildRuntimeSubagentInspection(
540
+ record: RuntimeRecord,
541
+ deps: Pick<SubagentInspectDeps, "isPidAlive" | "probeSurface" | "readSubagentLoadout">,
542
+ childNames: string[] = [],
543
+ roster: InspectRosterMember | null = null,
544
+ now = Date.now(),
545
+ ): SubagentInspection {
546
+ const inspectRecord = asInspectRecord(record);
547
+ const activityRead = freshActivity(inspectRecord);
548
+ const activityStatus = statusFromActivity(record.startTime, activityRead, now);
549
+ const process = inspectProcess(inspectRecord, "runtime-registry", deps);
550
+ const loadout = deps.readSubagentLoadout(record.sessionFile);
551
+ return buildInspection(inspectRecord, "runtime-registry", {
552
+ now,
553
+ activityRead,
554
+ activity: activityStatus.activity,
555
+ status: activityStatus.snapshot,
556
+ statusState: activityStatus.state,
557
+ process,
558
+ loadout,
559
+ roster,
560
+ childNames,
561
+ });
562
+ }
563
+
564
+ /** 构造已完成或已清理的会话注册记录;不会把未知进程状态说成存活。 */
565
+ export function buildSessionSubagentInspection(
566
+ name: string,
567
+ entry: NameRegistryEntry,
568
+ deps: Pick<SubagentInspectDeps, "readSubagentLoadout">,
569
+ roster: InspectRosterMember | null = null,
570
+ now = Date.now(),
571
+ ): SubagentInspection {
572
+ const loadout = deps.readSubagentLoadout(entry.sessionFile);
573
+ const record: InspectRecord = {
574
+ id: "",
575
+ name,
576
+ task: "",
577
+ agent: loadout?.agent ?? undefined,
578
+ model: loadout?.model ?? null,
579
+ surface: "",
580
+ startTime: now,
581
+ sessionFile: entry.sessionFile,
582
+ ...(entry.cohortId ? { cohortId: entry.cohortId } : {}),
583
+ interactive: loadout?.autoExit === false,
584
+ ...(loadout?.member ? { member: true } : {}),
585
+ };
586
+ const activityRead: ActivityReadResult = { ok: false, reason: "missing" };
587
+ return buildInspection(record, "session-registry", {
588
+ now,
589
+ activityRead,
590
+ status: null,
591
+ process: {
592
+ kind: "pane",
593
+ pid: null,
594
+ processAlive: null,
595
+ surfaceAlive: null,
596
+ exitCode: null,
597
+ exitSignal: null,
598
+ },
599
+ loadout,
600
+ roster,
601
+ childNames: [],
602
+ });
603
+ }
604
+
605
+ function formatAge(ageMs: number | null): string {
606
+ if (ageMs == null) return "unknown";
607
+ if (ageMs < 1000) return "<1s ago";
608
+ const seconds = Math.floor(ageMs / 1000);
609
+ if (seconds < 60) return `${seconds}s ago`;
610
+ const minutes = Math.floor(seconds / 60);
611
+ if (minutes < 60) return `${minutes}m ago`;
612
+ return `${Math.floor(minutes / 60)}h ago`;
613
+ }
614
+
615
+ function formatInspection(view: SubagentInspection): string {
616
+ const status = view.status ? ` status=${view.status}` : "";
617
+ const phase = view.phase ? ` phase=${view.phase}` : "";
618
+ const process = view.pid != null
619
+ ? `pid=${view.pid} ${view.processAlive === true ? "alive" : view.processAlive === false ? "exited" : "unknown"}`
620
+ : view.surfaceKind === "pane"
621
+ ? `pane=${view.surfaceAlive === true ? "alive" : view.surfaceAlive === false ? "gone" : "unknown"}`
622
+ : "process=unknown";
623
+ const activity = view.latestEvent
624
+ ? `event=${view.latestEvent}${view.activeScope ? `/${view.activeScope}` : ""}`
625
+ : `activity=${view.activitySnapshot}`;
626
+ const wait = view.waitingReason ? ` wait=${view.waitingReason}` : "";
627
+ const children = view.childCount > 0 ? ` children=${view.childCount}(${view.childNames.join(",")})` : " children=0";
628
+ const timeout = view.timeoutMs == null ? " timeout=none" : ` timeout=${view.timeoutState}/${view.timeoutMs}ms`;
629
+ const controls = `steer=${view.canSteer ? "yes" : "no"} resume=${view.canResume ? "yes" : "no"}`;
630
+ const model = view.model ? ` model=${view.model}` : "";
631
+ const thinking = view.thinking ? ` thinking=${view.thinking}` : "";
632
+ const cohort = view.cohortId ? ` cohort=${view.cohortId}` : "";
633
+ const task = view.task ? `\n task: ${view.task}` : "";
634
+ const question = view.waitingQuestion ? `\n question: ${view.waitingQuestion}` : "";
635
+ return (
636
+ `• ${view.name} [${view.lifecycle}]${status}${phase} elapsed=${Math.floor((view.elapsedMs ?? 0) / 1000)}s ` +
637
+ `${process} last=${formatAge(view.lastActivityAgeMs)} ${activity}${wait}${children}${timeout} ${controls}${model}${thinking}${cohort}` +
638
+ task + question
639
+ );
640
+ }
641
+
642
+ export function registerSubagentInspectTool(pi: ExtensionAPI, deps: SubagentInspectDeps): void {
643
+ pi.registerTool({
644
+ name: "subagent_inspect",
645
+ label: "Inspect Subagents",
646
+ description:
647
+ "Take a fresh, read-only status snapshot of subagents managed by this pi-subagents extension. " +
648
+ "With no name, inspect all currently running/recovering subagents; with name, inspect that session even if it has finished. " +
649
+ "The snapshot includes lifecycle, activity phase/event, last activity age, waiting reason or question, process/PID or pane liveness, " +
650
+ "model/thinking, optional cohortId, child count, timeout state, and steer/resume availability. " +
651
+ "This is an on-demand observation, not a polling loop; do not call it repeatedly just to wait.",
652
+ promptSnippet:
653
+ "Inspect the current subagent status once: lifecycle, phase, last activity, waiting reason, process/PID, children, timeout, and controls.",
654
+ parameters: Type.Object({
655
+ name: Type.Optional(
656
+ Type.String({
657
+ description:
658
+ "Optional exact subagent display name. Omit to inspect all currently running or recovering subagents.",
659
+ }),
660
+ ),
661
+ includeFinished: Type.Optional(
662
+ Type.Boolean({
663
+ description:
664
+ "When name is omitted, also include finished, offline, and discarded session-registry entries.",
665
+ }),
666
+ ),
667
+ }),
668
+
669
+ renderCall(args, theme) {
670
+ const name = typeof (args as any)?.name === "string" && (args as any).name.trim()
671
+ ? (args as any).name.trim()
672
+ : "all running";
673
+ return new Text(
674
+ "○ " + theme.fg("toolTitle", theme.bold("inspect ")) + theme.fg("dim", name),
675
+ 0,
676
+ 0,
677
+ );
678
+ },
679
+
680
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
681
+ const sessionFile = ctx?.sessionManager?.getSessionFile?.();
682
+ if (!sessionFile) {
683
+ const error =
684
+ "Subagent inspection is unavailable without a persistent parent session. Start pi with a session file first.";
685
+ return { content: [{ type: "text" as const, text: error }], details: { error: "session-unavailable" } };
686
+ }
687
+
688
+ const artifactDir = deps.getArtifactDir(
689
+ ctx.sessionManager.getSessionDir(),
690
+ ctx.sessionManager.getSessionId(),
691
+ );
692
+ const now = Date.now();
693
+ const registry = deps.readNameRegistry(artifactDir);
694
+ const runtimePath = deps.runtimeRegistryPath(artifactDir);
695
+ const runtimeRecords = deps.readRuntimeRecords(runtimePath);
696
+ const requestedName = typeof params?.name === "string"
697
+ ? normalizeName(params.name.trim(), "")
698
+ : "";
699
+ const includeFinished = params?.includeFinished === true;
700
+ const liveByName = new Map<string, RunningSubagent>();
701
+ for (const running of deps.runningSubagents.values()) {
702
+ liveByName.set(running.name, running);
703
+ }
704
+ const runtimeByName = new Map<string, RuntimeRecord>();
705
+ for (const record of runtimeRecords) {
706
+ if (!liveByName.has(record.name)) runtimeByName.set(record.name, record);
707
+ }
708
+ const rosterFor = (name: string): InspectRosterMember | null =>
709
+ deps.readRosterMember ? deps.readRosterMember(name, artifactDir) : null;
710
+ const childRecords = [
711
+ ...Array.from(deps.runningSubagents.values()).map((child) => ({
712
+ id: child.id,
713
+ name: child.name,
714
+ parentId: child.parentId ?? null,
715
+ })),
716
+ ...runtimeRecords.map((record) => ({
717
+ id: record.id,
718
+ name: record.name,
719
+ parentId: record.parentId ?? null,
720
+ })),
721
+ ];
722
+ const childNamesFor = (id: string): string[] => [...new Set(
723
+ childRecords
724
+ .filter((child) => child.parentId === id)
725
+ .map((child) => child.name),
726
+ )].sort((a, b) => a.localeCompare(b));
727
+
728
+ const inspectOne = (name: string): SubagentInspection | null => {
729
+ const live = liveByName.get(name);
730
+ if (live) {
731
+ return buildLiveSubagentInspection(
732
+ live,
733
+ {
734
+ observeRunningSubagent: deps.observeRunningSubagent,
735
+ isPidAlive: deps.isPidAlive,
736
+ probeSurface: deps.probeSurface,
737
+ readSubagentLoadout: deps.readSubagentLoadout,
738
+ readRosterMember: (memberName) => rosterFor(memberName),
739
+ },
740
+ childNamesFor(live.id),
741
+ now,
742
+ );
743
+ }
744
+ const runtime = runtimeByName.get(name);
745
+ if (runtime) {
746
+ // 旧 runtime 记录可能没有 activityFile;新路径可由父 artifact
747
+ // 目录和 child id 稳定推导,避免 /reload 后丢失状态观测。
748
+ const runtimeForInspection = runtime.activityFile
749
+ ? runtime
750
+ : { ...runtime, activityFile: getSubagentActivityFile(artifactDir, runtime.id) };
751
+ return buildRuntimeSubagentInspection(
752
+ runtimeForInspection,
753
+ {
754
+ isPidAlive: deps.isPidAlive,
755
+ probeSurface: deps.probeSurface,
756
+ readSubagentLoadout: deps.readSubagentLoadout,
757
+ },
758
+ childNamesFor(runtime.id),
759
+ rosterFor(name),
760
+ now,
761
+ );
762
+ }
763
+ const entry = registry[name];
764
+ if (entry && typeof entry.sessionFile === "string") {
765
+ return buildSessionSubagentInspection(name, entry, deps, rosterFor(name), now);
766
+ }
767
+ return null;
768
+ };
769
+
770
+ if (requestedName) {
771
+ const inspection = inspectOne(requestedName);
772
+ if (!inspection) {
773
+ const known = [...new Set([
774
+ ...liveByName.keys(),
775
+ ...runtimeByName.keys(),
776
+ ...Object.keys(registry),
777
+ ])].sort();
778
+ const error = `No subagent named "${requestedName}" is registered in this parent session.`;
779
+ return {
780
+ content: [{ type: "text" as const, text: known.length ? `${error} Known names: ${known.join(", ")}.` : error }],
781
+ details: { error: "unknown-subagent", name: requestedName, knownNames: known, observedAt: now },
782
+ };
783
+ }
784
+ return {
785
+ content: [{ type: "text" as const, text: `Subagent status (observed at ${new Date(now).toISOString()}):\n${formatInspection(inspection)}` }],
786
+ details: { observedAt: now, scope: "name", name: requestedName, subagents: [inspection] },
787
+ };
788
+ }
789
+
790
+ const inspections: SubagentInspection[] = [];
791
+ for (const name of liveByName.keys()) {
792
+ const inspection = inspectOne(name);
793
+ if (inspection) inspections.push(inspection);
794
+ }
795
+ for (const name of runtimeByName.keys()) {
796
+ const inspection = inspectOne(name);
797
+ if (inspection) inspections.push(inspection);
798
+ }
799
+ if (includeFinished) {
800
+ for (const name of Object.keys(registry)) {
801
+ if (liveByName.has(name) || runtimeByName.has(name)) continue;
802
+ const inspection = inspectOne(name);
803
+ if (inspection) inspections.push(inspection);
804
+ }
805
+ }
806
+ inspections.sort((a, b) => a.name.localeCompare(b.name));
807
+ const text = inspections.length === 0
808
+ ? includeFinished
809
+ ? "No subagents are registered in this parent session."
810
+ : "No running or recovering subagents are currently registered. Use includeFinished: true to include session history."
811
+ : `Subagent status (observed at ${new Date(now).toISOString()}):\n${inspections.map(formatInspection).join("\n")}`;
812
+ return {
813
+ content: [{ type: "text" as const, text }],
814
+ details: { observedAt: now, scope: includeFinished ? "all" : "running", subagents: inspections },
815
+ };
816
+ },
817
+
818
+ renderResult(result, _opts, theme) {
819
+ const details = result.details as { subagents?: SubagentInspection[]; error?: string } | undefined;
820
+ if (details?.error) {
821
+ const text = result.content[0]?.type === "text" ? result.content[0].text : details.error;
822
+ return new Text(theme.fg("error", text), 0, 0);
823
+ }
824
+ const views = details?.subagents ?? [];
825
+ if (views.length === 0) {
826
+ return new Text(theme.fg("dim", "No running subagents"), 0, 0);
827
+ }
828
+ return new Text(
829
+ views.map((view) => {
830
+ const color = view.lifecycle === "stalled" || view.lifecycle === "exited" ? "error" :
831
+ view.lifecycle === "waiting" || view.lifecycle === "offline" ? "warning" : "accent";
832
+ return theme.fg(color, `● ${view.name} — ${view.lifecycle}, last ${formatAge(view.lastActivityAgeMs)}`);
833
+ }).join("\n"),
834
+ 0,
835
+ 0,
836
+ );
837
+ },
838
+ });
839
+ }