pi-firecode 0.8.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.
- package/LICENSE +21 -0
- package/README.md +40 -0
- package/config.example.jsonc +100 -0
- package/config.ts +474 -0
- package/deliver.ts +32 -0
- package/flame-frames.ts +460 -0
- package/format.ts +80 -0
- package/header.ts +92 -0
- package/herdr-client.ts +60 -0
- package/index.ts +62 -0
- package/jsonc.ts +34 -0
- package/master/event-card.ts +106 -0
- package/master/event-format.ts +52 -0
- package/master/index.ts +1200 -0
- package/master/prompt.ts +26 -0
- package/master/prompts/master.zh.md +17 -0
- package/master/prompts/worker.zh.md +1 -0
- package/master/role.ts +13 -0
- package/master/spawn.ts +193 -0
- package/master/state.ts +201 -0
- package/package.json +59 -0
- package/provider/claude-sub.ts +129 -0
- package/provider/openai-native/index.ts +8 -0
- package/provider/openai-native/src/compact-client.ts +362 -0
- package/provider/openai-native/src/config.ts +297 -0
- package/provider/openai-native/src/extension.ts +93 -0
- package/provider/openai-native/src/native-compaction.ts +151 -0
- package/provider/openai-native/src/native-details.ts +157 -0
- package/provider/openai-native/src/native-replay.ts +253 -0
- package/provider/openai-native/src/native-runtime.ts +144 -0
- package/provider/openai-native/src/options.ts +85 -0
- package/provider/openai-native/src/request-pipeline.ts +21 -0
- package/provider/openai-native/src/responses-input.ts +433 -0
- package/review/advisor.ts +112 -0
- package/review/card.ts +385 -0
- package/review/checkpoint.ts +384 -0
- package/review/evidence.ts +232 -0
- package/review/index.ts +1246 -0
- package/review/outcome.ts +74 -0
- package/review/progress.ts +324 -0
- package/review/prompt.ts +210 -0
- package/review/prompts/advisor.en.md +41 -0
- package/review/prompts/advisor.zh.md +42 -0
- package/review/prompts/review.en.md +75 -0
- package/review/prompts/review.zh.md +75 -0
- package/review/reviewer.ts +356 -0
- package/review/session.ts +129 -0
- package/review/state.ts +905 -0
- package/review/ui.ts +528 -0
- package/session/bark.ts +157 -0
- package/session/herdr-display.ts +87 -0
- package/session/presets.ts +257 -0
- package/session/rename.ts +46 -0
- package/session/stats.ts +310 -0
- package/session/working-flame.ts +116 -0
- package/statusbar/index.ts +118 -0
- package/statusbar/quota-cache.ts +60 -0
- package/statusbar/quota-parse.ts +85 -0
- package/statusbar/quota.ts +203 -0
- package/statusbar/render.ts +181 -0
- package/statusbar/tps.ts +87 -0
- package/theme.ts +84 -0
- package/tools/grouping.ts +233 -0
- package/tools/index.ts +183 -0
- package/tools/line.ts +194 -0
- package/tools/parts.ts +143 -0
- package/tools/timing.ts +22 -0
- package/watcher/card.ts +85 -0
- package/watcher/index.ts +204 -0
- package/watcher/observer.ts +75 -0
- package/watcher/prompts/watch.zh.md +46 -0
- package/watcher/transcript.ts +64 -0
package/master/prompt.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
const PROMPTS_DIR = join(dirname(fileURLToPath(import.meta.url)), "prompts");
|
|
6
|
+
|
|
7
|
+
export type MasterPromptKind = "master" | "worker";
|
|
8
|
+
|
|
9
|
+
export function readMasterPrompt(kind: MasterPromptKind): string {
|
|
10
|
+
let prompt: string;
|
|
11
|
+
try {
|
|
12
|
+
prompt = readFileSync(join(PROMPTS_DIR, `${kind}.zh.md`), "utf8");
|
|
13
|
+
} catch (error) {
|
|
14
|
+
throw new Error(`Master ${kind} prompt 读取失败:${error instanceof Error ? error.message : String(error)}`);
|
|
15
|
+
}
|
|
16
|
+
if (!prompt.trim()) throw new Error(`Master ${kind} prompt 为空`);
|
|
17
|
+
return prompt.trim();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function assembleMasterPrompt(prompt: string, roster: string): string {
|
|
21
|
+
return `${prompt}\n\n角色表:${roster}。`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function assembleWorkerPrompt(prompt: string, name: string): string {
|
|
25
|
+
return `<firecode_worker name="${name}">\n${prompt}\n</firecode_worker>`;
|
|
26
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
subagents 激活时,你是唯一的指挥官(Master),负责委派与最终验收,把任务推进到交付完成。
|
|
2
|
+
|
|
3
|
+
- 派发:role 只使用调研员、工程师、全栈、架构师、设计师、哨兵六个固定词;start 按已配置角色表显式传 role,send 需要换职能时也传 role;thinking 仅在用户明确要求时覆盖角色原子档。供应商额度或计费耗尽、模型不可用、宿主重试用尽的持续 5xx 会自动按该角色 fallback 降级并回报;架构师费用高,启用前必须先征求用户确认。以等待为主的盯守(CI、部署、长测试)使用哨兵。
|
|
4
|
+
- 动手边界:短小明确的活亲手做——单文件小改、数条命令内可完成并验证的任务,委派开销大于任务本身就不委派。委派用于长时间运行、可并行、产生大量中间输出、需要干净上下文或独立验证的任务。终审 diff 与交付裁决始终亲手。
|
|
5
|
+
- 并行:并行实现前先冻结共享边界——接口、共享类型、注册表、schema 由你亲手或单一 Worker 先行落地,其余 Worker 按路径划界同时开跑,每个共享文件只有一个写者,工作说明写明各自路径;集成以测试门裁决。
|
|
6
|
+
- 工单:项目存在工单库时,每次派发前按约定认领工单、对齐他人工单状态,并基于最新代码开工;新问题由你决策开新工单或交给上下文合适的在场 Worker 顺手解决,Worker 不开单。无工单库则以用户验收为交付边界。
|
|
7
|
+
- 实现标准:实现类工作说明要求 Worker 先读 implement 技能(~/.agents/skills/matt/implement)再动手;你终审时按同一标准裁决。
|
|
8
|
+
- 收割:调研与盯守 Worker 收割要点后立即 kill;实现 Worker 保留到收口。计划产物存在时,其维护责任随指挥权归你。
|
|
9
|
+
- 调研衔接:执行档模型的聚焦探索(读代码定位、方案设计)直接 send 续派原 Worker 执行,现场就是工作上下文。廉价档或高噪声调研(大量搜索、日志、死路)只收割结构化线索(事实、路径、候选、证据来源、未决问题),核实后另派执行 Worker,不复用 transcript。
|
|
10
|
+
- 投递:Worker 结果、中断与审查终态会自动送达;tail 仅用于按需读取执行细节。
|
|
11
|
+
- 何时审查:复杂且影响大的实现,以及无法靠窄测可靠验收的任务,需要对抗性审查,在 start 时传 review:true;其余任务省略。
|
|
12
|
+
- 如何启动:review:true 只是在任务开始时标记“这个任务完成后需要审查”,不会自动启动审查。这个标记不会因 send、reload、中断或失败而丢失,审查结束前不能 ack。Worker 返回结果并完成验证后,指挥官主动执行 review,才会开始对抗性审查。任务开始时没有标记 review:true,也可以在 Worker 空闲后主动执行 review;如果整个任务已经放弃,直接 kill。
|
|
13
|
+
- 审查过程:review 会在原 Worker 会话中启动。独立模型读取 Worker 的工作记录,核对相关文件和验证结果。发现问题时,审查意见会交回同一个 Worker 核实和修复,然后再次审查;审查通过、顾问决定停止或达到最大轮数后结束。审查通过时仍可能附带不阻塞交付的建议,由指挥官判断是否需要继续处理。
|
|
14
|
+
- 审查收尾:审查通过但附带的非阻塞建议确有必要解决时,在原 Worker 会话收口干净再 ack。
|
|
15
|
+
- 交付:终审 diff 后,重要改动亲手 dogfood 试用;完全符合原则才 PR 与合并,看到发布成功后关闭工单并同步状态。合并冲突按 resolving-merge-conflicts 技能处理。
|
|
16
|
+
|
|
17
|
+
调用样板:start {"worker":"fix-auth","role":"工程师","prompt":"自包含工作说明"};send {"worker":"fix-auth","role":"架构师","prompt":"自包含工作说明"}。
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
你是指挥官委派的 Worker,只在当前 checkout 内完成工作说明。验证改动并报告结果、证据与遗留风险;无法完成或验证时如实报告阻塞原因和现场,不得假成功。Git 操作限于本地且仅覆盖自己修改的路径。
|
package/master/role.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
|
|
3
|
+
export type SubsessionRole = "worker" | "observer" | "reviewer" | "advisor";
|
|
4
|
+
|
|
5
|
+
const ROLE = new AsyncLocalStorage<SubsessionRole>();
|
|
6
|
+
|
|
7
|
+
export function currentSubsessionRole(): SubsessionRole | undefined {
|
|
8
|
+
return ROLE.getStore();
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function withSubsessionRole<T>(role: SubsessionRole, run: () => Promise<T>): Promise<T> {
|
|
12
|
+
return ROLE.run(role, run);
|
|
13
|
+
}
|
package/master/spawn.ts
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { basename, dirname } from "node:path";
|
|
3
|
+
import type { Model } from "@earendil-works/pi-ai";
|
|
4
|
+
import {
|
|
5
|
+
createAgentSession,
|
|
6
|
+
DefaultResourceLoader,
|
|
7
|
+
getAgentDir,
|
|
8
|
+
ModelRuntime,
|
|
9
|
+
SessionManager,
|
|
10
|
+
type AgentSession,
|
|
11
|
+
type ToolDefinition,
|
|
12
|
+
} from "@earendil-works/pi-coding-agent";
|
|
13
|
+
import { withSubsessionRole, type SubsessionRole } from "./role.js";
|
|
14
|
+
import type { WorkerThinking } from "./state.js";
|
|
15
|
+
|
|
16
|
+
export const IDLE_SESSION_TIMEOUT_MS = 10 * 60_000;
|
|
17
|
+
|
|
18
|
+
export type SessionPersistence =
|
|
19
|
+
| { type: "memory" }
|
|
20
|
+
| { type: "file"; sessionPath: string; resume?: boolean };
|
|
21
|
+
|
|
22
|
+
export interface SpawnSessionOptions {
|
|
23
|
+
cwd: string;
|
|
24
|
+
model: Model<any>;
|
|
25
|
+
role: SubsessionRole;
|
|
26
|
+
thinking: WorkerThinking;
|
|
27
|
+
tools: string[];
|
|
28
|
+
/** 只属于本子会话的工具(如观察员的 advise);名字仍要出现在 tools 里才激活。 */
|
|
29
|
+
customTools?: ToolDefinition[];
|
|
30
|
+
excludeExtensions?: string[];
|
|
31
|
+
systemPrompt: { mode: "append" | "replace"; text: string };
|
|
32
|
+
contextFiles: boolean;
|
|
33
|
+
persistence: SessionPersistence;
|
|
34
|
+
/** 审查会话关闭自动扩展、Skill 与模板,保持评判政策不受被审项目改写。 */
|
|
35
|
+
isolated?: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface SpawnedSession {
|
|
39
|
+
readonly session: AgentSession;
|
|
40
|
+
readonly sessionPath?: string;
|
|
41
|
+
prompt(text: string): Promise<void>;
|
|
42
|
+
dispose(): void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface HeldSession {
|
|
46
|
+
key: string;
|
|
47
|
+
session: AgentSession;
|
|
48
|
+
sessionPath?: string;
|
|
49
|
+
timer?: NodeJS.Timeout;
|
|
50
|
+
unsubscribe?: () => void;
|
|
51
|
+
disposed: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const SESSION_WRITERS = new Set<string>();
|
|
55
|
+
|
|
56
|
+
/** 全插件唯一的进程内子会话入口;池同时是 JSONL 单写者登记。 */
|
|
57
|
+
export class InProcessSessionPool {
|
|
58
|
+
private readonly held = new Map<string, HeldSession>();
|
|
59
|
+
|
|
60
|
+
constructor(private readonly environment: {
|
|
61
|
+
agentDir?: string;
|
|
62
|
+
modelRuntime?: ModelRuntime;
|
|
63
|
+
idleTimeoutMs?: number;
|
|
64
|
+
} = {}) {}
|
|
65
|
+
|
|
66
|
+
async spawn(options: SpawnSessionOptions): Promise<SpawnedSession> {
|
|
67
|
+
const sessionPath = options.persistence.type === "file" ? options.persistence.sessionPath : undefined;
|
|
68
|
+
if (sessionPath && SESSION_WRITERS.has(sessionPath))
|
|
69
|
+
throw new Error(`sessionPath 已有进程内会话持有:${sessionPath}`);
|
|
70
|
+
if (options.persistence.type === "file" && options.persistence.resume && !existsSync(sessionPath!))
|
|
71
|
+
throw new Error(`无法恢复子代理:会话文件不存在:${sessionPath}`);
|
|
72
|
+
if (sessionPath) SESSION_WRITERS.add(sessionPath);
|
|
73
|
+
|
|
74
|
+
let created: AgentSession;
|
|
75
|
+
try {
|
|
76
|
+
const loader = new DefaultResourceLoader({
|
|
77
|
+
cwd: options.cwd,
|
|
78
|
+
agentDir: this.environment.agentDir ?? getAgentDir(),
|
|
79
|
+
noContextFiles: !options.contextFiles,
|
|
80
|
+
noExtensions: options.isolated,
|
|
81
|
+
noSkills: options.isolated,
|
|
82
|
+
noPromptTemplates: options.isolated,
|
|
83
|
+
...(options.systemPrompt.mode === "replace"
|
|
84
|
+
? { systemPrompt: options.systemPrompt.text }
|
|
85
|
+
: { appendSystemPrompt: [options.systemPrompt.text] }),
|
|
86
|
+
extensionsOverride: (base) => ({
|
|
87
|
+
...base,
|
|
88
|
+
extensions: base.extensions.filter((extension) =>
|
|
89
|
+
!matchesExtension(extension.path, options.excludeExtensions ?? [])),
|
|
90
|
+
}),
|
|
91
|
+
});
|
|
92
|
+
await withSubsessionRole(options.role, () => loader.reload());
|
|
93
|
+
if (loader.getExtensions().errors.length)
|
|
94
|
+
throw new Error(`子会话扩展加载失败:${JSON.stringify(loader.getExtensions().errors)}`);
|
|
95
|
+
const sessionManager = makeSessionManager(options.persistence, options.cwd);
|
|
96
|
+
const result = await createAgentSession({
|
|
97
|
+
cwd: options.cwd,
|
|
98
|
+
agentDir: this.environment.agentDir,
|
|
99
|
+
modelRuntime: this.environment.modelRuntime,
|
|
100
|
+
model: options.model,
|
|
101
|
+
thinkingLevel: options.thinking,
|
|
102
|
+
tools: options.tools,
|
|
103
|
+
...(options.customTools ? { customTools: options.customTools } : {}),
|
|
104
|
+
resourceLoader: loader,
|
|
105
|
+
sessionManager,
|
|
106
|
+
});
|
|
107
|
+
await result.session.bindExtensions({ mode: "print" });
|
|
108
|
+
created = result.session;
|
|
109
|
+
} catch (error) {
|
|
110
|
+
if (sessionPath) SESSION_WRITERS.delete(sessionPath);
|
|
111
|
+
throw error;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const key = sessionPath ?? `memory:${crypto.randomUUID()}`;
|
|
115
|
+
const held: HeldSession = { key, session: created, sessionPath, disposed: false };
|
|
116
|
+
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
|
+
return {
|
|
122
|
+
session: created,
|
|
123
|
+
sessionPath,
|
|
124
|
+
prompt: (text) => created.prompt(text),
|
|
125
|
+
dispose: () => this.release(held),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
has(sessionPath: string): boolean {
|
|
130
|
+
return this.held.has(sessionPath);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
getSession(sessionPath: string): AgentSession | undefined {
|
|
134
|
+
const held = this.held.get(sessionPath);
|
|
135
|
+
if (!held) return undefined;
|
|
136
|
+
this.clearTimer(held);
|
|
137
|
+
return held.session;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
markIdle(sessionPath: string): void {
|
|
141
|
+
const held = this.held.get(sessionPath);
|
|
142
|
+
if (held) this.armIdleDisposal(held);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
dispose(sessionPath: string): boolean {
|
|
146
|
+
const held = this.held.get(sessionPath);
|
|
147
|
+
if (!held) return false;
|
|
148
|
+
this.release(held);
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
disposeAll(): void {
|
|
153
|
+
for (const held of [...this.held.values()]) this.release(held);
|
|
154
|
+
}
|
|
155
|
+
|
|
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);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private clearTimer(held: HeldSession): void {
|
|
175
|
+
if (held.timer) clearTimeout(held.timer);
|
|
176
|
+
held.timer = undefined;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function preallocateWorkerSession(mainSessionPath: string, cwd: string): string {
|
|
181
|
+
const sessionPath = SessionManager.create(cwd, `${dirname(mainSessionPath)}/subagents`).getSessionFile();
|
|
182
|
+
if (!sessionPath) throw new Error("无法为子代理预分配 Pi session 路径");
|
|
183
|
+
return sessionPath;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function makeSessionManager(persistence: SessionPersistence, cwd: string): SessionManager {
|
|
187
|
+
if (persistence.type === "memory") return SessionManager.inMemory(cwd);
|
|
188
|
+
return SessionManager.open(persistence.sessionPath, dirname(persistence.sessionPath), cwd);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function matchesExtension(path: string, exclusions: string[]): boolean {
|
|
192
|
+
return exclusions.some((excluded) => excluded === path || excluded === basename(path));
|
|
193
|
+
}
|
package/master/state.ts
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
|
|
5
|
+
const STATE_VERSION = 8;
|
|
6
|
+
|
|
7
|
+
export const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
|
|
8
|
+
export type WorkerThinking = (typeof THINKING_LEVELS)[number];
|
|
9
|
+
export type WorkerStatus = "working" | "idle" | "reviewing";
|
|
10
|
+
export type WorkerDisposition = "pending" | "reminded";
|
|
11
|
+
|
|
12
|
+
export interface WorkerRef {
|
|
13
|
+
name: string;
|
|
14
|
+
role: string;
|
|
15
|
+
model: string;
|
|
16
|
+
thinking: WorkerThinking;
|
|
17
|
+
status: WorkerStatus;
|
|
18
|
+
sessionPath: string;
|
|
19
|
+
cwd?: string;
|
|
20
|
+
interruptedAt?: number;
|
|
21
|
+
reviewNeeded?: boolean;
|
|
22
|
+
disposition?: WorkerDisposition;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface MasterState {
|
|
26
|
+
version: typeof STATE_VERSION;
|
|
27
|
+
workers: WorkerRef[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type MasterEvent =
|
|
31
|
+
| { type: "UPSERT_WORKER"; worker: WorkerRef }
|
|
32
|
+
| { type: "REMOVE_WORKER"; name: string }
|
|
33
|
+
| { type: "CLEAR" };
|
|
34
|
+
|
|
35
|
+
export function initialMasterState(): MasterState {
|
|
36
|
+
return { version: STATE_VERSION, workers: [] };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function reduceMaster(state: MasterState, event: MasterEvent): MasterState {
|
|
40
|
+
switch (event.type) {
|
|
41
|
+
case "UPSERT_WORKER":
|
|
42
|
+
return { ...state, workers: upsertWorker(state.workers, event.worker) };
|
|
43
|
+
case "REMOVE_WORKER": {
|
|
44
|
+
const workers = state.workers.filter((worker) => worker.name !== event.name);
|
|
45
|
+
return workers.length === state.workers.length ? state : { ...state, workers };
|
|
46
|
+
}
|
|
47
|
+
case "CLEAR":
|
|
48
|
+
return initialMasterState();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function recoverMasterState(state: MasterState, interruptedAt = Date.now()): MasterState {
|
|
53
|
+
let changed = false;
|
|
54
|
+
const workers = state.workers.map((worker) => {
|
|
55
|
+
if (worker.status === "idle") return worker;
|
|
56
|
+
changed = true;
|
|
57
|
+
return { ...worker, status: "idle" as const, interruptedAt };
|
|
58
|
+
});
|
|
59
|
+
return changed ? { ...state, workers } : state;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function restoreMasterState(data: unknown): MasterState | undefined {
|
|
63
|
+
if (!data || typeof data !== "object" || Array.isArray(data)) return undefined;
|
|
64
|
+
const record = data as Record<string, unknown>;
|
|
65
|
+
if (record.version !== STATE_VERSION || !Array.isArray(record.workers) || !record.workers.every(isWorker))
|
|
66
|
+
return undefined;
|
|
67
|
+
const workers = record.workers as WorkerRef[];
|
|
68
|
+
if (new Set(workers.map((worker) => worker.name)).size !== workers.length) return undefined;
|
|
69
|
+
if (new Set(workers.map((worker) => worker.sessionPath)).size !== workers.length) return undefined;
|
|
70
|
+
return { version: STATE_VERSION, workers };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export class LegacyMasterStateError extends Error {
|
|
74
|
+
constructor(readonly version: number) {
|
|
75
|
+
super(
|
|
76
|
+
`Master Worker Pool 状态是旧版 v${version}(当前 v${STATE_VERSION}),不再读取;`
|
|
77
|
+
+ "重新启动指挥官模式会从空池重建,旧运行时进程不会纳入新池,需要手动清理",
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function masterStatePath(sessionId: string): string {
|
|
83
|
+
const safeId = sessionId.replace(/[^a-zA-Z0-9_-]/gu, "-");
|
|
84
|
+
return join(homedir(), ".pi", "agent", "tmp", `firecode-master-${safeId}.json`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function loadMasterState(path: string): MasterState | undefined {
|
|
88
|
+
let raw: string;
|
|
89
|
+
try {
|
|
90
|
+
raw = readFileSync(path, "utf8");
|
|
91
|
+
} catch (error) {
|
|
92
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
93
|
+
throw error;
|
|
94
|
+
}
|
|
95
|
+
let data: unknown;
|
|
96
|
+
try {
|
|
97
|
+
data = JSON.parse(raw);
|
|
98
|
+
} catch {
|
|
99
|
+
throw new Error(`Master Worker Pool 状态不是合法 JSON:${path}`);
|
|
100
|
+
}
|
|
101
|
+
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);
|
|
104
|
+
if (!state) throw new Error(`Master Worker Pool 状态结构无效:${path}`);
|
|
105
|
+
return state;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export class MasterStore {
|
|
109
|
+
private stateValue: MasterState;
|
|
110
|
+
private readonly path: string;
|
|
111
|
+
private readonly onChange?: () => void;
|
|
112
|
+
readonly discardedLegacyVersion?: number;
|
|
113
|
+
|
|
114
|
+
constructor(path: string, restored?: MasterState, onChange?: () => void) {
|
|
115
|
+
this.path = path;
|
|
116
|
+
this.onChange = onChange;
|
|
117
|
+
if (restored) this.stateValue = restored;
|
|
118
|
+
else {
|
|
119
|
+
const loaded = this.loadOwnedState();
|
|
120
|
+
this.stateValue = loaded.state;
|
|
121
|
+
this.discardedLegacyVersion = loaded.discardedLegacyVersion;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
get state(): MasterState {
|
|
126
|
+
return this.stateValue;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
dispatch(event: MasterEvent): MasterState {
|
|
130
|
+
const next = reduceMaster(this.stateValue, event);
|
|
131
|
+
if (next === this.stateValue) return next;
|
|
132
|
+
if (event.type === "CLEAR") rmSync(this.path, { force: true });
|
|
133
|
+
else writeState(this.path, next);
|
|
134
|
+
this.stateValue = next;
|
|
135
|
+
this.onChange?.();
|
|
136
|
+
return next;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private loadOwnedState(): { state: MasterState; discardedLegacyVersion?: number } {
|
|
140
|
+
try {
|
|
141
|
+
return { state: loadMasterState(this.path) ?? initialMasterState() };
|
|
142
|
+
} catch (error) {
|
|
143
|
+
if (!(error instanceof LegacyMasterStateError)) throw error;
|
|
144
|
+
rmSync(this.path, { force: true });
|
|
145
|
+
return { state: initialMasterState(), discardedLegacyVersion: error.version };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function requireWorker(state: MasterState, name: string): WorkerRef {
|
|
151
|
+
const worker = state.workers.find((candidate) => candidate.name === name);
|
|
152
|
+
if (!worker) throw new Error(`子代理不存在:${name}`);
|
|
153
|
+
return worker;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function writeState(path: string, state: MasterState): void {
|
|
157
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
158
|
+
const temporary = `${path}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
159
|
+
try {
|
|
160
|
+
writeFileSync(temporary, `${JSON.stringify(state)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
161
|
+
renameSync(temporary, path);
|
|
162
|
+
} catch (error) {
|
|
163
|
+
rmSync(temporary, { force: true });
|
|
164
|
+
throw error;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function upsertWorker(workers: WorkerRef[], worker: WorkerRef): WorkerRef[] {
|
|
169
|
+
const index = workers.findIndex((candidate) => candidate.name === worker.name);
|
|
170
|
+
const sessionOwner = workers.find((candidate) => candidate.sessionPath === worker.sessionPath);
|
|
171
|
+
if (sessionOwner && sessionOwner.name !== worker.name)
|
|
172
|
+
throw new Error(`sessionPath 已被占用:${worker.sessionPath}`);
|
|
173
|
+
if (index < 0) return [...workers, worker];
|
|
174
|
+
if (workers[index].sessionPath !== worker.sessionPath)
|
|
175
|
+
throw new Error(`子代理 ${worker.name} 不能更换 sessionPath`);
|
|
176
|
+
return workers.map((candidate, position) => (position === index ? worker : candidate));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function isWorker(value: unknown): value is WorkerRef {
|
|
180
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
181
|
+
const record = value as Record<string, unknown>;
|
|
182
|
+
if (
|
|
183
|
+
typeof record.name !== "string" || !/^[a-z][a-z0-9_-]{0,31}$/u.test(record.name) ||
|
|
184
|
+
typeof record.role !== "string" || !record.role ||
|
|
185
|
+
typeof record.model !== "string" || !record.model ||
|
|
186
|
+
typeof record.thinking !== "string" || !THINKING_LEVELS.includes(record.thinking as WorkerThinking) ||
|
|
187
|
+
typeof record.status !== "string" || !isStatus(record.status) ||
|
|
188
|
+
typeof record.sessionPath !== "string" || !record.sessionPath
|
|
189
|
+
) return false;
|
|
190
|
+
if (record.cwd !== undefined && (typeof record.cwd !== "string" || !record.cwd)) return false;
|
|
191
|
+
if (record.interruptedAt !== undefined && (typeof record.interruptedAt !== "number" || record.interruptedAt <= 0))
|
|
192
|
+
return false;
|
|
193
|
+
if (record.reviewNeeded !== undefined && typeof record.reviewNeeded !== "boolean") return false;
|
|
194
|
+
if (record.disposition !== undefined && record.disposition !== "pending" && record.disposition !== "reminded")
|
|
195
|
+
return false;
|
|
196
|
+
return true;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function isStatus(value: string): value is WorkerStatus {
|
|
200
|
+
return value === "working" || value === "idle" || value === "reviewing";
|
|
201
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-firecode",
|
|
3
|
+
"version": "0.8.0",
|
|
4
|
+
"description": "A modular Pi extension for terminal UI, session workflows, adversarial review, and delegated agents",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/Suge8/firecode.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/Suge8/firecode#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/Suge8/firecode/issues"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"pi-package",
|
|
17
|
+
"pi-extension"
|
|
18
|
+
],
|
|
19
|
+
"files": [
|
|
20
|
+
"*.ts",
|
|
21
|
+
"config.example.jsonc",
|
|
22
|
+
"master/*.ts",
|
|
23
|
+
"master/prompts/*.md",
|
|
24
|
+
"provider/claude-sub.ts",
|
|
25
|
+
"provider/openai-native/index.ts",
|
|
26
|
+
"provider/openai-native/src/compact-client.ts",
|
|
27
|
+
"provider/openai-native/src/config.ts",
|
|
28
|
+
"provider/openai-native/src/extension.ts",
|
|
29
|
+
"provider/openai-native/src/native-compaction.ts",
|
|
30
|
+
"provider/openai-native/src/native-details.ts",
|
|
31
|
+
"provider/openai-native/src/native-replay.ts",
|
|
32
|
+
"provider/openai-native/src/native-runtime.ts",
|
|
33
|
+
"provider/openai-native/src/options.ts",
|
|
34
|
+
"provider/openai-native/src/request-pipeline.ts",
|
|
35
|
+
"provider/openai-native/src/responses-input.ts",
|
|
36
|
+
"!provider/openai-native/src/*.test.ts",
|
|
37
|
+
"review/*.ts",
|
|
38
|
+
"review/prompts/*.md",
|
|
39
|
+
"session/*.ts",
|
|
40
|
+
"statusbar/*.ts",
|
|
41
|
+
"tools/*.ts",
|
|
42
|
+
"watcher/*.ts",
|
|
43
|
+
"watcher/prompts/*.md"
|
|
44
|
+
],
|
|
45
|
+
"scripts": {
|
|
46
|
+
"test": "bun test"
|
|
47
|
+
},
|
|
48
|
+
"pi": {
|
|
49
|
+
"extensions": [
|
|
50
|
+
"./index.ts"
|
|
51
|
+
]
|
|
52
|
+
},
|
|
53
|
+
"peerDependencies": {
|
|
54
|
+
"@earendil-works/pi-agent-core": "*",
|
|
55
|
+
"@earendil-works/pi-ai": "*",
|
|
56
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
57
|
+
"@earendil-works/pi-tui": "*"
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anthropic OAuth 会话按 Claude Code 的归因格式发请求:补 user-agent 与
|
|
3
|
+
* 系统提示词首块的 billing header,缺失时注入,已存在则原样通过。
|
|
4
|
+
*/
|
|
5
|
+
import { execFileSync } from "node:child_process";
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
import { appendFileSync, mkdirSync } from "node:fs";
|
|
8
|
+
import { dirname, resolve } from "node:path";
|
|
9
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
|
|
11
|
+
const BILLING_PREFIX = "x-anthropic-billing-header:";
|
|
12
|
+
const FALLBACK_CLAUDE_CODE_VERSION = "2.1.229";
|
|
13
|
+
const DEFAULT_ENTRYPOINT = "cli";
|
|
14
|
+
const BILLING_SALT = "59cf53e54c78";
|
|
15
|
+
|
|
16
|
+
type TextBlock = {
|
|
17
|
+
type: "text";
|
|
18
|
+
text: string;
|
|
19
|
+
cache_control?: { type: "ephemeral"; ttl?: "1h" };
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
interface PayloadLike {
|
|
23
|
+
system?: unknown;
|
|
24
|
+
messages?: unknown;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
28
|
+
return typeof value === "object" && value !== null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isTextBlock(value: unknown): value is TextBlock {
|
|
32
|
+
return isObject(value) && value.type === "text" && typeof value.text === "string";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function shouldApply(ctx: ExtensionContext): boolean {
|
|
36
|
+
const model = ctx.model;
|
|
37
|
+
return !!model && model.provider === "anthropic" && ctx.modelRegistry.isUsingOAuth(model);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function detectClaudeCodeVersion(): string {
|
|
41
|
+
const explicit = process.env.PI_CLAUDE_CODE_VERSION;
|
|
42
|
+
if (explicit) return explicit;
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
const output = execFileSync("claude", ["--version"], {
|
|
46
|
+
encoding: "utf8",
|
|
47
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
48
|
+
timeout: 1000,
|
|
49
|
+
});
|
|
50
|
+
const match = output.match(/\b\d+\.\d+\.\d+\b/);
|
|
51
|
+
if (match) return match[0];
|
|
52
|
+
} catch {
|
|
53
|
+
// 本地未装 Claude Code 时用回退版本号,不阻塞启动。
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return FALLBACK_CLAUDE_CODE_VERSION;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const claudeCodeVersion = detectClaudeCodeVersion();
|
|
60
|
+
|
|
61
|
+
function textOf(content: unknown): string {
|
|
62
|
+
if (typeof content === "string") return content;
|
|
63
|
+
if (!Array.isArray(content)) return "";
|
|
64
|
+
return content
|
|
65
|
+
.filter(isTextBlock)
|
|
66
|
+
.map((block) => block.text)
|
|
67
|
+
.join("\n");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function firstUserText(messages: unknown): string {
|
|
71
|
+
const list = Array.isArray(messages) ? messages : [];
|
|
72
|
+
const firstUser = list.find((message) => isObject(message) && message.role === "user");
|
|
73
|
+
return isObject(firstUser) ? textOf(firstUser.content) : "";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function versionSuffix(messageText: string): string {
|
|
77
|
+
const explicit = process.env.PI_CLAUDE_CODE_VERSION_SUFFIX;
|
|
78
|
+
if (explicit) return explicit;
|
|
79
|
+
|
|
80
|
+
const sampled = [4, 7, 20].map((index) => messageText[index] ?? "0").join("");
|
|
81
|
+
return createHash("sha256")
|
|
82
|
+
.update(`${BILLING_SALT}${sampled}${claudeCodeVersion}`)
|
|
83
|
+
.digest("hex")
|
|
84
|
+
.slice(0, 3);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function buildBillingHeader(messages: unknown): string {
|
|
88
|
+
const version = `${claudeCodeVersion}.${versionSuffix(firstUserText(messages))}`;
|
|
89
|
+
const entrypoint =
|
|
90
|
+
process.env.PI_CLAUDE_CODE_ENTRYPOINT ?? process.env.CLAUDE_CODE_ENTRYPOINT ?? DEFAULT_ENTRYPOINT;
|
|
91
|
+
const workload = process.env.PI_CLAUDE_CODE_WORKLOAD ?? process.env.CLAUDE_CODE_WORKLOAD;
|
|
92
|
+
const workloadPart = workload ? ` cc_workload=${workload};` : "";
|
|
93
|
+
return `${BILLING_PREFIX} cc_version=${version}; cc_entrypoint=${entrypoint}; cch=00000;${workloadPart}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function log(details: Record<string, unknown>): void {
|
|
97
|
+
const logFile = process.env.PI_CLAUDE_OAUTH_LOG_FILE;
|
|
98
|
+
if (!logFile) return;
|
|
99
|
+
|
|
100
|
+
const path = resolve(logFile);
|
|
101
|
+
try {
|
|
102
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
103
|
+
appendFileSync(path, `${JSON.stringify({ timestamp: new Date().toISOString(), ...details })}\n`, "utf8");
|
|
104
|
+
} catch {
|
|
105
|
+
// 调试日志是可选的。
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function registerClaudeSub(pi: ExtensionAPI): void {
|
|
110
|
+
pi.registerProvider("anthropic", {
|
|
111
|
+
headers: {
|
|
112
|
+
"user-agent": `claude-cli/${claudeCodeVersion} (external, cli)`,
|
|
113
|
+
"x-app": "cli",
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
pi.on("before_provider_request", (event, ctx) => {
|
|
118
|
+
const payload = event.payload;
|
|
119
|
+
if (!shouldApply(ctx) || !isObject(payload)) return;
|
|
120
|
+
|
|
121
|
+
const { system, messages } = payload as PayloadLike;
|
|
122
|
+
const blocks = Array.isArray(system) ? system : [];
|
|
123
|
+
if (blocks.some((block) => isTextBlock(block) && block.text.startsWith(BILLING_PREFIX))) return;
|
|
124
|
+
|
|
125
|
+
const header: TextBlock = { type: "text", text: buildBillingHeader(messages) };
|
|
126
|
+
log({ event: "billing_header_injected", header: header.text });
|
|
127
|
+
return { ...payload, system: [header, ...blocks] };
|
|
128
|
+
});
|
|
129
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** OpenAI / Codex 请求层:verbosity、Fast(priority)、可选原生压缩。 */
|
|
2
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { CONFIG_PATH, loadConfig } from "../../config.js";
|
|
4
|
+
import openAINativeExtension from "./src/extension.ts";
|
|
5
|
+
|
|
6
|
+
export function registerOpenAINative(pi: ExtensionAPI): void {
|
|
7
|
+
openAINativeExtension(pi, CONFIG_PATH, loadConfig().config.keys.fast);
|
|
8
|
+
}
|