@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,212 @@
1
+ import type { SessionStats } from "./session.ts";
2
+
3
+ /**
4
+ * 路由异常结构化分类:子代理的 errorMessage 被归到有限的 kind 集合,连同
5
+ * retryable 与 suggestedActions 一起作为 tool result details 里的
6
+ * route_exception 提供给主编排代理。是否重试/换模型由主代理决定——本扩展
7
+ * 不做自动 fallback,也不暂停其它 sibling 子代理。
8
+ */
9
+ export type RouteExceptionKind =
10
+ | "quota_exhausted"
11
+ | "rate_limited"
12
+ | "auth_failed"
13
+ | "model_unavailable"
14
+ | "context_too_large"
15
+ | "provider_error"
16
+ | "tool_failure"
17
+ | "unknown";
18
+
19
+ export interface RouteException {
20
+ kind: RouteExceptionKind;
21
+ retryable: boolean;
22
+ /** 原始错误文本(人类可读 content 之外的程序可读副本)。 */
23
+ message: string;
24
+ /** 能从已解析模型 id 拆出时提供("openrouter/z-ai/glm-5.2" → "openrouter")。 */
25
+ provider?: string;
26
+ /** 已解析模型 id(不含 provider 前缀;能解析时提供)。 */
27
+ model?: string;
28
+ /** 子代理会话中已执行的工具调用数(区分"启动即败"与"执行中途失败")。 */
29
+ toolCount?: number;
30
+ suggestedActions: string[];
31
+ }
32
+
33
+ /** 拆分 "provider/model[:thinking]" 形式的模型 id;无 provider 前缀时省略。 */
34
+ export function splitModelRef(modelRef: string): { provider?: string; model: string } {
35
+ const slash = modelRef.indexOf("/");
36
+ if (slash > 0 && slash < modelRef.length - 1) {
37
+ return { provider: modelRef.slice(0, slash), model: modelRef.slice(slash + 1) };
38
+ }
39
+ return { model: modelRef };
40
+ }
41
+
42
+ interface RoutePattern {
43
+ kind: RouteExceptionKind;
44
+ retryable: boolean;
45
+ patterns: RegExp[];
46
+ suggestedActions: string[];
47
+ }
48
+
49
+ /**
50
+ * 分类规则按优先级排列:更具体/更高价值判定(上下文超限、鉴权、额度、
51
+ * 模型不存在)先于通用供应商错误;通用 5xx/网络类最后兜底,未命中即 unknown。
52
+ * 模式来自常见供应商错误文案(Anthropic/OpenAI/OpenRouter),保守避免误判。
53
+ */
54
+ const ROUTE_PATTERNS: RoutePattern[] = [
55
+ {
56
+ kind: "context_too_large",
57
+ retryable: false,
58
+ patterns: [
59
+ /context (?:length|window)/i,
60
+ /maximum context/i,
61
+ /context too large/i,
62
+ /prompt is too long/i,
63
+ /input (?:is )?too long/i,
64
+ /too many (?:input )?tokens/i,
65
+ /token limit/i,
66
+ /exceeds? the maximum/i,
67
+ ],
68
+ suggestedActions: [
69
+ "Reduce or split the task, or attach less context",
70
+ "Spawn with a model that has a larger context window",
71
+ ],
72
+ },
73
+ {
74
+ kind: "auth_failed",
75
+ retryable: false,
76
+ patterns: [
77
+ /\b401\b/,
78
+ /\b403\b/,
79
+ /unauthorized/i,
80
+ /forbidden/i,
81
+ /invalid api key/i,
82
+ /api key/i,
83
+ /authentication/i,
84
+ /not authenticated/i,
85
+ ],
86
+ suggestedActions: [
87
+ "Check the provider credentials/API key configuration",
88
+ "Fix the credential, then retry manually",
89
+ ],
90
+ },
91
+ {
92
+ kind: "quota_exhausted",
93
+ retryable: false,
94
+ patterns: [
95
+ /quota/i,
96
+ /\b402\b/,
97
+ /insufficient (?:credits?|balance|funds)/i,
98
+ /out of credits?/i,
99
+ /credit balance/i,
100
+ /billing/i,
101
+ /payment/i,
102
+ /spending limit/i,
103
+ ],
104
+ suggestedActions: [
105
+ "Check the provider account balance/quota",
106
+ "Decide on a different provider/model and respawn manually — no automatic switch",
107
+ ],
108
+ },
109
+ {
110
+ kind: "rate_limited",
111
+ retryable: true,
112
+ patterns: [/\b429\b/, /rate limit/i, /too many requests/i, /overloaded/i],
113
+ suggestedActions: [
114
+ "Wait for the limit window to pass, then retry manually",
115
+ "Consider a different model or provider if this recurs",
116
+ ],
117
+ },
118
+ {
119
+ kind: "model_unavailable",
120
+ retryable: false,
121
+ patterns: [
122
+ /model (?:is )?not found/i,
123
+ /does not exist/i,
124
+ /unknown model/i,
125
+ /invalid model/i,
126
+ /no such model/i,
127
+ /model.*not available/i,
128
+ /has been (?:decommissioned|deprecated)/i,
129
+ ],
130
+ suggestedActions: [
131
+ "Verify the model id for this provider",
132
+ "Update the pi-subagents tier mapping or pass an explicit model",
133
+ ],
134
+ },
135
+ {
136
+ kind: "tool_failure",
137
+ retryable: false,
138
+ patterns: [/tool (?:call|execution) failed/i, /failed to (?:run|execute) tool/i],
139
+ suggestedActions: [
140
+ "Inspect the sub-agent session for the failing tool call",
141
+ "Retry with a narrower task or different tools",
142
+ ],
143
+ },
144
+ {
145
+ kind: "provider_error",
146
+ retryable: true,
147
+ patterns: [
148
+ /\b5\d\d\b/,
149
+ /bad gateway/i,
150
+ /service unavailable/i,
151
+ /internal server error/i,
152
+ /server error/i,
153
+ /timeout|timed out/i,
154
+ /econn(?:refused|reset|aborted)/i,
155
+ /enotfound/i,
156
+ /fetch failed/i,
157
+ /connection (?:error|refused|reset)/i,
158
+ ],
159
+ suggestedActions: [
160
+ "Check the provider status page, then retry manually",
161
+ "Retry with a different provider/model if the outage persists",
162
+ ],
163
+ },
164
+ ];
165
+
166
+ /**
167
+ * 从错误文本分类路由异常。context.model 取自主代理已解析的模型 id
168
+ * (launch loadout / resume loadout),只做展示拆分,不影响 kind 判定。
169
+ */
170
+ export function buildRouteException(
171
+ errorMessage: string,
172
+ context?: { model?: string | null; toolCount?: number },
173
+ ): RouteException {
174
+ const matched = ROUTE_PATTERNS.find((route) =>
175
+ route.patterns.some((pattern) => pattern.test(errorMessage)),
176
+ );
177
+ const modelRef = context?.model?.trim();
178
+ const split = modelRef ? splitModelRef(modelRef) : { provider: undefined, model: undefined };
179
+ const base = matched ?? {
180
+ kind: "unknown" as const,
181
+ retryable: false,
182
+ suggestedActions: [
183
+ "Inspect the sub-agent session file for the full error",
184
+ "Retry manually or spawn with a different model",
185
+ ],
186
+ };
187
+ return {
188
+ kind: base.kind,
189
+ retryable: base.retryable,
190
+ message: errorMessage,
191
+ ...(split.provider ? { provider: split.provider } : {}),
192
+ ...(split.model ? { model: split.model } : {}),
193
+ ...(context?.toolCount != null ? { toolCount: context.toolCount } : {}),
194
+ suggestedActions: base.suggestedActions,
195
+ };
196
+ }
197
+
198
+ /**
199
+ * 子代理结果 → route_exception(details 组装的统一入口):errorMessage 缺失
200
+ * (成功/取消)或 userClosed(用户关闭 pane,非 provider/agent 错误)返回
201
+ * undefined;已有 errorMessage 则现场分类,并把 stats.toolCount 一并带上。
202
+ */
203
+ export function routeExceptionFromResult(
204
+ result: { errorMessage?: string; stats?: Pick<SessionStats, "toolCount">; userClosed?: boolean },
205
+ model?: string | null,
206
+ ): RouteException | undefined {
207
+ if (!result.errorMessage || result.userClosed) return undefined;
208
+ return buildRouteException(result.errorMessage, {
209
+ model,
210
+ toolCount: result.stats?.toolCount,
211
+ });
212
+ }
@@ -0,0 +1,235 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { homedir } from "node:os";
5
+
6
+ // src/routing.ts → 包根(两层向上),与 status.ts 的 PACKAGE_ROOT 同规则。
7
+ const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
8
+
9
+ /** 规范档位;配置与 params 都归一化到这三个值。 */
10
+ export type ModelTier = "fast" | "balanced" | "deep";
11
+
12
+ export const MODEL_TIERS: readonly ModelTier[] = ["fast", "balanced", "deep"];
13
+
14
+ /**
15
+ * tier 别名表:params 与配置键共用,归一化到 canonical。刻意保持最小集合,
16
+ * 不再扩大别名——同一含义多个写法只增加配置歧义。
17
+ */
18
+ const TIER_ALIASES: Record<ModelTier, string[]> = {
19
+ fast: ["fast", "quick"],
20
+ balanced: ["balanced", "balance", "standard"],
21
+ deep: ["deep", "strong"],
22
+ };
23
+
24
+ /** 归一化 tier 输入;无法识别返回 null,调用方必须报清晰错误,不得静默换档。 */
25
+ export function normalizeTier(input: string): ModelTier | null {
26
+ const value = input.trim().toLowerCase();
27
+ for (const tier of MODEL_TIERS) {
28
+ if (TIER_ALIASES[tier].includes(value)) return tier;
29
+ }
30
+ return null;
31
+ }
32
+
33
+ export interface TierRouteConfig {
34
+ /** tier → 模型 id(可带 ":thinking" 后缀,由启动参数构造层统一处理)。 */
35
+ models: Partial<Record<ModelTier, string>>;
36
+ /** 配置来源文件路径,用于错误提示定位。 */
37
+ sourcePath: string;
38
+ }
39
+
40
+ export type TierConfigParseResult = { config: TierRouteConfig } | { error: string };
41
+
42
+ /**
43
+ * 严格校验 tier 配置。根对象允许携带其它键(包级 config.json 与 status 配置
44
+ * 共用同一文件,这里只认 models);models 内部从严:未知 tier 键、非字符串、
45
+ * 空串、含空白、同 tier 别名重复都报错。models 缺失视为合法但无映射。
46
+ */
47
+ export function parseTierConfig(raw: unknown, source: string): TierConfigParseResult {
48
+ if (raw == null || typeof raw !== "object" || Array.isArray(raw)) {
49
+ return { error: `${source}: root must be a JSON object` };
50
+ }
51
+ const root = raw as Record<string, unknown>;
52
+ const rawModels = root.models;
53
+ if (rawModels === undefined) {
54
+ return { config: { models: {}, sourcePath: source } };
55
+ }
56
+ if (rawModels == null || typeof rawModels !== "object" || Array.isArray(rawModels)) {
57
+ return { error: `${source}: models must be an object` };
58
+ }
59
+ const models: Partial<Record<ModelTier, string>> = {};
60
+ const aliasOwner = new Map<ModelTier, string>();
61
+ for (const [key, value] of Object.entries(rawModels as Record<string, unknown>)) {
62
+ const tier = normalizeTier(key);
63
+ if (!tier) {
64
+ return {
65
+ error: `${source}: models has unsupported tier key "${key}" (use ${MODEL_TIERS.join(", ")})`,
66
+ };
67
+ }
68
+ if (typeof value !== "string" || value.trim() === "") {
69
+ return { error: `${source}: models.${key} must be a non-empty string` };
70
+ }
71
+ if (/\s/.test(value)) {
72
+ return { error: `${source}: models.${key} must not contain whitespace` };
73
+ }
74
+ const previousKey = aliasOwner.get(tier);
75
+ if (previousKey !== undefined) {
76
+ return {
77
+ error: `${source}: models has conflicting entries for tier "${tier}" ("${previousKey}" and "${key}")`,
78
+ };
79
+ }
80
+ aliasOwner.set(tier, key);
81
+ models[tier] = value;
82
+ }
83
+ return { config: { models, sourcePath: source } };
84
+ }
85
+
86
+ export interface TierConfigLoadResult {
87
+ /** null 表示没有任何配置文件(合法;仅在使用 tier 时才需要报错)。 */
88
+ config: TierRouteConfig | null;
89
+ /** 实际读取(或读取失败)的配置路径;一个都没找到时为 null。 */
90
+ sourcePath: string | null;
91
+ /** 显式指定(PI_SUBAGENTS_CONFIG)的文件缺失/解析失败时的错误。 */
92
+ error?: string;
93
+ }
94
+
95
+ /**
96
+ * 宿主注入的配置层(pi-toolkit 迁入后新增):调用方已经把 `subagents` 节解析好,
97
+ * 这里只负责把它按优先级插进候选链。`source` 仅用于错误提示定位。
98
+ */
99
+ export interface TierRouteInjectedSource {
100
+ readonly source: string;
101
+ readonly raw: unknown;
102
+ }
103
+
104
+ /**
105
+ * 配置读取链:PI_SUBAGENTS_CONFIG → <cwd>/.pi/agent/pi-subagents.json →
106
+ * PI_CODING_AGENT_DIR(缺省 ~/.pi/agent)/pi-subagents.json → 宿主注入层
107
+ * (pi-toolkit.json 的 `modules.subagents` 节) → 包 config.json →
108
+ * 包 config.json.example。第一个存在的文件即最终答案;显式环境变量指向的
109
+ * 文件缺失或非法视为配置错误,其余候选缺失只跳过。纯读取,不写盘,
110
+ * 不触碰凭据/余额。
111
+ */
112
+ export function loadTierRouteConfig(options?: {
113
+ env?: NodeJS.ProcessEnv;
114
+ cwd?: string;
115
+ agentConfigDir?: string;
116
+ /** 宿主注入的额外候选层,排在全局 pi-subagents.json 之后、包内文件之前 */
117
+ injected?: readonly TierRouteInjectedSource[];
118
+ }): TierConfigLoadResult {
119
+ const env = options?.env ?? process.env;
120
+ const cwd = options?.cwd ?? process.cwd();
121
+ // 与 index.ts getAgentConfigDir 同规则;以参数注入避免模块反向依赖。
122
+ const agentConfigDir =
123
+ options?.agentConfigDir ?? env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
124
+
125
+ const candidates: Array<{ path: string; explicit: boolean; raw?: unknown }> = [];
126
+ const explicit = env.PI_SUBAGENTS_CONFIG?.trim();
127
+ if (explicit) candidates.push({ path: explicit, explicit: true });
128
+ candidates.push(
129
+ { path: join(cwd, ".pi", "agent", "pi-subagents.json"), explicit: false },
130
+ { path: join(agentConfigDir, "pi-subagents.json"), explicit: false },
131
+ );
132
+ for (const source of options?.injected ?? []) {
133
+ candidates.push({ path: source.source, explicit: false, raw: source.raw });
134
+ }
135
+ candidates.push(
136
+ { path: join(PACKAGE_ROOT, "config.json"), explicit: false },
137
+ { path: join(PACKAGE_ROOT, "config.json.example"), explicit: false },
138
+ );
139
+
140
+ for (const candidate of candidates) {
141
+ let parsed: unknown;
142
+ if (candidate.raw !== undefined) {
143
+ // 宿主注入层:调用方已经读过一次(pi-toolkit.json 是宿主自己的文件),
144
+ // 这里直接吃解析结果,不再重复读盘。
145
+ parsed = candidate.raw;
146
+ } else {
147
+ let raw: string;
148
+ try {
149
+ raw = readFileSync(candidate.path, "utf8");
150
+ } catch (error) {
151
+ const errno = error as NodeJS.ErrnoException;
152
+ if (errno.code === "ENOENT") {
153
+ if (candidate.explicit) {
154
+ return {
155
+ config: null,
156
+ sourcePath: candidate.path,
157
+ error: `PI_SUBAGENTS_CONFIG points to a missing file: ${candidate.path}`,
158
+ };
159
+ }
160
+ continue;
161
+ }
162
+ return {
163
+ config: null,
164
+ sourcePath: candidate.path,
165
+ error: `Could not read ${candidate.path}: ${errno.message ?? String(error)}`,
166
+ };
167
+ }
168
+ try {
169
+ parsed = JSON.parse(raw);
170
+ } catch (error) {
171
+ const detail = error instanceof Error ? error.message : String(error);
172
+ return {
173
+ config: null,
174
+ sourcePath: candidate.path,
175
+ error: `Invalid JSON in ${candidate.path}: ${detail}`,
176
+ };
177
+ }
178
+ }
179
+ const parsedConfig = parseTierConfig(parsed, candidate.path);
180
+ if ("error" in parsedConfig) {
181
+ return { config: null, sourcePath: candidate.path, error: parsedConfig.error };
182
+ }
183
+ return { config: parsedConfig.config, sourcePath: candidate.path };
184
+ }
185
+ return { config: null, sourcePath: null };
186
+ }
187
+
188
+ /** tier → 具体模型;缺映射给清晰错误,绝不静默换模型。 */
189
+ export function resolveTierModel(
190
+ tier: ModelTier,
191
+ config: TierRouteConfig | null,
192
+ ): { model: string } | { error: string } {
193
+ const model = config?.models[tier];
194
+ if (!model) {
195
+ const hint = config
196
+ ? ` Add models.${tier} to ${config.sourcePath}.`
197
+ : " No pi-subagents config was found (looked at $PI_SUBAGENTS_CONFIG, " +
198
+ "<cwd>/.pi/agent/pi-subagents.json, $PI_CODING_AGENT_DIR/pi-subagents.json, " +
199
+ "and the package config).";
200
+ return { error: `No model configured for tier "${tier}".${hint}` };
201
+ }
202
+ return { model };
203
+ }
204
+
205
+ export type TierLaunchResolution =
206
+ | { tier: ModelTier | null; model?: string }
207
+ | { error: string };
208
+
209
+ /**
210
+ * launch 前的 tier 解析:显式 params.model 永远优先 tier(同时给出时不读
211
+ * 配置,tier 仅作 loadout 记录);tier 无法归一化或缺映射时报错,不回退。
212
+ */
213
+ export function resolveTierForParams(
214
+ params: { tier?: string; model?: string },
215
+ loadConfig: () => TierConfigLoadResult,
216
+ ): TierLaunchResolution {
217
+ const requested = params.tier?.trim();
218
+ if (!requested) return { tier: null };
219
+ const tier = normalizeTier(requested);
220
+ if (!tier) {
221
+ return {
222
+ error:
223
+ `Invalid tier "${params.tier}". Use one of: ${MODEL_TIERS.join(", ")} ` +
224
+ "(aliases: quick, balance/standard, strong).",
225
+ };
226
+ }
227
+ if (params.model) return { tier };
228
+ const loaded = loadConfig();
229
+ if (loaded.error) {
230
+ return { error: `Tier "${tier}" could not be resolved: ${loaded.error}` };
231
+ }
232
+ const resolved = resolveTierModel(tier, loaded.config);
233
+ if ("error" in resolved) return { error: resolved.error };
234
+ return { tier, model: resolved.model };
235
+ }
@@ -0,0 +1,159 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { debugLog } from "./diagnostics.ts";
4
+ import type { SubagentWaitMode, SubagentWaitRelease } from "./types.ts";
5
+
6
+ export interface RuntimeRecord {
7
+ version: 1;
8
+ id: string;
9
+ name: string;
10
+ task: string;
11
+ /** 仅用于展示和结果聚合的并行分组标签。 */
12
+ cohortId?: string;
13
+ agent?: string;
14
+ surface: string;
15
+ startTime: number;
16
+ sessionFile: string;
17
+ activityFile?: string;
18
+ sentinelToken: string;
19
+ interactive: boolean;
20
+ /** 运行载体;缺省(旧记录)按 "pane" 处理。 */
21
+ kind?: "pane" | "headless";
22
+ /** headless 运行的子进程 PID,用于 /reload 后探测存活性。 */
23
+ pid?: number;
24
+ /** 直接父子代理 run id;顶层子代理为 null。 */
25
+ parentId?: string | null;
26
+ /** 本次硬屏障等待上限(毫秒)。 */
27
+ timeoutMs?: number;
28
+ /** 启动时的等待载体,供 /reload 后状态观测。 */
29
+ waitMode?: SubagentWaitMode;
30
+ /** 主工具等待已被 Escape/timeout 解除,子代理仍在运行。 */
31
+ waitReleased?: SubagentWaitRelease;
32
+ /** 持久团队成员(member: true):常驻 headless,恢复路径按 offline 处理。 */
33
+ member?: boolean;
34
+ }
35
+
36
+ interface RuntimeRegistry {
37
+ version: 1;
38
+ records: RuntimeRecord[];
39
+ }
40
+
41
+ export type RuntimeSource = Omit<RuntimeRecord, "version">;
42
+
43
+ /**
44
+ * 从运行中的 RunningSubagent 挑选可持久化字段构造记录。
45
+ * 显式挑选而非展开:running 上挂着 statusState/headlessChild 等运行时对象
46
+ * (ChildProcess 存在循环引用),整体展开会让 JSON.stringify 炸掉。
47
+ */
48
+ export function createRuntimeRecord(source: RuntimeSource): RuntimeRecord {
49
+ return {
50
+ version: 1,
51
+ id: source.id,
52
+ name: source.name,
53
+ task: source.task,
54
+ ...(source.cohortId ? { cohortId: source.cohortId } : {}),
55
+ ...(source.agent ? { agent: source.agent } : {}),
56
+ surface: source.surface,
57
+ startTime: source.startTime,
58
+ sessionFile: source.sessionFile,
59
+ ...(source.activityFile ? { activityFile: source.activityFile } : {}),
60
+ sentinelToken: source.sentinelToken,
61
+ interactive: source.interactive,
62
+ ...(source.kind ? { kind: source.kind } : {}),
63
+ ...(source.pid != null ? { pid: source.pid } : {}),
64
+ ...(source.parentId !== undefined ? { parentId: source.parentId } : {}),
65
+ ...(source.timeoutMs != null ? { timeoutMs: source.timeoutMs } : {}),
66
+ ...(source.waitMode ? { waitMode: source.waitMode } : {}),
67
+ ...(source.waitReleased ? { waitReleased: source.waitReleased } : {}),
68
+ ...(source.member ? { member: true } : {}),
69
+ };
70
+ }
71
+
72
+ export function runtimeRegistryPath(artifactDir: string): string {
73
+ return join(artifactDir, "subagent-runtime.json");
74
+ }
75
+
76
+ export function readRuntimeRecords(path: string): RuntimeRecord[] {
77
+ try {
78
+ if (!existsSync(path)) return [];
79
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as RuntimeRegistry;
80
+ if (parsed?.version !== 1 || !Array.isArray(parsed.records)) return [];
81
+ return parsed.records.filter((record) =>
82
+ record &&
83
+ record.version === 1 &&
84
+ typeof record.id === "string" &&
85
+ typeof record.name === "string" &&
86
+ typeof record.task === "string" &&
87
+ (record.cohortId == null || typeof record.cohortId === "string") &&
88
+ typeof record.surface === "string" &&
89
+ typeof record.sessionFile === "string" &&
90
+ typeof record.sentinelToken === "string" &&
91
+ typeof record.startTime === "number" &&
92
+ Number.isFinite(record.startTime) &&
93
+ typeof record.interactive === "boolean" &&
94
+ (record.agent == null || typeof record.agent === "string") &&
95
+ (record.activityFile == null || typeof record.activityFile === "string") &&
96
+ (record.member == null || typeof record.member === "boolean") &&
97
+ (record.pid == null || (typeof record.pid === "number" && Number.isInteger(record.pid) && record.pid > 0)) &&
98
+ (record.parentId == null || typeof record.parentId === "string") &&
99
+ (record.timeoutMs == null || (typeof record.timeoutMs === "number" && Number.isFinite(record.timeoutMs) && record.timeoutMs >= 1000)) &&
100
+ (record.waitMode == null ||
101
+ record.waitMode === "hard-barrier" ||
102
+ record.waitMode === "interactive" ||
103
+ record.waitMode === "member-round" ||
104
+ record.waitMode === "recovered") &&
105
+ (record.waitReleased == null || record.waitReleased === "escape" || record.waitReleased === "timeout"),
106
+ );
107
+ } catch {
108
+ return [];
109
+ }
110
+ }
111
+
112
+ function writeRuntimeRecords(path: string, records: RuntimeRecord[]): void {
113
+ mkdirSync(dirname(path), { recursive: true });
114
+ const temp = `${path}.${process.pid}.tmp`;
115
+ writeFileSync(temp, JSON.stringify({ version: 1, records }), "utf8");
116
+ renameSync(temp, path);
117
+ }
118
+
119
+ export function upsertRuntimeRecord(path: string, record: RuntimeRecord): void {
120
+ const records = readRuntimeRecords(path).filter((item) => item.id !== record.id);
121
+ writeRuntimeRecords(path, [...records, record]);
122
+ }
123
+
124
+ /** 更新仍在登记中的等待释放原因;记录已被 watcher 移除时不重新创建。 */
125
+ export function markRuntimeWaitReleased(
126
+ path: string,
127
+ id: string,
128
+ waitReleased: SubagentWaitRelease,
129
+ ): void {
130
+ const records = readRuntimeRecords(path);
131
+ if (!records.some((record) => record.id === id)) return;
132
+ try {
133
+ writeRuntimeRecords(
134
+ path,
135
+ records.map((record) => record.id === id ? { ...record, waitReleased } : record),
136
+ );
137
+ } catch (error) {
138
+ debugLog(`Could not persist wait release for runtime record ${id}`, error);
139
+ }
140
+ }
141
+
142
+ export function removeRuntimeRecord(path: string, id: string): void {
143
+ const records = readRuntimeRecords(path).filter((item) => item.id !== id);
144
+ if (records.length === 0) {
145
+ try {
146
+ writeRuntimeRecords(path, []);
147
+ } catch (error) {
148
+ // 运行态清理失败不能覆盖已经取得的子代理结果。
149
+ debugLog(`Could not clear runtime registry ${path}`, error);
150
+ }
151
+ return;
152
+ }
153
+ try {
154
+ writeRuntimeRecords(path, records);
155
+ } catch (error) {
156
+ // watcher 清理是尽力而为,失败不会改变主会话结果。
157
+ debugLog(`Could not update runtime registry ${path}`, error);
158
+ }
159
+ }