pi-firecode 0.8.1 → 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 +17 -12
  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
@@ -0,0 +1,517 @@
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-firecode",
3
- "version": "0.8.1",
3
+ "version": "1.0.0",
4
4
  "description": "A modular Pi extension for terminal UI, session workflows, adversarial review, and delegated agents",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,17 +1,22 @@
1
1
  /**
2
- * Anthropic OAuth 会话按 Claude Code 的归因格式发请求:补 user-agent 与
3
- * 系统提示词首块的 billing header,缺失时注入,已存在则原样通过。
2
+ * Claude 订阅适配(Anthropic OAuth 会话):
3
+ * - 归因:按 Claude Code 的格式补 user-agent 与系统提示词首块的 billing header,缺失时注入,已存在则原样通过。
4
+ * - 换发自愈:Anthropic 换发订阅令牌即吊销旧令牌,换发时仍在途的请求以 401 落空;宿主不重试 401,
5
+ * 这里把该失败从模型投影中省略并续跑一次,续跑请求由宿主重读令牌文件拿到新令牌。重试仍 401 即登录真失效,照常落定。
6
+ * 是否已重试只看会话分支,不留内存标记;不交给角色 fallback,换模型解决不了令牌换发。宿主将来自行重试这类 401 时删除本自愈。
4
7
  */
5
8
  import { execFileSync } from "node:child_process";
6
9
  import { createHash } from "node:crypto";
7
10
  import { appendFileSync, mkdirSync } from "node:fs";
8
11
  import { dirname, resolve } from "node:path";
9
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
12
+ import type { ExtensionAPI, ExtensionContext, SessionEntry, SessionMessageEntry } from "@earendil-works/pi-coding-agent";
13
+ import { textOf } from "../format.js";
10
14
 
11
15
  const BILLING_PREFIX = "x-anthropic-billing-header:";
12
- const FALLBACK_CLAUDE_CODE_VERSION = "2.1.229";
16
+ const FALLBACK_CLAUDE_CODE_VERSION = "2.1.281";
13
17
  const DEFAULT_ENTRYPOINT = "cli";
14
18
  const BILLING_SALT = "59cf53e54c78";
19
+ const REVOKED_TOKEN_NOTICE = "Claude 令牌刚换发,已自动重试";
15
20
 
16
21
  type TextBlock = {
17
22
  type: "text";
@@ -58,15 +63,6 @@ function detectClaudeCodeVersion(): string {
58
63
 
59
64
  const claudeCodeVersion = detectClaudeCodeVersion();
60
65
 
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
66
  function firstUserText(messages: unknown): string {
71
67
  const list = Array.isArray(messages) ? messages : [];
72
68
  const firstUser = list.find((message) => isObject(message) && message.role === "user");
@@ -106,6 +102,23 @@ function log(details: Record<string, unknown>): void {
106
102
  }
107
103
  }
108
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
+
109
122
  export function registerClaudeSub(pi: ExtensionAPI): void {
110
123
  pi.registerProvider("anthropic", {
111
124
  headers: {
@@ -126,4 +139,13 @@ export function registerClaudeSub(pi: ExtensionAPI): void {
126
139
  log({ event: "billing_header_injected", header: header.text });
127
140
  return { ...payload, system: [header, ...blocks] };
128
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
+ });
129
151
  }
@@ -199,10 +199,6 @@ function parseResponseOutput(responseText: string):
199
199
  : { ok: false, reason: "malformed-response" };
200
200
  }
201
201
 
202
- function estimateSerializedTokens(value: unknown): number {
203
- return Math.ceil(JSON.stringify(value).length / 4);
204
- }
205
-
206
202
  function isRetainedInputItem(value: unknown): value is Record<string, unknown> {
207
203
  return isRecord(value) && (value.role === "user" || value.role === "developer" || value.role === "system");
208
204
  }
@@ -298,17 +294,9 @@ export async function executeNativeCompaction(args: {
298
294
  }
299
295
 
300
296
  try {
297
+ // 输入是否超窗由服务端裁决(context_length_exceeded → input-too-large):
298
+ // 本地字符估算既不准也是第二事实源,历史上曾把正常触发线(窗口 − 预留)上的压缩全部误拒。
301
299
  const input = [...request.input, COMPACTION_TRIGGER];
302
- const inputTokens = estimateSerializedTokens(request.instructions) + estimateSerializedTokens(input);
303
- const outputReserve = runtime.currentModel.maxTokens;
304
- const contextWindow = runtime.currentModel.contextWindow;
305
- if (inputTokens + outputReserve > contextWindow) {
306
- return {
307
- ok: false,
308
- reason: "input-too-large",
309
- detail: `estimated input exceeds the model context window (${inputTokens} input + ${outputReserve} output reserve > ${contextWindow} tokens)`,
310
- };
311
- }
312
300
  const response = await fetch(runtime.responsesUrl, {
313
301
  method: "POST",
314
302
  headers: buildHeaders(runtime),
@@ -5,12 +5,14 @@ export const FAST_STATUS_KEY = "pi-openai-native-fast";
5
5
 
6
6
  const OPENAI_RESPONSES_APIS = new Set(["openai-responses", "openai-codex-responses"]);
7
7
  const PRIORITY_MODEL_IDS = new Set([
8
- "gpt-5.4",
9
- "gpt-5.4-mini",
10
8
  "gpt-5.5",
11
9
  "gpt-5.6-sol",
12
10
  "gpt-5.6-terra",
13
11
  "gpt-5.6-luna",
12
+ "gpt-6-astra",
13
+ "gpt-6-sol",
14
+ "gpt-6.1-sol",
15
+ "gpt-6-luna",
14
16
  ]);
15
17
 
16
18
  function isRecord(value: unknown): value is Record<string, unknown> {
package/review/advisor.ts CHANGED
@@ -12,7 +12,6 @@ export interface RunAdvisorOptions {
12
12
  language: Language;
13
13
  signal?: AbortSignal;
14
14
  runSession: ReviewSessionRunner;
15
- onEvent?: (event: Record<string, unknown>) => void;
16
15
  }
17
16
 
18
17
  export async function runAdvisor(options: RunAdvisorOptions): Promise<AdvisorResult> {
@@ -25,7 +24,6 @@ export async function runAdvisor(options: RunAdvisorOptions): Promise<AdvisorRes
25
24
  cwd: options.cwd,
26
25
  timeoutMs: options.config.timeoutMs,
27
26
  signal: options.signal,
28
- onEvent: options.onEvent,
29
27
  });
30
28
  if (result.kind !== "output") throw new Error(advisorProcessError(result, options.language));
31
29
  return parseAdvisorOutput(result.text, options.language);