pi-firecode 0.8.0 → 1.0.0

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 (79) hide show
  1. package/README.md +35 -15
  2. package/activity.ts +92 -0
  3. package/busy.ts +188 -0
  4. package/config.example.jsonc +38 -47
  5. package/config.ts +198 -172
  6. package/deliver.ts +100 -13
  7. package/flame.ts +149 -0
  8. package/format.ts +84 -31
  9. package/header.ts +208 -82
  10. package/index.ts +17 -11
  11. package/master/actions.ts +293 -0
  12. package/master/activity-list.ts +281 -0
  13. package/master/event-card.ts +18 -37
  14. package/master/event-format.ts +71 -40
  15. package/master/guard.ts +46 -0
  16. package/master/index.ts +88 -1036
  17. package/master/list-view.ts +129 -0
  18. package/master/outbox.ts +188 -0
  19. package/master/prompts/master.zh.md +18 -13
  20. package/master/prompts/worker.zh.md +1 -1
  21. package/master/role.ts +6 -1
  22. package/master/run.ts +315 -0
  23. package/master/runtime.ts +259 -0
  24. package/master/spawn.ts +79 -38
  25. package/master/state.ts +30 -6
  26. package/master/worker-view.ts +517 -0
  27. package/package.json +1 -1
  28. package/provider/claude-sub.ts +35 -13
  29. package/provider/openai-native/src/compact-client.ts +2 -14
  30. package/provider/openai-native/src/options.ts +4 -2
  31. package/review/advisor.ts +0 -2
  32. package/review/card.ts +36 -23
  33. package/review/checkpoint.ts +2 -8
  34. package/review/evidence.ts +42 -44
  35. package/review/index.ts +271 -525
  36. package/review/occupancy.ts +25 -0
  37. package/review/outcome.ts +31 -7
  38. package/review/prompt.ts +7 -8
  39. package/review/prompts/review.en.md +7 -6
  40. package/review/prompts/review.zh.md +7 -6
  41. package/review/reviewer.ts +2 -5
  42. package/review/session.ts +11 -30
  43. package/review/state.ts +32 -60
  44. package/review/ui.ts +30 -469
  45. package/round-recorder.ts +18 -0
  46. package/session/herdr-display.ts +1 -1
  47. package/session/presets.ts +130 -87
  48. package/session/quota.ts +123 -0
  49. package/session/stats.ts +4 -1
  50. package/statusbar/index.ts +242 -104
  51. package/statusbar/render.ts +95 -163
  52. package/theme.ts +2 -41
  53. package/tools/actions.ts +53 -0
  54. package/tools/assistant-view.ts +64 -0
  55. package/tools/click-anchor.ts +56 -0
  56. package/tools/group-view.ts +420 -0
  57. package/tools/grouping.ts +146 -221
  58. package/tools/host.ts +369 -0
  59. package/tools/index.ts +108 -105
  60. package/tools/line.ts +95 -58
  61. package/tools/machine.ts +78 -0
  62. package/tools/parts.ts +2 -2
  63. package/tools/round.ts +95 -0
  64. package/tools/turn-clock.ts +41 -0
  65. package/tools/turn-summary.ts +109 -0
  66. package/truncated-write.ts +18 -0
  67. package/watcher/card.ts +28 -22
  68. package/watcher/index.ts +16 -44
  69. package/watcher/observer.ts +3 -3
  70. package/watcher/prompts/watch.zh.md +1 -1
  71. package/watcher/transcript.ts +4 -12
  72. package/flame-frames.ts +0 -460
  73. package/review/progress.ts +0 -324
  74. package/session/bark.ts +0 -157
  75. package/session/working-flame.ts +0 -116
  76. package/statusbar/quota-cache.ts +0 -60
  77. package/statusbar/quota-parse.ts +0 -85
  78. package/statusbar/quota.ts +0 -203
  79. package/statusbar/tps.ts +0 -87
package/master/spawn.ts CHANGED
@@ -3,17 +3,21 @@ import { basename, dirname } from "node:path";
3
3
  import type { Model } from "@earendil-works/pi-ai";
4
4
  import {
5
5
  createAgentSession,
6
+ createCodemodeExtension,
6
7
  DefaultResourceLoader,
7
8
  getAgentDir,
8
9
  ModelRuntime,
9
10
  SessionManager,
10
11
  type AgentSession,
12
+ type InlineExtension,
11
13
  type ToolDefinition,
12
14
  } from "@earendil-works/pi-coding-agent";
13
15
  import { withSubsessionRole, type SubsessionRole } from "./role.js";
14
16
  import type { WorkerThinking } from "./state.js";
15
17
 
16
18
  export const IDLE_SESSION_TIMEOUT_MS = 10 * 60_000;
19
+ /** 宿主只给 CLI 主会话注入内置扩展;子会话自带 codemode(按 builtin 名受 settings 开关),激活仍由 tools 决定。 */
20
+ const BUILTIN_CODEMODE: InlineExtension = { name: "codemode", factory: createCodemodeExtension(), replaceable: true, builtin: true };
17
21
 
18
22
  export type SessionPersistence =
19
23
  | { type: "memory" }
@@ -39,7 +43,8 @@ export interface SpawnedSession {
39
43
  readonly session: AgentSession;
40
44
  readonly sessionPath?: string;
41
45
  prompt(text: string): Promise<void>;
42
- dispose(): void;
46
+ /** 先让会话内扩展按宿主契约收口(session_shutdown),再释放;resolve 即收口完成。 */
47
+ dispose(): Promise<void>;
43
48
  }
44
49
 
45
50
  interface HeldSession {
@@ -47,21 +52,41 @@ interface HeldSession {
47
52
  session: AgentSession;
48
53
  sessionPath?: string;
49
54
  timer?: NodeJS.Timeout;
50
- unsubscribe?: () => void;
51
- disposed: boolean;
55
+ releasing?: Promise<void>;
52
56
  }
53
57
 
54
- const SESSION_WRITERS = new Set<string>();
58
+ // 单写者登记必须进程唯一:宿主按文件重新求值模块图(见 role.ts),模块级集合在副本间互不可见。
59
+ const WRITERS_KEY = Symbol.for("firecode.session-writers");
60
+ const SESSION_WRITERS = ((globalThis as Record<symbol, unknown>)[WRITERS_KEY] ??= new Set<string>()) as Set<string>;
55
61
 
56
- /** 全插件唯一的进程内子会话入口;池同时是 JSONL 单写者登记。 */
62
+ export interface PoolEnvironment {
63
+ agentDir?: string;
64
+ modelRuntime?: ModelRuntime;
65
+ idleTimeoutMs?: number;
66
+ /** 模型原子 id(provider/model)解析;默认用池内缓存的一份 ModelRuntime。 */
67
+ resolveModel?: (id: string) => Promise<Model<any>>;
68
+ }
69
+
70
+ /** 全插件唯一的进程内子会话入口:模型解析、单写者登记与热会话生命周期都在这里。 */
57
71
  export class InProcessSessionPool {
58
72
  private readonly held = new Map<string, HeldSession>();
59
-
60
- constructor(private readonly environment: {
61
- agentDir?: string;
62
- modelRuntime?: ModelRuntime;
63
- idleTimeoutMs?: number;
64
- } = {}) {}
73
+ private runtime?: Promise<ModelRuntime>;
74
+ private readonly releaseListeners = new Set<(sessionPath: string) => void>();
75
+
76
+ constructor(private readonly environment: PoolEnvironment = {}) {}
77
+
78
+ /**
79
+ * 把 "provider/model" 解析成模型。ModelRuntime 每个池只建一次(auth.json 与 models.json 只读一次),解析与建子会话共用;
80
+ * 扩展注册的 provider 在这里不可见,只能用内置 provider 与 models.json 里的模型。
81
+ */
82
+ async resolveModel(id: string): Promise<Model<any>> {
83
+ if (this.environment.resolveModel) return this.environment.resolveModel(id);
84
+ const runtime = await this.modelRuntime();
85
+ const slash = id.indexOf("/");
86
+ const model = slash > 0 ? runtime.getModel(id.slice(0, slash), id.slice(slash + 1)) : undefined;
87
+ if (!model) throw new Error(`找不到模型:${id};子会话只能使用内置 provider 或 models.json 里的模型`);
88
+ return model;
89
+ }
65
90
 
66
91
  async spawn(options: SpawnSessionOptions): Promise<SpawnedSession> {
67
92
  const sessionPath = options.persistence.type === "file" ? options.persistence.sessionPath : undefined;
@@ -77,6 +102,7 @@ export class InProcessSessionPool {
77
102
  cwd: options.cwd,
78
103
  agentDir: this.environment.agentDir ?? getAgentDir(),
79
104
  noContextFiles: !options.contextFiles,
105
+ extensionFactories: [BUILTIN_CODEMODE],
80
106
  noExtensions: options.isolated,
81
107
  noSkills: options.isolated,
82
108
  noPromptTemplates: options.isolated,
@@ -96,7 +122,8 @@ export class InProcessSessionPool {
96
122
  const result = await createAgentSession({
97
123
  cwd: options.cwd,
98
124
  agentDir: this.environment.agentDir,
99
- modelRuntime: this.environment.modelRuntime,
125
+ // 与模型解析同一份:不传时宿主会为每个子会话重读一次 auth.json 与 models.json。
126
+ modelRuntime: await this.modelRuntime(),
100
127
  model: options.model,
101
128
  thinkingLevel: options.thinking,
102
129
  tools: options.tools,
@@ -112,12 +139,8 @@ export class InProcessSessionPool {
112
139
  }
113
140
 
114
141
  const key = sessionPath ?? `memory:${crypto.randomUUID()}`;
115
- const held: HeldSession = { key, session: created, sessionPath, disposed: false };
142
+ const held: HeldSession = { key, session: created, sessionPath };
116
143
  this.held.set(key, held);
117
- held.unsubscribe = created.subscribe((event) => {
118
- if (event.type === "agent_start") this.clearTimer(held);
119
- if (event.type === "agent_settled") this.armIdleDisposal(held);
120
- });
121
144
  return {
122
145
  session: created,
123
146
  sessionPath,
@@ -126,6 +149,20 @@ export class InProcessSessionPool {
126
149
  };
127
150
  }
128
151
 
152
+ /** 热会话被释放(空闲到期或 dispose)后通知持有方放掉对它的订阅与引用;返回退订函数。 */
153
+ onRelease(listener: (sessionPath: string) => void): () => void {
154
+ this.releaseListeners.add(listener);
155
+ return () => this.releaseListeners.delete(listener);
156
+ }
157
+
158
+ private modelRuntime(): Promise<ModelRuntime> {
159
+ const agentDir = this.environment.agentDir ?? getAgentDir();
160
+ this.runtime ??= this.environment.modelRuntime
161
+ ? Promise.resolve(this.environment.modelRuntime)
162
+ : ModelRuntime.create({ authPath: `${agentDir}/auth.json`, modelsPath: `${agentDir}/models.json` });
163
+ return this.runtime;
164
+ }
165
+
129
166
  has(sessionPath: string): boolean {
130
167
  return this.held.has(sessionPath);
131
168
  }
@@ -137,38 +174,42 @@ export class InProcessSessionPool {
137
174
  return held.session;
138
175
  }
139
176
 
177
+ /** 空闲只由调用方判定:池不订阅会话事件自判空闲;超时后释放热会话,档案与 JSONL 保留。 */
140
178
  markIdle(sessionPath: string): void {
141
179
  const held = this.held.get(sessionPath);
142
- if (held) this.armIdleDisposal(held);
180
+ if (!held) return;
181
+ this.clearTimer(held);
182
+ held.timer = setTimeout(() => void this.release(held), this.environment.idleTimeoutMs ?? IDLE_SESSION_TIMEOUT_MS);
183
+ held.timer.unref?.();
143
184
  }
144
185
 
145
- dispose(sessionPath: string): boolean {
186
+ async dispose(sessionPath: string): Promise<boolean> {
146
187
  const held = this.held.get(sessionPath);
147
188
  if (!held) return false;
148
- this.release(held);
189
+ await this.release(held);
149
190
  return true;
150
191
  }
151
192
 
152
- disposeAll(): void {
153
- for (const held of [...this.held.values()]) this.release(held);
193
+ async disposeAll(): Promise<void> {
194
+ await Promise.all([...this.held.values()].map((held) => this.release(held)));
154
195
  }
155
196
 
156
- private armIdleDisposal(held: HeldSession): void {
157
- this.clearTimer(held);
158
- held.timer = setTimeout(() => {
159
- if (!held.session.isStreaming) this.release(held);
160
- }, this.environment.idleTimeoutMs ?? IDLE_SESSION_TIMEOUT_MS);
161
- held.timer.unref?.();
162
- }
163
-
164
- private release(held: HeldSession): void {
165
- if (held.disposed) return;
166
- held.disposed = true;
167
- held.unsubscribe?.();
168
- this.clearTimer(held);
169
- held.session.dispose();
170
- if (this.held.get(held.key) === held) this.held.delete(held.key);
171
- if (held.sessionPath) SESSION_WRITERS.delete(held.sessionPath);
197
+ /** 镜像宿主替换会话的顺序:先 session_shutdown 让扩展收口,再 dispose 作废上下文。 */
198
+ private release(held: HeldSession): Promise<void> {
199
+ held.releasing ??= (async () => {
200
+ this.clearTimer(held);
201
+ if (this.held.get(held.key) === held) this.held.delete(held.key);
202
+ try {
203
+ await held.session.extensionRunner.emit({ type: "session_shutdown", reason: "quit" });
204
+ } finally {
205
+ held.session.dispose();
206
+ if (held.sessionPath) {
207
+ SESSION_WRITERS.delete(held.sessionPath);
208
+ for (const listener of this.releaseListeners) listener(held.sessionPath);
209
+ }
210
+ }
211
+ })();
212
+ return held.releasing;
172
213
  }
173
214
 
174
215
  private clearTimer(held: HeldSession): void {
package/master/state.ts CHANGED
@@ -1,8 +1,7 @@
1
1
  import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
- import { homedir } from "node:os";
3
2
  import { dirname, join } from "node:path";
4
3
 
5
- const STATE_VERSION = 8;
4
+ const STATE_VERSION = 9;
6
5
 
7
6
  export const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
8
7
  export type WorkerThinking = (typeof THINKING_LEVELS)[number];
@@ -18,6 +17,11 @@ export interface WorkerRef {
18
17
  sessionPath: string;
19
18
  cwd?: string;
20
19
  interruptedAt?: number;
20
+ /**
21
+ * 启动序:start 在同步段按到达先后取的单调序号,活动列表与全过程视图按它排。必须持久化:并行 start 越过
22
+ * await 后落盘的先后(以及任何落盘时刻)与到达先后不一致,恢复后若靠别的字段排会换序。
23
+ */
24
+ launch: number;
21
25
  reviewNeeded?: boolean;
22
26
  disposition?: WorkerDisposition;
23
27
  }
@@ -79,9 +83,10 @@ export class LegacyMasterStateError extends Error {
79
83
  }
80
84
  }
81
85
 
82
- export function masterStatePath(sessionId: string): string {
86
+ /** 子代理池档案:与运行配置同一个 Pi Agent 目录(含 PI_CODING_AGENT_DIR 覆写),按主会话 id 分文件。 */
87
+ export function masterStatePath(agentDir: string, sessionId: string): string {
83
88
  const safeId = sessionId.replace(/[^a-zA-Z0-9_-]/gu, "-");
84
- return join(homedir(), ".pi", "agent", "tmp", `firecode-master-${safeId}.json`);
89
+ return join(agentDir, "tmp", `firecode-master-${safeId}.json`);
85
90
  }
86
91
 
87
92
  export function loadMasterState(path: string): MasterState | undefined {
@@ -99,8 +104,8 @@ export function loadMasterState(path: string): MasterState | undefined {
99
104
  throw new Error(`Master Worker Pool 状态不是合法 JSON:${path}`);
100
105
  }
101
106
  const version = (data as { version?: unknown } | null)?.version;
102
- if (typeof version === "number" && version !== STATE_VERSION) throw new LegacyMasterStateError(version);
103
- const state = restoreMasterState(data);
107
+ if (typeof version === "number" && version !== STATE_VERSION && version !== 8) throw new LegacyMasterStateError(version);
108
+ const state = restoreMasterState(version === 8 ? migrateFromV8(data as { workers?: unknown }) : data);
104
109
  if (!state) throw new Error(`Master Worker Pool 状态结构无效:${path}`);
105
110
  return state;
106
111
  }
@@ -147,6 +152,24 @@ export class MasterStore {
147
152
  }
148
153
  }
149
154
 
155
+ /**
156
+ * v8 → v9 只差启动序:v8 的创建时间是同一先后的近似(并行 start 下可能有出入,从此以 launch 为准),没有创建时间的
157
+ * 是更早版本恢复来的、排最前。升级不丢池:池里是用户仍在用的子代理,丢弃会让指挥官失去它们的会话与审查义务。
158
+ */
159
+ function migrateFromV8(data: { workers?: unknown }): unknown {
160
+ if (!Array.isArray(data.workers)) return data;
161
+ const workers = data.workers as Record<string, unknown>[];
162
+ const created = (index: number) => (typeof workers[index]?.createdAt === "number" ? workers[index].createdAt as number : -Infinity);
163
+ const order = workers.map((_, index) => index).sort((a, b) => created(a) - created(b) || a - b);
164
+ return {
165
+ version: STATE_VERSION,
166
+ workers: workers.map((worker, index) => {
167
+ const { createdAt: _createdAt, ...rest } = worker ?? {};
168
+ return { ...rest, launch: order.indexOf(index) + 1 };
169
+ }),
170
+ };
171
+ }
172
+
150
173
  export function requireWorker(state: MasterState, name: string): WorkerRef {
151
174
  const worker = state.workers.find((candidate) => candidate.name === name);
152
175
  if (!worker) throw new Error(`子代理不存在:${name}`);
@@ -191,6 +214,7 @@ function isWorker(value: unknown): value is WorkerRef {
191
214
  if (record.interruptedAt !== undefined && (typeof record.interruptedAt !== "number" || record.interruptedAt <= 0))
192
215
  return false;
193
216
  if (record.reviewNeeded !== undefined && typeof record.reviewNeeded !== "boolean") return false;
217
+ if (typeof record.launch !== "number" || !Number.isInteger(record.launch) || record.launch <= 0) return false;
194
218
  if (record.disposition !== undefined && record.disposition !== "pending" && record.disposition !== "reminded")
195
219
  return false;
196
220
  return true;