@zhushanwen/pi-subagents 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 (37) hide show
  1. package/agents/context-builder.md +19 -0
  2. package/agents/oracle.md +19 -0
  3. package/agents/planner.md +19 -0
  4. package/agents/researcher.md +19 -0
  5. package/agents/reviewer.md +19 -0
  6. package/agents/scout.md +19 -0
  7. package/agents/worker.md +18 -0
  8. package/index.ts +1 -0
  9. package/package.json +59 -0
  10. package/src/commands/subagents.ts +78 -0
  11. package/src/core/agent-registry.ts +222 -0
  12. package/src/core/concurrency-pool.ts +78 -0
  13. package/src/core/event-bridge.ts +199 -0
  14. package/src/core/execution-record.ts +500 -0
  15. package/src/core/model-resolver.ts +206 -0
  16. package/src/core/output-collector.ts +118 -0
  17. package/src/core/path-encoding.ts +16 -0
  18. package/src/core/session-factory.ts +365 -0
  19. package/src/core/session-runner.ts +303 -0
  20. package/src/core/turn-limiter.ts +71 -0
  21. package/src/index.ts +104 -0
  22. package/src/runtime/config/config.ts +170 -0
  23. package/src/runtime/discovery-config.ts +135 -0
  24. package/src/runtime/execution/history-store.ts +196 -0
  25. package/src/runtime/execution/notifier.ts +209 -0
  26. package/src/runtime/execution/record-store.ts +280 -0
  27. package/src/runtime/model-config-service.ts +265 -0
  28. package/src/runtime/session-file-gc.ts +70 -0
  29. package/src/runtime/subagent-service.ts +549 -0
  30. package/src/tools/subagent-tool.ts +286 -0
  31. package/src/tui/bg-notify-render.ts +139 -0
  32. package/src/tui/config-wizard.ts +253 -0
  33. package/src/tui/format-helpers.ts +37 -0
  34. package/src/tui/format.ts +332 -0
  35. package/src/tui/list-view.ts +883 -0
  36. package/src/tui/tool-render.ts +467 -0
  37. package/src/types.ts +334 -0
@@ -0,0 +1,71 @@
1
+ // src/core/turn-limiter.ts
2
+ //
3
+ // soft/hard turn 限制器。maxTurns 到达 → steer 提醒收尾;
4
+ // graceTurns 后仍不结束 → abort。
5
+
6
+ /** steer 提醒消息:要求 agent 总结已完成/未完成/下一步,不得谎报完成。 */
7
+ const WRAP_UP_MESSAGE = [
8
+ "You have reached your turn limit. Wrap up now:",
9
+ "1. Summarize what you have completed (with evidence: file paths, command output).",
10
+ "2. List what remains undone and why.",
11
+ "3. State the single most important next step for whoever continues.",
12
+ "Do NOT claim the task is complete if any part remains unfinished.",
13
+ ].join(" ");
14
+
15
+ /** turn limiter 配置。 */
16
+ export interface TurnLimiterOptions {
17
+ maxTurns: number;
18
+ graceTurns: number;
19
+ steer: (msg: string) => void;
20
+ abort: () => void;
21
+ }
22
+
23
+ /**
24
+ * soft/hard turn 限制。
25
+ *
26
+ * onTurnEnd(currentTurns):
27
+ * 已 aborted 或 maxTurns<=0(禁用)→ 直接 return
28
+ * currentTurns >= maxTurns 且未 steer → steer(WRAP_UP_MESSAGE)(仅一次)
29
+ * 已 steer 且 currentTurns >= maxTurns + graceTurns → abort()(仅一次)
30
+ *
31
+ * maxTurns<=0 表示不限(limit=Infinity,永不触发)。
32
+ * graceTurns<=0 时 steer 后下一 turn 即 abort。
33
+ */
34
+ export interface TurnLimiter {
35
+ /** 每次 turn_end 调用。 */
36
+ onTurnEnd(currentTurns: number): void;
37
+ /** 是否已发过 steer(诊断用)。 */
38
+ readonly didSteer: boolean;
39
+ /** 是否已 abort(诊断用)。 */
40
+ readonly didAbort: boolean;
41
+ }
42
+
43
+ /** 工厂函数。 */
44
+ export function createTurnLimiter(opts: TurnLimiterOptions): TurnLimiter {
45
+ let steered = false;
46
+ let aborted = false;
47
+ const limit = opts.maxTurns > 0 ? opts.maxTurns : Infinity;
48
+ const grace = opts.graceTurns > 0 ? opts.graceTurns : 0;
49
+
50
+ const onTurnEnd = (turn: number): void => {
51
+ if (aborted || !Number.isFinite(limit)) return;
52
+ if (!steered && turn >= limit) {
53
+ steered = true;
54
+ opts.steer(WRAP_UP_MESSAGE);
55
+ }
56
+ if (steered && turn >= limit + grace) {
57
+ aborted = true;
58
+ opts.abort();
59
+ }
60
+ };
61
+
62
+ return {
63
+ onTurnEnd,
64
+ get didSteer(): boolean {
65
+ return steered;
66
+ },
67
+ get didAbort(): boolean {
68
+ return aborted;
69
+ },
70
+ };
71
+ }
package/src/index.ts ADDED
@@ -0,0 +1,104 @@
1
+ // src/index.ts
2
+ //
3
+ // Pi extension 工厂。只做注册胶水——不含业务逻辑。
4
+ // 注册项:tool / command / messageRenderer / session 事件。
5
+
6
+ import type { ExtensionAPI, ExtensionContext, ResourcesDiscoverEvent, ResourcesDiscoverResult, SessionShutdownEvent, SessionStartEvent } from "@mariozechner/pi-coding-agent";
7
+ import { getAgentDir } from "@mariozechner/pi-coding-agent";
8
+
9
+ import { registerSubagentsCommand } from "./commands/subagents.ts";
10
+ import { DiscoveryConfigLoader } from "./runtime/discovery-config.ts";
11
+ import {
12
+ getModelConfigService,
13
+ ModelConfigService,
14
+ setModelConfigService,
15
+ } from "./runtime/model-config-service.ts";
16
+ import { maybeCleanupExpiredSessionFiles } from "./runtime/session-file-gc.ts";
17
+ import {
18
+ getSubagentService,
19
+ setSubagentService,
20
+ SubagentService,
21
+ } from "./runtime/subagent-service.ts";
22
+ import { registerSubagentTool } from "./tools/subagent-tool.ts";
23
+ import { renderBgNotifyMessage } from "./tui/bg-notify-render.ts";
24
+
25
+ /**
26
+ * FR-10.2: Pi extension 工厂。
27
+ *
28
+ * ╔══════════════════════════════════════════════════════════════════╗
29
+ // ║ 注册(进程级,一次): ║
30
+ // ║ registerSubagentsCommand(pi) → /subagents ║
31
+ // ║ registerSubagentTool(pi) → subagent tool ║
32
+ // ║ pi.registerMessageRenderer("subagent-bg-notify", renderBgNotify)║
33
+ // ║ ║
34
+ // ║ session_start(event, ctx): ║
35
+ // ║ 1. modelService = getModelConfigService() ?? new ModelConfigService(...)║
36
+ // ║ 2. service = getSubagentService() ?? new SubagentService({cwd, modelService})║
37
+ // ║ 3. modelService.initModel({modelRegistry, sessionId, entries}) ║
38
+ // ║ 4. service.initSession({pi, sessionId}) ║
39
+ // ║ 5. maybeCleanupExpiredSessionFiles(homeDir, cwd) ║
40
+ // ║ ║
41
+ // ║ session_shutdown(event): ║
42
+ // ║ rt.dispose() ║
43
+ // ╚══════════════════════════════════════════════════════════════════╝
44
+ */
45
+ export default function subagentsExtension(pi: ExtensionAPI): void {
46
+ registerSubagentsCommand(pi);
47
+ registerSubagentTool(pi);
48
+ pi.registerMessageRenderer("subagent-bg-notify", renderBgNotifyMessage);
49
+
50
+ // discovery.json 契约加载器(进程级单例,跨 session 复用 mtime 缓存)。
51
+ // 宿主启动 pi 前写入 <agentDir>/subagents/discovery.json 声明多 skill/agent 目录。
52
+ // 详见 ADR-025。
53
+ const discoveryLoader = new DiscoveryConfigLoader(getAgentDir());
54
+
55
+ // resources_discover:把 discovery 的 skillDirs 注入主 session 的 resourceLoader。
56
+ // 主 agent 的 skill 走此通道(pi 原生官方机制),子 session 的 skill 由 session-factory 另读。
57
+ pi.on("resources_discover", (_event: ResourcesDiscoverEvent, _ctx: ExtensionContext): ResourcesDiscoverResult => {
58
+ const discovery = discoveryLoader.load();
59
+ return { skillPaths: discovery.skillDirs.length > 0 ? [...discovery.skillDirs] : undefined };
60
+ });
61
+
62
+ pi.on("session_start", (_event: SessionStartEvent, ctx: ExtensionContext) => {
63
+ const cwd = ctx.cwd;
64
+ // agentDir 由 Pi 核心 getAgentDir() 决定(读 PI_CODING_AGENT_DIR,默认 ~/.pi/agent),
65
+ // 与 Pi 主进程目录约定完全一致——宿主可经环境变量整体重定向配置/agent/skill 目录。
66
+ const agentDir = getAgentDir();
67
+
68
+ // 双 Service 装配:ModelConfigService(配置/模型域)+ SubagentService(执行/记录/通知域)
69
+ const existingService = getSubagentService();
70
+ const existingModelService = getModelConfigService();
71
+ const modelService = existingModelService ?? new ModelConfigService({ agentDir, discoveryLoader });
72
+ const service = existingService ?? new SubagentService({ cwd, modelService });
73
+
74
+ // 分别 init(两个域的生命周期独立)
75
+ modelService.initModel({
76
+ modelRegistry: ctx.modelRegistry,
77
+ sessionId: ctx.sessionManager.getSessionId(),
78
+ entries: ctx.sessionManager.getEntries() ?? [],
79
+ });
80
+ service.initSession({
81
+ pi,
82
+ sessionId: ctx.sessionManager.getSessionId(),
83
+ });
84
+
85
+ // 先注册 Service(让 execute 可用),再做 best-effort 清理。
86
+ // 顺序很重要:清理若 throw 不能阻塞 service 注册,否则 getSubagentService() 永远返回 undefined。
87
+ if (!existingService) {
88
+ setModelConfigService(modelService);
89
+ setSubagentService(service);
90
+ }
91
+
92
+ // best-effort 清理(GC),失败不应阻断 session——但额外兜底:
93
+ // 万一仍抛错,catch 住防止 session_start 整体崩。
94
+ try {
95
+ maybeCleanupExpiredSessionFiles(agentDir, cwd);
96
+ } catch {
97
+ // best-effort 清理失败,忽略——service 已注册,session 可用
98
+ }
99
+ });
100
+
101
+ pi.on("session_shutdown", (_event: SessionShutdownEvent) => {
102
+ getSubagentService()?.dispose();
103
+ });
104
+ }
@@ -0,0 +1,170 @@
1
+ // src/runtime/config/config.ts
2
+ //
3
+ // 全局配置(~/.pi/agent/subagents/config.json)+ session 级状态(内存)。
4
+ //
5
+ // 开箱默认值内联在代码里(见 DEFAULT_CONFIG),不依赖任何包内文件。
6
+ // 用户私有 category models 走 ~/.pi/agent/subagents/config.json(loadGlobalConfig)。
7
+
8
+ import * as fs from "node:fs";
9
+ import * as path from "node:path";
10
+
11
+ import type {
12
+ SessionModelState,
13
+ SubagentsGlobalConfig,
14
+ } from "../../types.ts";
15
+
16
+ // ============================================================
17
+ // 常量
18
+ // ============================================================
19
+
20
+ /** JSON 序列化缩进。 */
21
+ const JSON_INDENT = 2;
22
+
23
+ /** appendEntry 的 customType(restoreSessionState 据此匹配)。 */
24
+ const CONFIG_ENTRY_TYPE = "subagent-config-entry";
25
+
26
+ /**
27
+ * 开箱默认配置(单一真相源,内联在代码里)。
28
+ *
29
+ * 历史教训 [HISTORICAL]:曾用包内 config.json(与 src/ 同级)作为默认值源,
30
+ * 但 config.json 被 .gitignore 排除且不应随 npm 包分发用户私有配置——导致
31
+ * npm pack 后 BUILTIN_CONFIG_PATH 读不到文件,catch 兜底用 fallback.model="",
32
+ * 进而 resolveModelForAgent 第 5 级 lookupModel("") 必失败,pi install 后首次
33
+ * 执行 subagent tool 抛 "No available model"。修复:默认值内联在代码里,不依赖
34
+ * 任何包内文件。用户私有配置仍走 ~/.pi/agent/subagents/config.json(loadGlobalConfig)。
35
+ *
36
+ * fallback.model 选 Anthropic claude-sonnet-4-5 作为公开可用默认——Pi 默认环境通常
37
+ * 已配置 Anthropic 凭证。即便用户未配置,resolveModelForAgent 会 fall through 到
38
+ * tried 列表并抛清晰错误(列出可用 model),不会静默崩溃。
39
+ */
40
+ const DEFAULT_CONFIG: SubagentsGlobalConfig = {
41
+ version: 1,
42
+ yoloByDefault: false,
43
+ maxConcurrent: 4,
44
+ categories: {},
45
+ agentCategoryOverrides: {},
46
+ fallback: { model: "anthropic/claude-sonnet-4-5", thinkingLevel: undefined },
47
+ };
48
+
49
+ // ============================================================
50
+ // 路径
51
+ // ============================================================
52
+
53
+ /**
54
+ * 配置文件路径(<agentDir>/subagents/config.json)。
55
+ * agentDir 由 Pi 核心 getAgentDir() 决定(读 PI_CODING_AGENT_DIR,默认 ~/.pi/agent),
56
+ * 与 Pi 主进程的目录约定完全一致——支持宿主经环境变量整体重定向。
57
+ */
58
+ export function getGlobalConfigPath(agentDir: string): string {
59
+ return path.join(agentDir, "subagents", "config.json");
60
+ }
61
+
62
+ // ============================================================
63
+ // 全局配置加载/保存
64
+ // ============================================================
65
+
66
+ /** 默认配置的深拷贝(避免调用方 mutate DEFAULT_CONFIG 常量)。 */
67
+ function defaultConfig(): SubagentsGlobalConfig {
68
+ return {
69
+ ...DEFAULT_CONFIG,
70
+ categories: { ...DEFAULT_CONFIG.categories },
71
+ agentCategoryOverrides: { ...DEFAULT_CONFIG.agentCategoryOverrides },
72
+ fallback: { ...DEFAULT_CONFIG.fallback },
73
+ };
74
+ }
75
+
76
+ /** 校验 categories 最小结构(label + model 字符串),非法值回退默认。 */
77
+ function sanitizeCategories(input: unknown): SubagentsGlobalConfig["categories"] {
78
+ const result = { ...DEFAULT_CONFIG.categories };
79
+ if (!input || typeof input !== "object") return result;
80
+ for (const [name, def] of Object.entries(input as Record<string, unknown>)) {
81
+ if (!def || typeof def !== "object") continue;
82
+ const d = def as Record<string, unknown>;
83
+ if (typeof d.label !== "string" || typeof d.model !== "string") continue;
84
+ result[name] = {
85
+ label: d.label,
86
+ model: d.model,
87
+ thinkingLevel: typeof d.thinkingLevel === "string" ? d.thinkingLevel : undefined,
88
+ };
89
+ }
90
+ return result;
91
+ }
92
+
93
+ /**
94
+ * 加载全局配置。文件不存在时返回默认配置(保证 categories/fallback 完整)。
95
+ * 与默认配置 deep-merge——新增字段有默认值,旧 config 缺字段不崩溃。
96
+ */
97
+ export function loadGlobalConfig(agentDir: string): SubagentsGlobalConfig {
98
+ const configPath = getGlobalConfigPath(agentDir);
99
+ try {
100
+ const raw = fs.readFileSync(configPath, "utf-8");
101
+ const parsed = JSON.parse(raw) as Partial<SubagentsGlobalConfig>;
102
+ return {
103
+ version: parsed.version ?? DEFAULT_CONFIG.version,
104
+ yoloByDefault: parsed.yoloByDefault ?? DEFAULT_CONFIG.yoloByDefault,
105
+ maxConcurrent: parsed.maxConcurrent ?? DEFAULT_CONFIG.maxConcurrent,
106
+ categories: sanitizeCategories(parsed.categories),
107
+ agentCategoryOverrides: { ...DEFAULT_CONFIG.agentCategoryOverrides, ...parsed.agentCategoryOverrides },
108
+ fallback: { ...DEFAULT_CONFIG.fallback, ...parsed.fallback },
109
+ };
110
+ } catch {
111
+ // 文件不存在 / JSON 解析失败 → 返回默认配置的深拷贝
112
+ return defaultConfig();
113
+ }
114
+ }
115
+
116
+ /** 保存全局配置(config-wizard 调用)。原子写入(temp + rename)。 */
117
+ export function saveGlobalConfig(agentDir: string, config: SubagentsGlobalConfig): Promise<void> {
118
+ const configPath = getGlobalConfigPath(agentDir);
119
+ const configDir = path.dirname(configPath);
120
+ return new Promise((resolve, reject) => {
121
+ try {
122
+ fs.mkdirSync(configDir, { recursive: true });
123
+ const tempPath = `${configPath}.tmp.${process.pid}`;
124
+ fs.writeFileSync(tempPath, `${JSON.stringify(config, null, JSON_INDENT)}\n`, "utf-8");
125
+ fs.renameSync(tempPath, configPath);
126
+ resolve();
127
+ } catch (err) {
128
+ reject(err);
129
+ }
130
+ });
131
+ }
132
+
133
+ // ============================================================
134
+ // Session 级状态(内存,可经 appendEntry 持久化/恢复)
135
+ // ============================================================
136
+
137
+ /**
138
+ * 创建初始 session 状态(session_start 时调用)。
139
+ *
140
+ * categoryConfirmed 默认 true——不拦截执行(D-1:取消首次确认)。
141
+ * 用户改 category 模型走 /subagents config(写 globalConfig);感知模型靠 tool block
142
+ * 醒目显示。categoryModels/agentModels 保留为 inert 字段(resolveModel 有兜底不崩)。
143
+ */
144
+ export function createSessionState(): SessionModelState {
145
+ return { yoloMode: false, categoryConfirmed: true, categoryModels: {}, agentModels: {} };
146
+ }
147
+
148
+ /**
149
+ * 从 entries 恢复 session 状态(/resume 时)。
150
+ * 倒序遍历——取最新一条 subagent-config-entry 快照(与仓库约定一致)。
151
+ * 反序列化字段缺失时用默认值(向后兼容)。
152
+ */
153
+ export function restoreSessionState(entries: ReadonlyArray<{ type: string; data?: unknown }>): SessionModelState {
154
+ const state = createSessionState();
155
+ for (let i = entries.length - 1; i >= 0; i--) {
156
+ const entry = entries[i];
157
+ if (entry.type !== CONFIG_ENTRY_TYPE || !entry.data) continue;
158
+ const d = entry.data as Partial<SessionModelState>;
159
+ if (typeof d.yoloMode === "boolean") state.yoloMode = d.yoloMode;
160
+ if (typeof d.categoryConfirmed === "boolean") state.categoryConfirmed = d.categoryConfirmed;
161
+ if (d.categoryModels && typeof d.categoryModels === "object") {
162
+ state.categoryModels = { ...d.categoryModels };
163
+ }
164
+ if (d.agentModels && typeof d.agentModels === "object") {
165
+ state.agentModels = { ...d.agentModels };
166
+ }
167
+ break;
168
+ }
169
+ return state;
170
+ }
@@ -0,0 +1,135 @@
1
+ // src/runtime/discovery-config.ts
2
+ //
3
+ // 资源发现契约(<agentDir>/subagents/discovery.json)。
4
+ //
5
+ // 宿主(xyz-agent GUI 等)启动 pi 前写入本文件,声明要加载的 skill 目录与
6
+ // agent 目录(按覆盖顺序排列)。subagents 在 session_start 与 resources_discover
7
+ // 时读取,主 agent 与子 session 的资源注入都以此文件为单一真相源。
8
+ // 文件缺失或字段非法时,skillDirs/agentDirs 视为空数组,走默认行为(零破坏)。
9
+ //
10
+ // 详见 ADR-025。
11
+
12
+ import * as fs from "node:fs";
13
+ import * as path from "node:path";
14
+
15
+ import type { DiscoveryConfig } from "../types.ts";
16
+
17
+ // ============================================================
18
+ // 常量与默认值
19
+ // ============================================================
20
+
21
+ /** discovery.json 的当前版本。字段结构变更时递增。 */
22
+ const DISCOVERY_VERSION = 1;
23
+
24
+ /** 空契约(文件缺失/解析失败时返回,保证调用方拿到统一形状)。 */
25
+ const EMPTY_DISCOVERY: DiscoveryConfig = {
26
+ version: DISCOVERY_VERSION,
27
+ skillDirs: [],
28
+ agentDirs: [],
29
+ };
30
+
31
+ // ============================================================
32
+ // 路径
33
+ // ============================================================
34
+
35
+ /**
36
+ * discovery.json 路径(<agentDir>/subagents/discovery.json)。
37
+ * 与 config.json 同级,落在 subagents 专属子目录下。
38
+ */
39
+ export function getDiscoveryConfigPath(agentDir: string): string {
40
+ return path.join(agentDir, "subagents", "discovery.json");
41
+ }
42
+
43
+ // ============================================================
44
+ // 加载与校验
45
+ // ============================================================
46
+
47
+ /**
48
+ * 从字符串数组字段中提取合法的绝对路径条目。
49
+ * 去重(保序,靠前优先)、剔除非字符串/空串。
50
+ */
51
+ function sanitizePathList(input: unknown): string[] {
52
+ if (!Array.isArray(input)) return [];
53
+ const seen = new Set<string>();
54
+ const result: string[] = [];
55
+ for (const item of input) {
56
+ if (typeof item !== "string" || item.length === 0) continue;
57
+ if (seen.has(item)) continue;
58
+ seen.add(item);
59
+ result.push(item);
60
+ }
61
+ return result;
62
+ }
63
+
64
+ /**
65
+ * 解析 discovery.json 原始内容为 DiscoveryConfig。
66
+ * 宽容解析:任何字段非法都回退到空数组,不抛错(best-effort,不阻断 session)。
67
+ */
68
+ function parseDiscoveryConfig(raw: string): DiscoveryConfig {
69
+ let parsed: unknown;
70
+ try {
71
+ parsed = JSON.parse(raw);
72
+ } catch {
73
+ return { ...EMPTY_DISCOVERY };
74
+ }
75
+ if (typeof parsed !== "object" || parsed === null) {
76
+ return { ...EMPTY_DISCOVERY };
77
+ }
78
+ const obj = parsed as Record<string, unknown>;
79
+ return {
80
+ version: typeof obj.version === "number" ? obj.version : DISCOVERY_VERSION,
81
+ skillDirs: sanitizePathList(obj.skillDirs),
82
+ agentDirs: sanitizePathList(obj.agentDirs),
83
+ };
84
+ }
85
+
86
+ // ============================================================
87
+ // mtime 缓存加载器
88
+ // ============================================================
89
+
90
+ /**
91
+ * 带 mtime 缓存的 discovery.json 读取器。
92
+ *
93
+ * discovery.json 在一次 session 内被读取两次(resources_discover + session-factory),
94
+ * 用 mtime 判断是否需要重新 read+parse,避免重复磁盘 IO。
95
+ *
96
+ * 跨 session 复用:mtime 变了才重读。宿主写文件时用原子写(temp + rename),
97
+ * mtime 会变化,触发下次读取重新解析。
98
+ */
99
+ export class DiscoveryConfigLoader {
100
+ private cached: DiscoveryConfig | null = null;
101
+ private cachedMtimeMs: number | null = null;
102
+ private readonly filePath: string;
103
+
104
+ constructor(agentDir: string) {
105
+ this.filePath = getDiscoveryConfigPath(agentDir);
106
+ }
107
+
108
+ /**
109
+ * 读取 discovery.json(mtime 未变则复用缓存)。
110
+ * 文件不存在/不可读/解析失败均返回空契约,永不抛错。
111
+ */
112
+ load(): DiscoveryConfig {
113
+ let stat: fs.Stats;
114
+ try {
115
+ stat = fs.statSync(this.filePath);
116
+ } catch {
117
+ // 文件不存在 / 不可 stat → 清缓存返回空(宿主可能删了文件)
118
+ this.cached = null;
119
+ this.cachedMtimeMs = null;
120
+ return { ...EMPTY_DISCOVERY };
121
+ }
122
+ const mtimeMs = stat.mtimeMs;
123
+ if (this.cached !== null && this.cachedMtimeMs === mtimeMs) {
124
+ return this.cached;
125
+ }
126
+ try {
127
+ const raw = fs.readFileSync(this.filePath, "utf-8");
128
+ this.cached = parseDiscoveryConfig(raw);
129
+ this.cachedMtimeMs = mtimeMs;
130
+ return this.cached;
131
+ } catch {
132
+ return { ...EMPTY_DISCOVERY };
133
+ }
134
+ }
135
+ }
@@ -0,0 +1,196 @@
1
+ // src/runtime/execution/history-store.ts
2
+ //
3
+ // 跨 session 执行记录持久化。
4
+ // 存储格式:history.jsonl,append-only,每行一个 PersistedAgentRecord。
5
+ // 目录布局:<agentDir>/subagents/<encoded-cwd>/history.jsonl
6
+ // (agentDir 默认 ~/.pi/agent,可被 PI_CODING_AGENT_DIR 重定向)
7
+ // GC:超 HISTORY_MAX 时重写保留最近 N 条(每 GC_CHECK_INTERVAL 次写检查)。
8
+
9
+ import * as fs from "node:fs";
10
+ import * as path from "node:path";
11
+
12
+ import { encodeCwd } from "../../core/path-encoding.ts";
13
+ import type { PersistedAgentRecord } from "../../types.ts";
14
+
15
+ // ============================================================
16
+ // 常量
17
+ // ============================================================
18
+
19
+ /** GC 上限(超过则重写保留最近 N 条)。 */
20
+ const HISTORY_MAX = 500;
21
+
22
+ /** GC 检查间隔(每 N 次写触发一次 forceGc)。 */
23
+ const GC_CHECK_INTERVAL = 10;
24
+
25
+ // ============================================================
26
+ // 路径
27
+ // ============================================================
28
+
29
+ /**
30
+ * 计算 history 文件路径(<agentDir>/subagents/<encoded-cwd>/history.jsonl)。
31
+ * encoded-cwd 与 session-factory 的 encodeCwd 逻辑一致(复用 Pi SDK 编码约定,
32
+ * 共享 core/path-encoding.ts 的唯一定义)。
33
+ */
34
+ export function getHistoryFilePath(agentDir: string, cwd: string): string {
35
+ const encoded = encodeCwd(cwd);
36
+ return path.join(agentDir, "subagents", encoded, "history.jsonl");
37
+ }
38
+
39
+ // ============================================================
40
+ // 结构校验
41
+ // ============================================================
42
+
43
+ const VALID_STATUS = new Set(["running", "done", "failed", "cancelled"]);
44
+ const VALID_MODE = new Set(["sync", "background"]);
45
+
46
+ /** 校验 PersistedAgentRecord 最小结构(防旧版本字段漂移污染下游)。 */
47
+ export function isValidPersistedRecord(value: unknown): value is PersistedAgentRecord {
48
+ if (typeof value !== "object" || value === null) return false;
49
+ const v = value as Record<string, unknown>;
50
+ return (
51
+ typeof v.id === "string" &&
52
+ typeof v.agent === "string" &&
53
+ typeof v.status === "string" && VALID_STATUS.has(v.status) &&
54
+ typeof v.mode === "string" && VALID_MODE.has(v.mode) &&
55
+ typeof v.startedAt === "number" &&
56
+ typeof v.cwd === "string"
57
+ );
58
+ }
59
+
60
+ // ============================================================
61
+ // HistoryStore
62
+ // ============================================================
63
+
64
+ /**
65
+ * 按 (agentDir, cwd) 隔离的执行记录存储。
66
+ *
67
+ * ╔════════════════════════════════════════════════════════════════╗
68
+ // ║ append(record): ║
69
+ // ║ 1. 串行化(writeChain,防并发行交错) ║
70
+ // ║ 2. fs.appendFileSync(filePath, JSON + "\n") ║
71
+ // ║ 3. maybeGc():writesSinceLastGc++ 达阈值则 forceGc ║
72
+ // ║ 4. best-effort:失败静默(不阻断主流程) ║
73
+ // ║ ║
74
+ // ║ recent(limit, sessionId?): ║
75
+ // ║ 1. read(sessionId) 过滤 ║
76
+ // ║ 2. 同 id 去重:last-writer-wins;endedAt 相同 cancelled 优先 ║
77
+ // ║ (cancel 先写 cancelled,runAgent catch 再写 failed) ║
78
+ // ║ 3. endedAt desc + startedAt desc 排序 ║
79
+ // ║ 4. slice(limit) ║
80
+ // ╚════════════════════════════════════════════════════════════════╝
81
+ */
82
+ export class HistoryStore {
83
+ private writeChain: Promise<void> = Promise.resolve();
84
+ private writesSinceLastGc = 0;
85
+ private readonly filePath: string;
86
+
87
+ constructor(
88
+ private readonly agentDir: string,
89
+ private readonly cwd: string,
90
+ ) {
91
+ this.filePath = getHistoryFilePath(agentDir, cwd);
92
+ }
93
+
94
+ /** 追加一条记录(串行化防并发交错,best-effort 失败静默)。 */
95
+ append(record: PersistedAgentRecord): Promise<void> {
96
+ this.writeChain = this.writeChain
97
+ .then(() => this.doAppend(record))
98
+ .catch(() => {
99
+ // best-effort:写入失败不阻断主流程(执行已完成,history 只是日志)
100
+ });
101
+ return this.writeChain;
102
+ }
103
+
104
+ /** 实际写入 + 惰性 GC。 */
105
+ private doAppend(record: PersistedAgentRecord): void {
106
+ try {
107
+ fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
108
+ fs.appendFileSync(this.filePath, `${JSON.stringify(record)}\n`, "utf-8");
109
+ } catch {
110
+ // 目录创建/写入失败 → 静默(best-effort)
111
+ return;
112
+ }
113
+ this.maybeGc();
114
+ }
115
+
116
+ /** 读取全部(旧→新)。损坏行跳过。sessionId 过滤。 */
117
+ read(sessionId?: string): PersistedAgentRecord[] {
118
+ let raw: string;
119
+ try {
120
+ raw = fs.readFileSync(this.filePath, "utf-8");
121
+ } catch {
122
+ return []; // 文件不存在 → 空
123
+ }
124
+ const records: PersistedAgentRecord[] = [];
125
+ for (const line of raw.split("\n")) {
126
+ const trimmed = line.trim();
127
+ if (!trimmed) continue;
128
+ try {
129
+ const parsed = JSON.parse(trimmed);
130
+ if (isValidPersistedRecord(parsed)) {
131
+ if (!sessionId || parsed.sessionId === sessionId) {
132
+ records.push(parsed);
133
+ }
134
+ }
135
+ } catch (_err) {
136
+ // 有意吞掉:损坏行跳过(不阻断后续行解析)
137
+ void _err;
138
+ }
139
+ }
140
+ return records;
141
+ }
142
+
143
+ /** 最近 N 条(新→旧),同 id 去重(last-writer-wins,cancelled 优先)。 */
144
+ recent(limit: number, sessionId?: string): PersistedAgentRecord[] {
145
+ const all = this.read(sessionId);
146
+ // 同 id 去重:后写覆盖前写;endedAt 相同时 cancelled 优先
147
+ const byId = new Map<string, PersistedAgentRecord>();
148
+ for (const r of all) {
149
+ const existing = byId.get(r.id);
150
+ if (!existing) {
151
+ byId.set(r.id, r);
152
+ continue;
153
+ }
154
+ // cancelled 优先保留(用户意图,即使被后写覆盖)——与 record-store.merge 一致
155
+ if (existing.status === "cancelled" && r.status !== "cancelled") {
156
+ continue;
157
+ }
158
+ // 否则 last-writer-wins
159
+ byId.set(r.id, r);
160
+ }
161
+ // 排序:endedAt desc(running 用 startedAt 兜底)+ startedAt desc
162
+ return [...byId.values()]
163
+ .sort((a, b) => {
164
+ const aEnd = a.endedAt ?? a.startedAt;
165
+ const bEnd = b.endedAt ?? b.startedAt;
166
+ if (bEnd !== aEnd) return bEnd - aEnd;
167
+ return b.startedAt - a.startedAt;
168
+ })
169
+ .slice(0, limit);
170
+ }
171
+
172
+ /** 强制 GC(测试用)。重写文件保留最近 HISTORY_MAX 条。 */
173
+ forceGc(): void {
174
+ const all = this.read();
175
+ if (all.length <= HISTORY_MAX) return;
176
+ const keep = all.slice(all.length - HISTORY_MAX); // 保留最新 N 条
177
+ try {
178
+ const tempPath = `${this.filePath}.tmp.${process.pid}`;
179
+ const content = keep.map((r) => JSON.stringify(r)).join("\n") + "\n";
180
+ fs.writeFileSync(tempPath, content, "utf-8");
181
+ fs.renameSync(tempPath, this.filePath);
182
+ } catch (_err) {
183
+ // 有意吞掉:GC 失败不影响 append 主流程(下次 GC 重试)
184
+ void _err;
185
+ }
186
+ }
187
+
188
+ /** 惰性 GC(每 GC_CHECK_INTERVAL 次写触发一次)。 */
189
+ private maybeGc(): void {
190
+ this.writesSinceLastGc += 1;
191
+ if (this.writesSinceLastGc >= GC_CHECK_INTERVAL) {
192
+ this.writesSinceLastGc = 0;
193
+ this.forceGc();
194
+ }
195
+ }
196
+ }