pi-firecode 1.0.0 → 1.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 (93) hide show
  1. package/README.md +55 -41
  2. package/README.zh-CN.md +74 -0
  3. package/config.example.jsonc +21 -22
  4. package/dist/config.example.jsonc +90 -0
  5. package/dist/index.js +11464 -0
  6. package/dist/master/prompts/master.en.md +22 -0
  7. package/{master → dist/master}/prompts/master.zh.md +3 -3
  8. package/dist/master/prompts/worker.en.md +1 -0
  9. package/dist/watcher/prompts/watch.en.md +43 -0
  10. package/package.json +10 -27
  11. package/activity.ts +0 -92
  12. package/busy.ts +0 -188
  13. package/config.ts +0 -500
  14. package/deliver.ts +0 -119
  15. package/flame.ts +0 -149
  16. package/format.ts +0 -133
  17. package/header.ts +0 -218
  18. package/herdr-client.ts +0 -60
  19. package/index.ts +0 -68
  20. package/jsonc.ts +0 -34
  21. package/master/actions.ts +0 -293
  22. package/master/activity-list.ts +0 -281
  23. package/master/event-card.ts +0 -87
  24. package/master/event-format.ts +0 -83
  25. package/master/guard.ts +0 -46
  26. package/master/index.ts +0 -252
  27. package/master/list-view.ts +0 -129
  28. package/master/outbox.ts +0 -188
  29. package/master/prompt.ts +0 -26
  30. package/master/role.ts +0 -18
  31. package/master/run.ts +0 -315
  32. package/master/runtime.ts +0 -259
  33. package/master/spawn.ts +0 -234
  34. package/master/state.ts +0 -225
  35. package/master/worker-view.ts +0 -517
  36. package/provider/claude-sub.ts +0 -151
  37. package/provider/openai-native/index.ts +0 -8
  38. package/provider/openai-native/src/compact-client.ts +0 -350
  39. package/provider/openai-native/src/config.ts +0 -297
  40. package/provider/openai-native/src/extension.ts +0 -93
  41. package/provider/openai-native/src/native-compaction.ts +0 -151
  42. package/provider/openai-native/src/native-details.ts +0 -157
  43. package/provider/openai-native/src/native-replay.ts +0 -253
  44. package/provider/openai-native/src/native-runtime.ts +0 -144
  45. package/provider/openai-native/src/options.ts +0 -87
  46. package/provider/openai-native/src/request-pipeline.ts +0 -21
  47. package/provider/openai-native/src/responses-input.ts +0 -433
  48. package/review/advisor.ts +0 -110
  49. package/review/card.ts +0 -398
  50. package/review/checkpoint.ts +0 -378
  51. package/review/evidence.ts +0 -230
  52. package/review/index.ts +0 -992
  53. package/review/occupancy.ts +0 -25
  54. package/review/outcome.ts +0 -98
  55. package/review/prompt.ts +0 -209
  56. package/review/reviewer.ts +0 -353
  57. package/review/session.ts +0 -110
  58. package/review/state.ts +0 -877
  59. package/review/ui.ts +0 -89
  60. package/round-recorder.ts +0 -18
  61. package/session/herdr-display.ts +0 -87
  62. package/session/presets.ts +0 -300
  63. package/session/quota.ts +0 -123
  64. package/session/rename.ts +0 -46
  65. package/session/stats.ts +0 -313
  66. package/statusbar/index.ts +0 -256
  67. package/statusbar/render.ts +0 -113
  68. package/theme.ts +0 -45
  69. package/tools/actions.ts +0 -53
  70. package/tools/assistant-view.ts +0 -64
  71. package/tools/click-anchor.ts +0 -56
  72. package/tools/group-view.ts +0 -420
  73. package/tools/grouping.ts +0 -158
  74. package/tools/host.ts +0 -369
  75. package/tools/index.ts +0 -186
  76. package/tools/line.ts +0 -231
  77. package/tools/machine.ts +0 -78
  78. package/tools/parts.ts +0 -143
  79. package/tools/round.ts +0 -95
  80. package/tools/timing.ts +0 -22
  81. package/tools/turn-clock.ts +0 -41
  82. package/tools/turn-summary.ts +0 -109
  83. package/truncated-write.ts +0 -18
  84. package/watcher/card.ts +0 -91
  85. package/watcher/index.ts +0 -176
  86. package/watcher/observer.ts +0 -75
  87. package/watcher/transcript.ts +0 -56
  88. /package/{master → dist/master}/prompts/worker.zh.md +0 -0
  89. /package/{review → dist/review}/prompts/advisor.en.md +0 -0
  90. /package/{review → dist/review}/prompts/advisor.zh.md +0 -0
  91. /package/{review → dist/review}/prompts/review.en.md +0 -0
  92. /package/{review → dist/review}/prompts/review.zh.md +0 -0
  93. /package/{watcher → dist/watcher}/prompts/watch.zh.md +0 -0
@@ -1,517 +0,0 @@
1
- /**
2
- * 子代理全过程视图:点活动列表里的一行打开全屏浮层,用主会话同一套过程组投影(折叠、摘要行、点击展开、ctrl+o)
3
- * 看这个子代理的完整记录。轮与耗时读 Worker 会话自己的轮记录(与主会话同一个轮记录器写下);输入区上横线的状态与耗时读活动列表
4
- * 同一份行状态;在这里打字补话就是 Master 的 send 动作(视图来源),working 时 steer、idle 时唤醒。
5
- *
6
- * 资源纪律:关闭时零订阅零构建。打开时一次构建(热会话读内存分支、已释放的冷子代理读一次会话文件),之后只按子会话
7
- * 事件增量更新;打开期间看过的子代理各留一份记录与展开状态(切回原样),关闭时全部退订丢弃。草稿按子代理留在
8
- * 运行时内存里,到会话结束。
9
- */
10
- import { readFileSync } from "node:fs";
11
- import type { AgentSession, AgentSessionEvent, EntryRenderer, KeybindingsManager, Theme } from "@earendil-works/pi-coding-agent";
12
- import { Container, Input, matchesKey, visibleWidth, type Component, type Focusable, type TUI, type TuiMouseEvent } from "@earendil-works/pi-tui";
13
- import { onFrame } from "../flame.js";
14
- import { clip } from "../format.js";
15
- import { ClickAnchor, type Scroller } from "../tools/click-anchor.js";
16
- import { projectProcessGroups, type ProjectionEnv } from "../tools/group-view.js";
17
- import { ChatMirror, detachedTui, HostShapeError, type MirrorEntry } from "../tools/host.js";
18
- import { toolDefinitions } from "../tools/index.js";
19
- import { roundFromEntry, roundMarker, ROUND_ENTRY } from "../tools/round.js";
20
- import { TurnClock } from "../tools/turn-clock.js";
21
- import { type BorderParts, fitBorder, SEPARATOR } from "../statusbar/render.js";
22
- import { ACTION_HANDLERS } from "./actions.js";
23
- import { ANIMATING_KINDS, launchOrder, rowState, type ActivityFacts, type RowKind } from "./activity-list.js";
24
- import { modelAtomText } from "./run.js";
25
- import type { MasterRuntime } from "./runtime.js";
26
- import type { WorkerRef } from "./state.js";
27
-
28
- /** 上横线状态词;卡住行用活动列表给的“N 分钟无输出”提醒代替。 */
29
- const STATUS_WORD: Record<Exclude<RowKind, "stuck">, string> = {
30
- running: "运行中", review: "审查中", done: "完成", failed: "失败", interrupted: "被中断", idle: "空闲",
31
- };
32
- /** 输入区的上横线、输入行、下横线之外是正文(排队中的补话在上横线之上占行)。 */
33
- const CHROME_ROWS = 3;
34
- /** 名字被迫截短时至少留的宽度;再窄就不写名字。 */
35
- const MIN_NAME = 6;
36
- /** 下横线右侧:按显示顺序;宽度不够时 drop 小的先让,“esc 返回”永远保留。model 是模型原子的位置。 */
37
- const TAIL = [
38
- { text: "model", drop: 2 },
39
- { text: "Tab 换子代理", drop: 3 },
40
- { text: "点摘要展开", drop: 0 },
41
- { text: "ctrl+o 全部展开", drop: 1 },
42
- { text: "esc 返回", drop: Infinity },
43
- ];
44
- /** “已发出”替换按键提示的时长;任何按键也会提前收起。 */
45
- const NOTICE_MS = 3_000;
46
-
47
- /** 视图要的全部外部事实与动作:由 Master 运行时提供,测试替身同形。 */
48
- export interface WorkerViewSource {
49
- /** 活动列表的同一份事实:启动序名单与上横线的行状态都从它来。 */
50
- facts(): ActivityFacts;
51
- worker(name: string): WorkerRef | undefined;
52
- /** 进程内热会话;已释放返回 undefined,记录改从会话文件读。 */
53
- session(worker: WorkerRef): AgentSession | undefined;
54
- /** 子代理会话接上订阅(冷启动、重开)时通知,在它的第一条事件之前。 */
55
- onSession(listener: (name: string) => void): () => void;
56
- /** 子代理被移除(kill、启动失败撤票)时按名字通知。 */
57
- onWorkerRemoved(listener: (name: string) => void): () => void;
58
- /** 视图来源的 send:与指挥官 send 同一处理入口。 */
59
- send(name: string, prompt: string): Promise<void>;
60
- /** 名字 → 没发出的草稿;随运行时活到会话结束,不持久化。 */
61
- drafts: Map<string, string>;
62
- }
63
-
64
- /** 打开浮层;同一时刻只有一个。返回的 Promise 在浮层关闭时结束。 */
65
- export async function openWorkerView(active: MasterRuntime, name: string): Promise<void> {
66
- if (viewOpen) return;
67
- viewOpen = true;
68
- try {
69
- await active.ctx.ui.custom<void>(
70
- (tui, theme, keybindings, done) => new WorkerView(runtimeSource(active), name, tui, theme, keybindings, done),
71
- { overlay: true, overlayOptions: { width: "100%", maxHeight: "100%", anchor: "top-left" } },
72
- );
73
- } finally {
74
- viewOpen = false;
75
- }
76
- }
77
- let viewOpen = false;
78
- const runtimeDrafts = new WeakMap<MasterRuntime, Map<string, string>>();
79
-
80
- function runtimeSource(active: MasterRuntime): WorkerViewSource {
81
- let drafts = runtimeDrafts.get(active);
82
- if (!drafts) runtimeDrafts.set(active, drafts = new Map());
83
- return {
84
- facts: () => active.activityFacts(),
85
- worker: (name) => active.store.state.workers.find((worker) => worker.name === name),
86
- session: (worker) => active.setup.pool.getSession(worker.sessionPath),
87
- onSession: (listener) => active.onWorkerSession(listener),
88
- onWorkerRemoved: (listener) => active.onWorkerRemoved(listener),
89
- send: async (name, prompt) => {
90
- await ACTION_HANDLERS.send(active, { worker: name, prompt, origin: "view" }, active.ctx);
91
- },
92
- drafts,
93
- };
94
- }
95
-
96
- /**
97
- * 一个子代理记录:宿主组件镜像(轮记录随分支进来,投影按它分轮)、这个子代理自己的轮次时钟、热会话时的事件订阅,
98
- * 以及视图在它上面的状态(逐轮展开、全部展开档位、滚动与点击锚定),切走切回原样。
99
- */
100
- class WorkerRecord {
101
- readonly mirror: ChatMirror;
102
- readonly clock = new TurnClock();
103
- readonly overrides = new Set<object>();
104
- expanded = false;
105
- /** 正文的第一行;undefined 表示跟随末尾。 */
106
- scrollTop: number | undefined;
107
- /** 上次布局的正文高与最大首行。 */
108
- contentHeight = 0;
109
- maxTop = 0;
110
- viewport = 0;
111
- readonly anchor: ClickAnchor;
112
- private unsubscribe: (() => void) | undefined;
113
-
114
- constructor(worker: WorkerRef, readonly session: AgentSession | undefined, tui: TUI, theme: Theme, private readonly changed: () => void) {
115
- const definitions: Record<string, ReturnType<typeof toolDefinitions>[keyof ReturnType<typeof toolDefinitions>]> = toolDefinitions();
116
- this.mirror = new ChatMirror({
117
- ui: detachedTui(tui, changed),
118
- cwd: worker.cwd ?? process.cwd(),
119
- theme,
120
- toolDefinition: (tool) => definitions[tool],
121
- messageRenderer: (type) => session?.extensionRunner.getMessageRenderer(type),
122
- // 轮记录逐条经 roundFromEntry 读出,以零行标记放进聊天树,投影按它分轮(与主会话同一份记录格式)。
123
- entryRenderer: (type) => (type === ROUND_ENTRY ? roundEntryRenderer : undefined),
124
- });
125
- const branch = session ? session.sessionManager.getBranch() as MirrorEntry[] : fileBranch(worker.sessionPath);
126
- for (const entry of branch) this.mirror.replay(entry);
127
- this.sync(session?.isStreaming === true);
128
- if (session) this.unsubscribe = session.subscribe((event) => this.onEvent(event));
129
- const record = this;
130
- const scroller: Scroller = {
131
- get top() { return record.scrollTop ?? record.maxTop; },
132
- get following() { return record.scrollTop === undefined; },
133
- get viewport() { return record.viewport; },
134
- get contentHeight() { return record.contentHeight; },
135
- holdAt: (top) => { this.scrollTop = top; },
136
- follow: () => { this.scrollTop = undefined; },
137
- };
138
- this.anchor = new ClickAnchor(() => scroller);
139
- }
140
-
141
- /** 用户滚动:到底即恢复跟随末尾。 */
142
- scrollBy(delta: number): void {
143
- const next = Math.max(0, Math.min(this.maxTop, (this.scrollTop ?? this.maxTop) + delta));
144
- this.scrollTop = next >= this.maxTop ? undefined : next;
145
- }
146
-
147
- dispose(): void {
148
- this.unsubscribe?.();
149
- this.unsubscribe = undefined;
150
- }
151
-
152
- private onEvent(event: AgentSessionEvent): void {
153
- if (event.type === "agent_start") this.sync(true);
154
- if (event.type === "agent_end") this.sync(false);
155
- const touched = this.mirror.handle(event);
156
- if (touched || event.type === "agent_start" || event.type === "agent_end" || event.type === "queue_update") this.changed();
157
- }
158
-
159
- private sync(running: boolean): void {
160
- this.clock.sync({ agentRunning: running, inFlight: 0, busy: running, review: false, ...(running ? { since: Date.now() } : {}) });
161
- }
162
- }
163
-
164
- const roundEntryRenderer: EntryRenderer = (entry) => {
165
- const round = roundFromEntry(entry);
166
- return round && roundMarker(round);
167
- };
168
-
169
- /** 已释放的冷子代理:读一次会话文件,从最后一条沿 parentId 回到根,得到当前分支。 */
170
- function fileBranch(path: string): MirrorEntry[] {
171
- type Line = MirrorEntry & { id?: string; parentId?: string | null };
172
- const entries = new Map<string, Line>();
173
- let leaf: Line | undefined;
174
- for (const text of readFileSync(path, "utf8").split("\n")) {
175
- if (!text.trim()) continue;
176
- try {
177
- const entry = JSON.parse(text) as Line;
178
- if (!entry.id) continue;
179
- entries.set(entry.id, entry);
180
- leaf = entry;
181
- } catch {
182
- // 正在追加的尾行可能不完整。
183
- }
184
- }
185
- const branch: Line[] = [];
186
- for (let entry = leaf; entry; entry = entry.parentId ? entries.get(entry.parentId) : undefined) branch.unshift(entry);
187
- return branch;
188
- }
189
-
190
- export class WorkerView implements Component, Focusable {
191
- private readonly input = new Input({ prompt: "› ", placeholder: "补话给这个子代理,回车发送" });
192
- private readonly projection = new Container();
193
- private readonly headless = {};
194
- /** 打开期间看过的子代理:名字 → 记录。 */
195
- private readonly records = new Map<string, WorkerRecord>();
196
- private readonly stopWatches: (() => void)[];
197
- private failure: string | undefined;
198
- /** 正在看的子代理已被移除:上横线写“已移除”,输入框停用。 */
199
- private removed = false;
200
- /** 在启动序里的位置;被移除后 Tab 从这里接着走。 */
201
- private index = 0;
202
- private notice: { text: string; until?: number } | undefined;
203
- private noticeTimer: ReturnType<typeof setTimeout> | undefined;
204
- private bodyRows = 0;
205
- private stopFrames: (() => void) | undefined;
206
- private sending = false;
207
-
208
- constructor(
209
- private readonly source: WorkerViewSource,
210
- private name: string,
211
- private readonly tui: Pick<TUI, "requestRender"> & { terminal: { rows: number } },
212
- private readonly theme: Theme,
213
- private readonly keys: Pick<KeybindingsManager, "matches">,
214
- private readonly done: () => void,
215
- ) {
216
- this.input.onSubmit = (text) => void this.send(text);
217
- this.stopWatches = [
218
- // 冷子代理被唤醒、或释放后重开:会话在第一条事件之前接上订阅,记录从新会话重建一次。
219
- source.onSession((worker) => {
220
- if (worker !== this.name || this.removed) return;
221
- this.load();
222
- this.tui.requestRender();
223
- }),
224
- source.onWorkerRemoved((worker) => this.onRemoved(worker)),
225
- ];
226
- this.enter(name);
227
- }
228
-
229
- get focused(): boolean {
230
- return this.input.focused;
231
- }
232
-
233
- set focused(value: boolean) {
234
- this.input.focused = value;
235
- }
236
-
237
- invalidate(): void {}
238
-
239
- dispose(): void {
240
- this.saveDraft();
241
- for (const stop of this.stopWatches.splice(0)) stop();
242
- for (const record of this.records.values()) record.dispose();
243
- this.records.clear();
244
- clearTimeout(this.noticeTimer);
245
- this.stopFrames?.();
246
- this.stopFrames = undefined;
247
- this.projection.children = [];
248
- }
249
-
250
- private get record(): WorkerRecord | undefined {
251
- return this.records.get(this.name);
252
- }
253
-
254
- private worker(): WorkerRef | undefined {
255
- return this.source.worker(this.name);
256
- }
257
-
258
- private names(): string[] {
259
- return launchOrder(this.source.facts());
260
- }
261
-
262
- /** 换到某个子代理:草稿换成它的,记录与展开状态打开期间看过就原样接上。 */
263
- private enter(name: string): void {
264
- this.name = name;
265
- this.removed = false;
266
- this.notice = undefined;
267
- this.index = Math.max(0, this.names().indexOf(name));
268
- this.input.setValue(this.source.drafts.get(name) ?? "");
269
- this.load();
270
- }
271
-
272
- private saveDraft(): void {
273
- const draft = this.input.getValue();
274
- if (draft && !this.removed) this.source.drafts.set(this.name, draft);
275
- else this.source.drafts.delete(this.name);
276
- }
277
-
278
- /** 记录缺失或会话已换(释放后重开、冷启动)时构建一次;之后由事件增量。 */
279
- private load(): void {
280
- this.failure = undefined;
281
- const worker = this.worker();
282
- if (!worker) {
283
- this.failure = `${this.name} 已不在池里`;
284
- return;
285
- }
286
- const session = this.source.session(worker);
287
- const cached = this.record;
288
- if (cached && cached.session === session) return;
289
- cached?.dispose();
290
- this.records.delete(this.name);
291
- try {
292
- this.records.set(this.name, new WorkerRecord(worker, session, this.tui as TUI, this.theme, () => this.tui.requestRender()));
293
- } catch (error) {
294
- this.failure = error instanceof HostShapeError ? error.message : `读不到 ${worker.name} 的记录:${error instanceof Error ? error.message : String(error)}`;
295
- }
296
- }
297
-
298
- /** 被移除的子代理:别的直接丢掉;正在看的留着已有记录(不再更新),上横线写“已移除”,输入框停用。 */
299
- private onRemoved(name: string): void {
300
- this.records.get(name)?.dispose();
301
- this.source.drafts.delete(name);
302
- if (name === this.name) {
303
- this.removed = true;
304
- this.input.setValue("");
305
- } else this.records.delete(name);
306
- this.tui.requestRender();
307
- }
308
-
309
- private env(record: WorkerRecord): ProjectionEnv {
310
- return {
311
- ui: { theme: this.theme, getToolsExpanded: () => record.expanded },
312
- clock: record.clock, headless: this.headless,
313
- toggleRow: (row) => {
314
- row.setExpanded(!(row as unknown as { expanded: boolean }).expanded);
315
- this.tui.requestRender();
316
- },
317
- isOpen: (key) => record.overrides.has(key),
318
- toggleOpen: (key) => {
319
- if (!record.overrides.delete(key)) record.overrides.add(key);
320
- this.tui.requestRender();
321
- },
322
- };
323
- }
324
-
325
- render(width: number): string[] {
326
- const worker = this.removed ? undefined : this.worker();
327
- const facts = this.source.facts();
328
- const index = worker ? facts.workers.findIndex((entry) => entry.name === worker.name) : -1;
329
- const state = index < 0 ? undefined : rowState(facts, index, Date.now(), this.theme);
330
- const queued = this.queued(width);
331
- this.bodyRows = Math.max(1, this.tui.terminal.rows - CHROME_ROWS - queued.length);
332
- const { lines: body, animating } = this.body(width);
333
- // 动效与上横线耗时只在有东西在动时订阅全局时钟,静止即取消。
334
- this.syncFrames(animating || (state !== undefined && ANIMATING_KINDS.has(state.kind)));
335
- const record = this.record;
336
- let top = 0;
337
- if (record) {
338
- record.viewport = this.bodyRows;
339
- record.anchor.layout(body.length);
340
- record.contentHeight = body.length;
341
- record.maxTop = Math.max(0, body.length - this.bodyRows);
342
- top = Math.min(record.scrollTop ?? record.maxTop, record.maxTop);
343
- }
344
- const shown = body.slice(top, top + this.bodyRows);
345
- while (shown.length < this.bodyRows) shown.push("");
346
- return [...shown, ...queued, this.topLine(width, worker, state), this.inputLine(width), this.bottomLine(width, worker)];
347
- }
348
-
349
- private body(width: number): { lines: string[]; animating: boolean } {
350
- const record = this.record;
351
- if (!record) return { lines: [this.theme.fg("warning", ` ${this.failure ?? ""}`)], animating: false };
352
- const { nodes, animating } = projectProcessGroups(record.mirror.chat.children, this.env(record));
353
- this.projection.children = nodes;
354
- return { lines: this.projection.render(width), animating };
355
- }
356
-
357
- private syncFrames(moving: boolean): void {
358
- if (moving && !this.stopFrames) this.stopFrames = onFrame(() => this.tui.requestRender());
359
- else if (!moving && this.stopFrames) {
360
- this.stopFrames();
361
- this.stopFrames = undefined;
362
- }
363
- }
364
-
365
- /** 已发出、还没在句缝送达的补话:读 Worker 会话的排队事实,送达后自然消失。 */
366
- private queued(width: number): string[] {
367
- const steering = this.removed ? [] : this.record?.session?.getSteeringMessages() ?? [];
368
- return steering.map((text) => clip(this.theme.fg("dim", ` 排队中:${text}`), width));
369
- }
370
-
371
- private readonly line = (text: string) => this.theme.fg("borderMuted", text);
372
-
373
- /** 上横线:左状态(字形、状态词、耗时,必保),右名字 · 角色;放不下时先让角色,再截短名字,最后不写名字。 */
374
- private topLine(width: number, worker: WorkerRef | undefined, state: ReturnType<typeof rowState> | undefined): string {
375
- const name = this.theme.bold(this.name);
376
- if (!worker || !state) {
377
- const status = this.removed ? this.theme.fg("warning", "已移除") : "";
378
- return fitBorder(width, this.line, 0, [[status, name], [status, ""]]);
379
- }
380
- const { kind, row } = state;
381
- const word = kind === "stuck" ? this.theme.fg("warning", row.note?.short ?? "") : STATUS_WORD[kind];
382
- // 空闲的“·”标记紧挨状态词像个分隔符,不放。
383
- const status = [kind === "idle" ? "" : row.mark, word, row.elapsed && this.theme.fg("muted", row.elapsed)].filter(Boolean).join(" ");
384
- const role = this.theme.fg("muted", worker.role);
385
- function* candidates(): Generator<BorderParts> {
386
- yield [status, `${name}${SEPARATOR}${role}`];
387
- for (let room = visibleWidth(name); room >= MIN_NAME; room--) yield [status, clip(name, room)];
388
- yield [status, ""];
389
- }
390
- return fitBorder(width, this.line, ANIMATING_KINDS.has(kind) ? 1 : 0, candidates());
391
- }
392
-
393
- private inputLine(width: number): string {
394
- if (this.removed) return clip(`› ${this.theme.fg("dim", "子代理已移除,不能再补话")}`, width);
395
- return this.input.render(width)[0] ?? "";
396
- }
397
-
398
- /** 下横线:左位置 n/N(必保),右模型与按键提示(按宽度退让,“esc 返回”永远保留);通知(已发出、未送达)替换右侧。 */
399
- private bottomLine(width: number, worker: WorkerRef | undefined): string {
400
- const names = this.names();
401
- const position = names.indexOf(this.name);
402
- if (position >= 0) this.index = position;
403
- const lead = position >= 0 ? `${position + 1}/${names.length}` : "";
404
- const notice = this.notice && (this.notice.until === undefined || Date.now() < this.notice.until) ? this.notice.text : undefined;
405
- const model = worker ? modelAtomText(worker) : "";
406
- const dim = (text: string) => this.theme.fg("dim", text);
407
- function* candidates(): Generator<BorderParts> {
408
- if (notice) {
409
- for (let room = visibleWidth(notice); room >= 1; room--) yield [lead, dim(clip(notice, room))];
410
- return;
411
- }
412
- const tail = TAIL.filter((item) => item.text !== "model" || model);
413
- for (;;) {
414
- yield [lead, dim(tail.map((item) => (item.text === "model" ? model : item.text)).join(" · "))];
415
- const weakest = tail.reduce((a, b) => (b.drop < a.drop ? b : a));
416
- if (weakest.drop === Infinity) return;
417
- tail.splice(tail.indexOf(weakest), 1);
418
- }
419
- }
420
- return fitBorder(width, this.line, 0, candidates());
421
- }
422
-
423
- /**
424
- * 浮层抢走焦点后宿主编辑器上的全局键不再生效,在这里给出同义行为:esc 返回;ctrl+c 先清输入、再按关闭视图;
425
- * 空输入的 ctrl+d 关闭视图(不在浮层里退出整个 pi);ctrl+o 是这个视图的全部展开;PageUp/PageDown 翻页。
426
- */
427
- handleInput(data: string): void {
428
- const empty = !this.input.getValue();
429
- this.notice = undefined;
430
- if (this.keys.matches(data, "app.interrupt")) return this.done();
431
- if (this.keys.matches(data, "app.clear")) return empty ? this.done() : this.clearInput();
432
- if (this.keys.matches(data, "app.exit") && empty) return this.done();
433
- if (this.keys.matches(data, "app.tools.expand")) return this.toggleAll();
434
- if (matchesKey(data, "tab")) return this.step(1);
435
- if (matchesKey(data, "shift+tab")) return this.step(-1);
436
- if (matchesKey(data, "pageUp")) return this.scroll(1 - this.bodyRows);
437
- if (matchesKey(data, "pageDown")) return this.scroll(this.bodyRows - 1);
438
- if (!this.removed) this.input.handleInput(data);
439
- this.tui.requestRender();
440
- }
441
-
442
- private toggleAll(): void {
443
- const record = this.record;
444
- if (!record) return;
445
- record.expanded = !record.expanded;
446
- record.overrides.clear();
447
- this.tui.requestRender();
448
- }
449
-
450
- private scroll(delta: number): void {
451
- this.record?.scrollBy(delta);
452
- this.tui.requestRender();
453
- }
454
-
455
- private clearInput(): void {
456
- this.input.setValue("");
457
- this.tui.requestRender();
458
- }
459
-
460
- private step(direction: number): void {
461
- const names = this.names();
462
- const count = names.length;
463
- if (count < (this.removed ? 1 : 2)) return;
464
- this.saveDraft();
465
- if (this.removed) {
466
- // 被移除的位置由后一个顶上:Tab 落在原位置,Shift+Tab 落在前一个。
467
- this.enter(names[(direction > 0 ? this.index : this.index - 1 + count) % count]);
468
- } else this.enter(names[(names.indexOf(this.name) + direction + count) % count]);
469
- this.tui.requestRender();
470
- }
471
-
472
- /**
473
- * 只处理滚轮与点击:按下、拖动与松开交还宿主,宿主据此做文字选择(有浮层时按屏幕坐标选、松开即复制),
474
- * 不拖动的松开再由宿主转成点击发回来;点到摘要、↳ 与工具行之外的地方也交还宿主。
475
- */
476
- handleMouse(event: TuiMouseEvent) {
477
- if (event.type === "wheel") {
478
- this.scroll(event.wheelDelta ?? 0);
479
- return { handled: true };
480
- }
481
- const record = this.record;
482
- const row = event.y;
483
- if (event.type !== "click" || !record || row < 0 || row >= this.bodyRows) return undefined;
484
- const line = Math.min(record.scrollTop ?? record.maxTop, record.maxTop) + row;
485
- const result = this.projection.handleMouse({ ...event, y: line, height: record.contentHeight });
486
- if (result) record.anchor.click(line);
487
- return result;
488
- }
489
-
490
- private async send(text: string): Promise<void> {
491
- const prompt = text.trim();
492
- if (!prompt || this.sending || this.removed) return;
493
- const name = this.name;
494
- this.sending = true;
495
- this.showNotice("发送中…");
496
- try {
497
- await this.source.send(name, prompt);
498
- if (name === this.name) this.input.setValue("");
499
- this.source.drafts.delete(name);
500
- this.showNotice("已发出", NOTICE_MS);
501
- } catch (error) {
502
- this.showNotice(`未送达:${error instanceof Error ? error.message : String(error)}`);
503
- } finally {
504
- this.sending = false;
505
- }
506
- }
507
-
508
- private showNotice(text: string, duration?: number): void {
509
- clearTimeout(this.noticeTimer);
510
- this.notice = { text, ...(duration === undefined ? {} : { until: Date.now() + duration }) };
511
- if (duration !== undefined) {
512
- this.noticeTimer = setTimeout(() => this.tui.requestRender(), duration);
513
- this.noticeTimer.unref?.();
514
- }
515
- this.tui.requestRender();
516
- }
517
- }
@@ -1,151 +0,0 @@
1
- /**
2
- * Claude 订阅适配(Anthropic OAuth 会话):
3
- * - 归因:按 Claude Code 的格式补 user-agent 与系统提示词首块的 billing header,缺失时注入,已存在则原样通过。
4
- * - 换发自愈:Anthropic 换发订阅令牌即吊销旧令牌,换发时仍在途的请求以 401 落空;宿主不重试 401,
5
- * 这里把该失败从模型投影中省略并续跑一次,续跑请求由宿主重读令牌文件拿到新令牌。重试仍 401 即登录真失效,照常落定。
6
- * 是否已重试只看会话分支,不留内存标记;不交给角色 fallback,换模型解决不了令牌换发。宿主将来自行重试这类 401 时删除本自愈。
7
- */
8
- import { execFileSync } from "node:child_process";
9
- import { createHash } from "node:crypto";
10
- import { appendFileSync, mkdirSync } from "node:fs";
11
- import { dirname, resolve } from "node:path";
12
- import type { ExtensionAPI, ExtensionContext, SessionEntry, SessionMessageEntry } from "@earendil-works/pi-coding-agent";
13
- import { textOf } from "../format.js";
14
-
15
- const BILLING_PREFIX = "x-anthropic-billing-header:";
16
- const FALLBACK_CLAUDE_CODE_VERSION = "2.1.281";
17
- const DEFAULT_ENTRYPOINT = "cli";
18
- const BILLING_SALT = "59cf53e54c78";
19
- const REVOKED_TOKEN_NOTICE = "Claude 令牌刚换发,已自动重试";
20
-
21
- type TextBlock = {
22
- type: "text";
23
- text: string;
24
- cache_control?: { type: "ephemeral"; ttl?: "1h" };
25
- };
26
-
27
- interface PayloadLike {
28
- system?: unknown;
29
- messages?: unknown;
30
- }
31
-
32
- function isObject(value: unknown): value is Record<string, unknown> {
33
- return typeof value === "object" && value !== null;
34
- }
35
-
36
- function isTextBlock(value: unknown): value is TextBlock {
37
- return isObject(value) && value.type === "text" && typeof value.text === "string";
38
- }
39
-
40
- function shouldApply(ctx: ExtensionContext): boolean {
41
- const model = ctx.model;
42
- return !!model && model.provider === "anthropic" && ctx.modelRegistry.isUsingOAuth(model);
43
- }
44
-
45
- function detectClaudeCodeVersion(): string {
46
- const explicit = process.env.PI_CLAUDE_CODE_VERSION;
47
- if (explicit) return explicit;
48
-
49
- try {
50
- const output = execFileSync("claude", ["--version"], {
51
- encoding: "utf8",
52
- stdio: ["ignore", "pipe", "ignore"],
53
- timeout: 1000,
54
- });
55
- const match = output.match(/\b\d+\.\d+\.\d+\b/);
56
- if (match) return match[0];
57
- } catch {
58
- // 本地未装 Claude Code 时用回退版本号,不阻塞启动。
59
- }
60
-
61
- return FALLBACK_CLAUDE_CODE_VERSION;
62
- }
63
-
64
- const claudeCodeVersion = detectClaudeCodeVersion();
65
-
66
- function firstUserText(messages: unknown): string {
67
- const list = Array.isArray(messages) ? messages : [];
68
- const firstUser = list.find((message) => isObject(message) && message.role === "user");
69
- return isObject(firstUser) ? textOf(firstUser.content) : "";
70
- }
71
-
72
- function versionSuffix(messageText: string): string {
73
- const explicit = process.env.PI_CLAUDE_CODE_VERSION_SUFFIX;
74
- if (explicit) return explicit;
75
-
76
- const sampled = [4, 7, 20].map((index) => messageText[index] ?? "0").join("");
77
- return createHash("sha256")
78
- .update(`${BILLING_SALT}${sampled}${claudeCodeVersion}`)
79
- .digest("hex")
80
- .slice(0, 3);
81
- }
82
-
83
- function buildBillingHeader(messages: unknown): string {
84
- const version = `${claudeCodeVersion}.${versionSuffix(firstUserText(messages))}`;
85
- const entrypoint =
86
- process.env.PI_CLAUDE_CODE_ENTRYPOINT ?? process.env.CLAUDE_CODE_ENTRYPOINT ?? DEFAULT_ENTRYPOINT;
87
- const workload = process.env.PI_CLAUDE_CODE_WORKLOAD ?? process.env.CLAUDE_CODE_WORKLOAD;
88
- const workloadPart = workload ? ` cc_workload=${workload};` : "";
89
- return `${BILLING_PREFIX} cc_version=${version}; cc_entrypoint=${entrypoint}; cch=00000;${workloadPart}`;
90
- }
91
-
92
- function log(details: Record<string, unknown>): void {
93
- const logFile = process.env.PI_CLAUDE_OAUTH_LOG_FILE;
94
- if (!logFile) return;
95
-
96
- const path = resolve(logFile);
97
- try {
98
- mkdirSync(dirname(path), { recursive: true });
99
- appendFileSync(path, `${JSON.stringify({ timestamp: new Date().toISOString(), ...details })}\n`, "utf8");
100
- } catch {
101
- // 调试日志是可选的。
102
- }
103
- }
104
-
105
- function isRevokedTokenFailure(entry: SessionEntry | undefined): entry is SessionMessageEntry {
106
- if (entry?.type !== "message" || entry.message.role !== "assistant") return false;
107
- const { stopReason, provider, errorMessage } = entry.message;
108
- return stopReason === "error" && provider === "anthropic" && !!errorMessage?.includes('"authentication_error"');
109
- }
110
-
111
- /** 分支上最后两条消息;中间的 context_edit 等非消息条目不算。 */
112
- function lastTwoMessages(branch: SessionEntry[]): [SessionEntry | undefined, SessionEntry | undefined] {
113
- let last: SessionEntry | undefined;
114
- for (let index = branch.length - 1; index >= 0; index--) {
115
- if (branch[index].type !== "message") continue;
116
- if (last) return [branch[index], last];
117
- last = branch[index];
118
- }
119
- return [undefined, last];
120
- }
121
-
122
- export function registerClaudeSub(pi: ExtensionAPI): void {
123
- pi.registerProvider("anthropic", {
124
- headers: {
125
- "user-agent": `claude-cli/${claudeCodeVersion} (external, cli)`,
126
- "x-app": "cli",
127
- },
128
- });
129
-
130
- pi.on("before_provider_request", (event, ctx) => {
131
- const payload = event.payload;
132
- if (!shouldApply(ctx) || !isObject(payload)) return;
133
-
134
- const { system, messages } = payload as PayloadLike;
135
- const blocks = Array.isArray(system) ? system : [];
136
- if (blocks.some((block) => isTextBlock(block) && block.text.startsWith(BILLING_PREFIX))) return;
137
-
138
- const header: TextBlock = { type: "text", text: buildBillingHeader(messages) };
139
- log({ event: "billing_header_injected", header: header.text });
140
- return { ...payload, system: [header, ...blocks] };
141
- });
142
-
143
- pi.on("agent_before_settle", (event, ctx) => {
144
- if (event.outcome !== "error" || !shouldApply(ctx)) return;
145
- const [previous, failure] = lastTwoMessages(ctx.sessionManager.getBranch());
146
- // 紧挨着的上一条也是同类失败,说明这次已是自愈重试:登录真失效,交回宿主照常落定。
147
- if (!isRevokedTokenFailure(failure) || isRevokedTokenFailure(previous)) return;
148
- ctx.ui.notify(REVOKED_TOKEN_NOTICE, "info");
149
- return { entries: [{ type: "context_edit", targetId: failure.id, replacement: null }], continue: true };
150
- });
151
- }
@@ -1,8 +0,0 @@
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
- }