pi-firecode 0.8.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/README.md +35 -15
  2. package/activity.ts +92 -0
  3. package/busy.ts +188 -0
  4. package/config.example.jsonc +38 -47
  5. package/config.ts +198 -172
  6. package/deliver.ts +100 -13
  7. package/flame.ts +149 -0
  8. package/format.ts +84 -31
  9. package/header.ts +208 -82
  10. package/index.ts +17 -11
  11. package/master/actions.ts +293 -0
  12. package/master/activity-list.ts +281 -0
  13. package/master/event-card.ts +18 -37
  14. package/master/event-format.ts +71 -40
  15. package/master/guard.ts +46 -0
  16. package/master/index.ts +88 -1036
  17. package/master/list-view.ts +129 -0
  18. package/master/outbox.ts +188 -0
  19. package/master/prompts/master.zh.md +18 -13
  20. package/master/prompts/worker.zh.md +1 -1
  21. package/master/role.ts +6 -1
  22. package/master/run.ts +315 -0
  23. package/master/runtime.ts +259 -0
  24. package/master/spawn.ts +79 -38
  25. package/master/state.ts +30 -6
  26. package/master/worker-view.ts +517 -0
  27. package/package.json +1 -1
  28. package/provider/claude-sub.ts +35 -13
  29. package/provider/openai-native/src/compact-client.ts +2 -14
  30. package/provider/openai-native/src/options.ts +4 -2
  31. package/review/advisor.ts +0 -2
  32. package/review/card.ts +36 -23
  33. package/review/checkpoint.ts +2 -8
  34. package/review/evidence.ts +42 -44
  35. package/review/index.ts +271 -525
  36. package/review/occupancy.ts +25 -0
  37. package/review/outcome.ts +31 -7
  38. package/review/prompt.ts +7 -8
  39. package/review/prompts/review.en.md +7 -6
  40. package/review/prompts/review.zh.md +7 -6
  41. package/review/reviewer.ts +2 -5
  42. package/review/session.ts +11 -30
  43. package/review/state.ts +32 -60
  44. package/review/ui.ts +30 -469
  45. package/round-recorder.ts +18 -0
  46. package/session/herdr-display.ts +1 -1
  47. package/session/presets.ts +130 -87
  48. package/session/quota.ts +123 -0
  49. package/session/stats.ts +4 -1
  50. package/statusbar/index.ts +242 -104
  51. package/statusbar/render.ts +95 -163
  52. package/theme.ts +2 -41
  53. package/tools/actions.ts +53 -0
  54. package/tools/assistant-view.ts +64 -0
  55. package/tools/click-anchor.ts +56 -0
  56. package/tools/group-view.ts +420 -0
  57. package/tools/grouping.ts +146 -221
  58. package/tools/host.ts +369 -0
  59. package/tools/index.ts +108 -105
  60. package/tools/line.ts +95 -58
  61. package/tools/machine.ts +78 -0
  62. package/tools/parts.ts +2 -2
  63. package/tools/round.ts +95 -0
  64. package/tools/turn-clock.ts +41 -0
  65. package/tools/turn-summary.ts +109 -0
  66. package/truncated-write.ts +18 -0
  67. package/watcher/card.ts +28 -22
  68. package/watcher/index.ts +16 -44
  69. package/watcher/observer.ts +3 -3
  70. package/watcher/prompts/watch.zh.md +1 -1
  71. package/watcher/transcript.ts +4 -12
  72. package/flame-frames.ts +0 -460
  73. package/review/progress.ts +0 -324
  74. package/session/bark.ts +0 -157
  75. package/session/working-flame.ts +0 -116
  76. package/statusbar/quota-cache.ts +0 -60
  77. package/statusbar/quota-parse.ts +0 -85
  78. package/statusbar/quota.ts +0 -203
  79. package/statusbar/tps.ts +0 -87
package/config.ts CHANGED
@@ -14,25 +14,27 @@ export type ThinkingLevelValue =
14
14
  | "xhigh"
15
15
  | "max";
16
16
 
17
+ /**
18
+ * 模型原子:配置里一律写作 "provider/model/thinking",解析后拆成运行时模型 id 与思考档。
19
+ * 全仓库指定模型与思考档的唯一形状。
20
+ */
21
+ export interface ModelAtom {
22
+ model: string;
23
+ thinking: ThinkingLevelValue;
24
+ }
25
+
17
26
  export interface Preset {
18
- provider?: string;
19
- model?: string;
20
- thinkingLevel?: ThinkingLevelValue;
27
+ model?: ModelAtom;
21
28
  tools?: string[];
22
29
  instructions?: string;
23
30
  /** 一键切换,如 alt+1;不填则无快捷键 */
24
31
  key?: string;
25
32
  }
26
33
 
27
- export interface ReviewModel {
28
- model: string;
29
- thinking: ThinkingLevelValue;
30
- }
31
-
32
34
  /** /fire-review 配置:审查者 / 顾问模型 + 循环限制。见 config.jsonc 的 review 节注释。 */
33
35
  export interface ReviewConfig {
34
- advisor: ReviewModel;
35
- reviewers: ReviewModel[];
36
+ advisor: ModelAtom;
37
+ reviewers: ModelAtom[];
36
38
  /** 审查轮数硬上限。 */
37
39
  maxRounds: number;
38
40
  /** 连续几轮失败触发顾问仲裁。 */
@@ -44,19 +46,10 @@ export interface ReviewConfig {
44
46
  language: Language;
45
47
  }
46
48
 
47
- /** Master 角色表条目:原子模型已拆成运行时模型与思考档。 */
48
- export interface MasterModelAtom {
49
- model: string;
50
- thinking: ThinkingLevelValue;
51
- }
52
-
53
- export const MASTER_ROLES = ["调研员", "工程师", "全栈", "架构师", "设计师", "哨兵"] as const;
54
- export type MasterRoleName = (typeof MASTER_ROLES)[number];
55
-
56
- export interface MasterRole extends MasterModelAtom {
57
- role: MasterRoleName;
49
+ export interface MasterRole extends ModelAtom {
50
+ role: string;
58
51
  use: string;
59
- fallback: MasterModelAtom[];
52
+ fallback: ModelAtom[];
60
53
  }
61
54
 
62
55
  export interface MasterConfig {
@@ -68,14 +61,13 @@ export interface MasterConfig {
68
61
  /** 观察员喂给观察会话的增量粒度:minimal 省略 reasoning 与 diff 正文。 */
69
62
  export type WatcherContext = "minimal" | "full";
70
63
 
71
- /** Watcher 观察员配置:模型与 thinking 必须显式配置,绝不回退默认模型。 */
72
- export interface WatcherConfig {
64
+ /** Watcher 观察员配置:模型原子必须显式配置,绝不回退默认模型。 */
65
+ export interface WatcherConfig extends ModelAtom {
73
66
  enabled: boolean;
74
- model: string;
75
- thinking: ThinkingLevelValue;
76
67
  context: WatcherContext;
77
68
  }
78
69
 
70
+
79
71
  export const FEATURES = [
80
72
  "header",
81
73
  "statusbar",
@@ -85,8 +77,6 @@ export const FEATURES = [
85
77
  "stats",
86
78
  "claudeSub",
87
79
  "openaiNative",
88
- "workingFlame",
89
- "bark",
90
80
  "review",
91
81
  "master",
92
82
  "watcher",
@@ -94,15 +84,16 @@ export const FEATURES = [
94
84
 
95
85
  export type Feature = (typeof FEATURES)[number];
96
86
 
87
+ /** 顶层只认这些节;openai 节由 provider/openai-native 自己解析。 */
88
+ const SECTIONS = ["features", "keys", "openai", "presets", "review", "master", "watcher"];
89
+
97
90
  export const DEFAULT_KEYS = {
98
91
  rename: "ctrl+r",
99
- cyclePreset: "ctrl+shift+u",
100
92
  fast: "ctrl+f",
101
93
  } as const;
102
94
 
103
95
  export type FireCodeKeys = {
104
96
  rename: string;
105
- cyclePreset: string;
106
97
  fast: string;
107
98
  };
108
99
 
@@ -115,9 +106,18 @@ export interface FireCodeConfig {
115
106
  watcher: WatcherConfig;
116
107
  }
117
108
 
109
+ /** 一节能否启动的唯一判定:要么交出可用配置,要么给出拒绝启动的原因。 */
110
+ export type Section<T> = { config: T } | { error: string };
111
+
118
112
  export type LoadedConfig = {
119
113
  config: FireCodeConfig;
114
+ /** 需要在 session_start 全局警告的问题;关闭的功能那一节的问题不在其中。 */
120
115
  problems: string[];
116
+ review: Section<ReviewConfig>;
117
+ master: Section<MasterConfig>;
118
+ watcher: Section<WatcherConfig>;
119
+ /** features 整节类型错误:已安全回退成全关,但那是配置坏而非用户关闭。 */
120
+ featuresBroken: boolean;
121
121
  };
122
122
 
123
123
  export const CONFIG_PATH = join(getAgentDir(), "extensions", "firecode", "config.jsonc");
@@ -153,6 +153,39 @@ function asRecord(value: unknown): Record<string, unknown> {
153
153
  : {};
154
154
  }
155
155
 
156
+ /** 嵌套对象也做键白名单:拼写错误必须报出来,不能静默回退默认值。 */
157
+ function rejectUnknownKeys(
158
+ record: Record<string, unknown>,
159
+ allowed: readonly string[],
160
+ field: string,
161
+ problems: string[],
162
+ ) {
163
+ for (const key of Object.keys(record))
164
+ if (!allowed.includes(key)) problems.push(`未知字段 ${field}.${key}`);
165
+ }
166
+
167
+ function booleanValue(value: unknown, field: string, fallback: boolean, problems: string[]): boolean {
168
+ if (value === undefined) return fallback;
169
+ if (typeof value === "boolean") return value;
170
+ problems.push(`${field} 必须是 true 或 false`);
171
+ return fallback;
172
+ }
173
+
174
+ function stringValue(value: unknown, field: string, problems: string[]): string | undefined {
175
+ if (typeof value === "string" && value) return value;
176
+ problems.push(`${field} 必须是非空字符串`);
177
+ return undefined;
178
+ }
179
+
180
+ function stringArray(value: unknown, field: string, problems: string[]): string[] {
181
+ if (value === undefined) return [];
182
+ if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || !item)) {
183
+ problems.push(`${field} 必须是非空字符串数组`);
184
+ return [];
185
+ }
186
+ return [...new Set(value)];
187
+ }
188
+
156
189
  function checkFeatures(features: Record<string, unknown>, problems: string[]): void {
157
190
  for (const [key, value] of Object.entries(features)) {
158
191
  if (!FEATURES.includes(key as Feature)) {
@@ -169,7 +202,6 @@ function checkFeatures(features: Record<string, unknown>, problems: string[]): v
169
202
  function checkKeys(keys: FireCodeKeys, presets: Record<string, Preset>, problems: string[]): void {
170
203
  const owners = new Map<string, string>([
171
204
  [keys.rename, "keys.rename"],
172
- [keys.cyclePreset, "keys.cyclePreset"],
173
205
  [keys.fast, "keys.fast"],
174
206
  ]);
175
207
  const declared = Object.entries(keys);
@@ -195,46 +227,135 @@ let cached: LoadedConfig | undefined;
195
227
  export function loadConfig(): LoadedConfig {
196
228
  if (cached) return cached;
197
229
 
198
- const problems: string[] = [];
199
- const raw = readFile(problems);
230
+ // 文件级与开关级问题阻断所有付费功能:开关写成字符串 "false" 时 `!== false` 仍会启用。
231
+ const blocking: string[] = [];
232
+ const raw = readFile(blocking);
200
233
  // features 省略表示沿用默认全开;只要显式写了,就必须是对象。
201
234
  // 非对象不能回退成 {},因为 {} 在入口语义里正是「全部启用」。
202
- const invalidFeatures = raw.features !== undefined && !isPlainObject(raw.features);
203
- if (invalidFeatures) problems.push("features 必须是对象");
204
- const features: Partial<Record<Feature, boolean>> = invalidFeatures
235
+ const featuresBroken = raw.features !== undefined && !isPlainObject(raw.features);
236
+ if (featuresBroken) blocking.push("features 必须是对象");
237
+ const features: Partial<Record<Feature, boolean>> = featuresBroken
205
238
  ? Object.fromEntries(FEATURES.map((feature) => [feature, false]))
206
239
  : asRecord(raw.features);
240
+ checkFeatures(features, blocking);
241
+
242
+ const problems = [...blocking];
243
+ for (const key of Object.keys(raw)) if (!SECTIONS.includes(key)) problems.push(`未知配置节 ${key}`);
207
244
  const rawKeys = asRecord(raw.keys);
208
- const presets = asRecord(raw.presets) as Record<string, Preset>;
245
+ rejectUnknownKeys(rawKeys, Object.keys(DEFAULT_KEYS), "keys", problems);
246
+ const presets = parsePresets(raw.presets, problems);
209
247
  const keys: FireCodeKeys = {
210
248
  rename: typeof rawKeys.rename === "string" ? rawKeys.rename : DEFAULT_KEYS.rename,
211
- cyclePreset:
212
- typeof rawKeys.cyclePreset === "string" ? rawKeys.cyclePreset : DEFAULT_KEYS.cyclePreset,
213
249
  fast: typeof rawKeys.fast === "string" ? rawKeys.fast : DEFAULT_KEYS.fast,
214
250
  };
215
- checkFeatures(features, problems);
216
251
  checkKeys(keys, presets, problems);
217
- // review 写成字符串/数组/null 或缺字段时不能静默补齐:会拿用户未选择的模型真实发起审查。
218
- const reviewProblems: string[] = [];
219
- if (raw.review !== undefined && !isPlainObject(raw.review))
220
- reviewProblems.push("review 必须是对象");
221
- const review = parseReviewConfig(asRecord(raw.review), reviewProblems);
222
- if (raw.review !== undefined || features.review !== false) problems.push(...reviewProblems);
223
- // master 同理:角色表错误会拿错模型真实发起 Worker,不能静默当空对象。
224
- if (raw.master !== undefined && !isPlainObject(raw.master))
225
- problems.push("master 必须是对象");
226
- const master = parseMasterConfig(asRecord(raw.master), problems);
227
- // watcher 同理:缺节或模型有误时功能拒绝启动,静默回退会拿用户没配的模型真实发起观察。
228
- const watcherProblems: string[] = [];
229
- if (raw.watcher !== undefined && !isPlainObject(raw.watcher))
230
- watcherProblems.push("watcher 必须是对象");
231
- const watcher = parseWatcherConfig(asRecord(raw.watcher), watcherProblems);
232
- if (raw.watcher !== undefined || features.watcher !== false) problems.push(...watcherProblems);
233
-
234
- cached = { config: { features, keys, presets, review, master, watcher }, problems };
252
+
253
+ // review / master / watcher 有问题时对应功能拒绝启动:静默补齐会拿用户没选的模型真实发起调用。
254
+ // 节内问题只在功能开启时进全局警告。
255
+ const section = <T>(
256
+ name: "review" | "master" | "watcher",
257
+ label: string,
258
+ parse: (record: Record<string, unknown>, problems: string[]) => T,
259
+ incomplete: (config: T) => string | undefined,
260
+ ): { config: T; verdict: Section<T> } => {
261
+ const own: string[] = [];
262
+ if (raw[name] !== undefined && !isPlainObject(raw[name])) own.push(`${name} 必须是对象`);
263
+ const config = parse(asRecord(raw[name]), own);
264
+ if (features[name] !== false) problems.push(...own);
265
+ const reasons = [...blocking, ...own];
266
+ const missing = reasons.length ? undefined : incomplete(config);
267
+ if (missing) reasons.push(missing);
268
+ return { config, verdict: reasons.length ? { error: `${label}配置有问题,已停止:${reasons.join(";")}` } : { config } };
269
+ };
270
+ const review = section("review", "fire-review ", parseReviewConfig, (config) =>
271
+ config.advisor.model && config.reviewers.length ? undefined : "请显式完整配置 review");
272
+ const master = section("master", "Master ", parseMasterConfig, (config) =>
273
+ config.roles.length ? undefined : "请在 master.roles 至少配置一个角色");
274
+ const watcher = section("watcher", "观察员", parseWatcherConfig, () => undefined);
275
+
276
+ cached = {
277
+ config: { features, keys, presets, review: review.config, master: master.config, watcher: watcher.config },
278
+ problems,
279
+ review: review.verdict,
280
+ master: master.verdict,
281
+ watcher: watcher.verdict,
282
+ featuresBroken,
283
+ };
235
284
  return cached;
236
285
  }
237
286
 
287
+ // ---- 模型原子 ----
288
+
289
+ const THINKING_LEVELS = new Set<ThinkingLevelValue>([
290
+ "off",
291
+ "minimal",
292
+ "low",
293
+ "medium",
294
+ "high",
295
+ "xhigh",
296
+ "max",
297
+ ]);
298
+ const FALLBACK_THINKING: ThinkingLevelValue = "medium";
299
+
300
+ /**
301
+ * 解析 "provider/model/thinking":按最后一个斜杠切出思考档,前半必须仍是 provider/model。
302
+ * 任何位置的模型配置都走这里,解析失败只记录问题并留空模型,让上层拒绝启动。
303
+ * 每个字段只报一条问题,且必带目标形状——两段式旧写法会同时踩中两项校验,逐项报错说不出该改成什么。
304
+ * 旧的分字段与两段式写法一律拒绝、不做兼容:兼容层会把三种写法固化成三套事实源。
305
+ */
306
+ export function parseModelAtom(value: unknown, field: string, problems: string[]): ModelAtom {
307
+ const shape = `${field} 必须是“provider/model/thinking”字符串`;
308
+ if (typeof value !== "string" || !value) {
309
+ problems.push(shape);
310
+ return { model: "", thinking: FALLBACK_THINKING };
311
+ }
312
+ const slash = value.lastIndexOf("/");
313
+ const model = slash > 0 ? value.slice(0, slash) : "";
314
+ const thinking = slash > 0 ? value.slice(slash + 1) : value;
315
+ const providerSlash = model.indexOf("/");
316
+ const valid = THINKING_LEVELS.has(thinking as ThinkingLevelValue);
317
+ const faults: string[] = [];
318
+ if (providerSlash <= 0 || providerSlash === model.length - 1)
319
+ faults.push(`模型段不是 provider/model:${model || value}`);
320
+ if (!valid) faults.push(`思考档无效:${thinking}`);
321
+ if (faults.length) problems.push(`${shape}(${faults.join(";")})`);
322
+ return { model, thinking: valid ? (thinking as ThinkingLevelValue) : FALLBACK_THINKING };
323
+ }
324
+
325
+ // ---- presets 节 ----
326
+
327
+ const PRESET_KEYS = ["model", "tools", "instructions", "key"] as const;
328
+
329
+ function parsePresets(value: unknown, problems: string[]): Record<string, Preset> {
330
+ if (value === undefined) return {};
331
+ if (!isPlainObject(value)) {
332
+ problems.push("presets 必须是对象");
333
+ return {};
334
+ }
335
+ return Object.fromEntries(
336
+ Object.entries(value).map(([name, raw]) => [name, parsePreset(raw, `presets.${name}`, problems)]),
337
+ );
338
+ }
339
+
340
+ /** preset 只在写了 model 时切模型;其余字段与模型原子互不依赖。 */
341
+ function parsePreset(value: unknown, field: string, problems: string[]): Preset {
342
+ if (!isPlainObject(value)) {
343
+ problems.push(`${field} 必须是对象`);
344
+ return {};
345
+ }
346
+ rejectUnknownKeys(value, PRESET_KEYS, field, problems);
347
+ return {
348
+ ...(value.model === undefined
349
+ ? {}
350
+ : { model: parseModelAtom(value.model, `${field}.model`, problems) }),
351
+ ...(value.tools === undefined ? {} : { tools: stringArray(value.tools, `${field}.tools`, problems) }),
352
+ ...(value.instructions === undefined
353
+ ? {}
354
+ : { instructions: stringValue(value.instructions, `${field}.instructions`, problems) }),
355
+ ...(value.key === undefined ? {} : { key: stringValue(value.key, `${field}.key`, problems) }),
356
+ };
357
+ }
358
+
238
359
  // ---- review 节 ----
239
360
 
240
361
  const REVIEW_KEYS = new Set([
@@ -247,16 +368,6 @@ const REVIEW_KEYS = new Set([
247
368
  "language",
248
369
  ]);
249
370
  const DEFAULT_TOOLS = ["read", "grep", "find", "ls", "bash"];
250
- const EMPTY_REVIEW_MODEL: ReviewModel = { model: "", thinking: "medium" };
251
- const THINKING_LEVELS = new Set<ThinkingLevelValue>([
252
- "off",
253
- "minimal",
254
- "low",
255
- "medium",
256
- "high",
257
- "xhigh",
258
- "max",
259
- ]);
260
371
  const LANGUAGES = new Set<Language>(["zh", "en"]);
261
372
 
262
373
  /** 导出供测试:严格拒绝未知字段(含嵌套),类型错误一律记录而非静默回退。 */
@@ -267,9 +378,11 @@ export function parseReviewConfig(raw: Record<string, unknown>, problems: string
267
378
  ? "review.background 已随审查子进程层删除,请直接移除该键"
268
379
  : `未知字段 review.${key}`);
269
380
  }
381
+ // advisor 与 reviewers 缺失由模型原子解析自己报形状,不再叠一条泛化的“必须显式配置”。
270
382
  for (const key of REVIEW_KEYS)
271
- if (!(key in raw)) problems.push(`review.${key} 必须显式配置`);
272
- const advisor = reviewModel(raw.advisor, "review.advisor", EMPTY_REVIEW_MODEL, problems);
383
+ if (key !== "advisor" && key !== "reviewers" && !(key in raw))
384
+ problems.push(`review.${key} 必须显式配置`);
385
+ const advisor = parseModelAtom(raw.advisor, "review.advisor", problems);
273
386
  const reviewers = reviewModels(raw.reviewers, problems);
274
387
  return {
275
388
  advisor,
@@ -282,46 +395,12 @@ export function parseReviewConfig(raw: Record<string, unknown>, problems: string
282
395
  };
283
396
  }
284
397
 
285
- /** 嵌套对象也做键白名单:拼写错误必须报出来,不能静默回退默认值。 */
286
- function rejectUnknownKeys(
287
- record: Record<string, unknown>,
288
- allowed: readonly string[],
289
- field: string,
290
- problems: string[],
291
- ) {
292
- for (const key of Object.keys(record))
293
- if (!allowed.includes(key)) problems.push(`未知字段 ${field}.${key}`);
294
- }
295
-
296
- function reviewModel(
297
- value: unknown,
298
- field: string,
299
- fallback: ReviewModel,
300
- problems: string[],
301
- ): ReviewModel {
302
- if (value === undefined) return fallback;
303
- const record = asRecord(value);
304
- rejectUnknownKeys(record, ["model", "thinking"], field, problems);
305
- if (!record.model || typeof record.model !== "string") {
306
- problems.push(`${field}.model 必须是非空字符串`);
307
- return fallback;
308
- }
309
- const thinking =
310
- typeof record.thinking === "string" && THINKING_LEVELS.has(record.thinking as ThinkingLevelValue)
311
- ? (record.thinking as ThinkingLevelValue)
312
- : fallback.thinking;
313
- if (thinking !== record.thinking) problems.push(`${field}.thinking 值无效`);
314
- return { model: record.model, thinking };
315
- }
316
-
317
- function reviewModels(value: unknown, problems: string[]): ReviewModel[] {
398
+ function reviewModels(value: unknown, problems: string[]): ModelAtom[] {
318
399
  if (!Array.isArray(value) || value.length === 0 || value.length > 5) {
319
- if (value !== undefined) problems.push("review.reviewers 必须包含 1–5 个模型");
400
+ problems.push("review.reviewers 必须包含 1–5 个模型原子");
320
401
  return [];
321
402
  }
322
- return value.map((item, index) =>
323
- reviewModel(item, `review.reviewers[${index}]`, EMPTY_REVIEW_MODEL, problems),
324
- );
403
+ return value.map((item, index) => parseModelAtom(item, `review.reviewers[${index}]`, problems));
325
404
  }
326
405
 
327
406
  function reviewInt(
@@ -364,104 +443,51 @@ export function parseMasterConfig(raw: Record<string, unknown>, problems: string
364
443
  if (raw.roles === undefined)
365
444
  return { roles: [], workerExcludeExtensions: exclusions, autoActivate };
366
445
  if (!isPlainObject(raw.roles) || Object.keys(raw.roles).length === 0) {
367
- problems.push("master.roles 必须是至少包含一个固定角色的对象");
446
+ problems.push("master.roles 必须是至少包含一个角色的对象");
368
447
  return { roles: [], workerExcludeExtensions: exclusions, autoActivate };
369
448
  }
370
- const configured = raw.roles;
371
- for (const role of Object.keys(configured))
372
- if (!MASTER_ROLES.includes(role as MasterRoleName))
373
- problems.push(`未知角色 master.roles.${role},可用:${MASTER_ROLES.join(" / ")}`);
374
- const roles = MASTER_ROLES.flatMap((role) =>
375
- Object.hasOwn(configured, role)
376
- ? [masterRole(configured[role], `master.roles.${role}`, role, problems)]
377
- : []);
449
+ const roles = Object.entries(raw.roles).map(([role, value]) =>
450
+ masterRole(value, `master.roles.${role}`, role, problems));
378
451
  return { roles, workerExcludeExtensions: exclusions, autoActivate };
379
452
  }
380
453
 
381
- function booleanValue(value: unknown, field: string, fallback: boolean, problems: string[]): boolean {
382
- if (value === undefined) return fallback;
383
- if (typeof value === "boolean") return value;
384
- problems.push(`${field} 必须是 true 或 false`);
385
- return fallback;
386
- }
387
-
388
- function stringArray(value: unknown, field: string, problems: string[]): string[] {
389
- if (value === undefined) return [];
390
- if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || !item)) {
391
- problems.push(`${field} 必须是非空字符串数组`);
392
- return [];
393
- }
394
- return [...new Set(value)];
395
- }
396
-
397
- function masterRole(
398
- value: unknown,
399
- field: string,
400
- role: MasterRoleName,
401
- problems: string[],
402
- ): MasterRole {
454
+ function masterRole(value: unknown, field: string, role: string, problems: string[]): MasterRole {
403
455
  const record = asRecord(value);
404
456
  rejectUnknownKeys(record, ["model", "use", "fallback"], field, problems);
405
- const atom = masterModelAtom(record.model, `${field}.model`, problems);
457
+ const atom = parseModelAtom(record.model, `${field}.model`, problems);
406
458
  const use = typeof record.use === "string" && record.use ? record.use : "";
407
459
  if (!use) problems.push(`${field}.use 必须是非空字符串`);
408
460
  const fallback = masterFallback(record.fallback, `${field}.fallback`, problems);
409
461
  return { role, ...atom, use, fallback };
410
462
  }
411
463
 
412
- function masterFallback(value: unknown, field: string, problems: string[]): MasterModelAtom[] {
464
+ function masterFallback(value: unknown, field: string, problems: string[]): ModelAtom[] {
413
465
  if (value === undefined) return [];
414
466
  if (!Array.isArray(value) || value.length > 2) {
415
467
  problems.push(`${field} 必须是至多 2 项的数组`);
416
468
  return [];
417
469
  }
418
- return value.map((item, index) => masterModelAtom(item, `${field}[${index}]`, problems));
419
- }
420
-
421
- function masterModelAtom(value: unknown, field: string, problems: string[]): MasterModelAtom {
422
- if (typeof value !== "string" || !value) {
423
- problems.push(`${field} 必须是“供应商/模型/思考档”字符串`);
424
- return { model: "", thinking: "medium" };
425
- }
426
- const slash = value.lastIndexOf("/");
427
- const model = slash > 0 ? value.slice(0, slash) : "";
428
- const thinking = slash > 0 ? value.slice(slash + 1) : value;
429
- if (!THINKING_LEVELS.has(thinking as ThinkingLevelValue))
430
- problems.push(`${field} 思考档无效:${thinking}`);
431
- const providerSlash = model.indexOf("/");
432
- if (providerSlash <= 0 || providerSlash === model.length - 1)
433
- problems.push(`${field} 模型无效:必须是 provider/model`);
434
- return {
435
- model,
436
- thinking: THINKING_LEVELS.has(thinking as ThinkingLevelValue)
437
- ? thinking as ThinkingLevelValue
438
- : "medium",
439
- };
470
+ return value.map((item, index) => parseModelAtom(item, `${field}[${index}]`, problems));
440
471
  }
441
472
 
442
473
  // ---- watcher 节 ----
443
474
 
444
- const WATCHER_KEYS = ["enabled", "model", "thinking", "context"] as const;
475
+ const WATCHER_KEYS = ["enabled", "model", "context"] as const;
445
476
  const WATCHER_CONTEXTS = new Set<WatcherContext>(["minimal", "full"]);
446
477
 
447
- /** 导出供测试:model/thinking 必填,enabled 默认 true、context 默认 minimal。 */
478
+ /** 导出供测试:model 必填(含思考档),enabled 默认 true、context 默认 minimal。 */
448
479
  export function parseWatcherConfig(raw: Record<string, unknown>, problems: string[]): WatcherConfig {
449
480
  rejectUnknownKeys(raw, WATCHER_KEYS, "watcher", problems);
450
481
  const enabled = booleanValue(raw.enabled, "watcher.enabled", true, problems);
451
- const model = typeof raw.model === "string" && raw.model ? raw.model : "";
452
- if (!model) problems.push("watcher.model 必须显式配置为非空字符串");
453
- let thinking: ThinkingLevelValue | undefined;
454
- if (raw.thinking === undefined) problems.push("watcher.thinking 必须显式配置");
455
- else if (typeof raw.thinking === "string" && THINKING_LEVELS.has(raw.thinking as ThinkingLevelValue))
456
- thinking = raw.thinking as ThinkingLevelValue;
457
- else problems.push("watcher.thinking 值无效");
482
+ // 模型原子必填:缺失或写错时留空模型并记录问题,观察员据此拒绝启动。
483
+ const atom = parseModelAtom(raw.model, "watcher.model", problems);
458
484
  let context: WatcherContext = "minimal";
459
485
  if (raw.context !== undefined) {
460
486
  if (typeof raw.context === "string" && WATCHER_CONTEXTS.has(raw.context as WatcherContext))
461
487
  context = raw.context as WatcherContext;
462
488
  else problems.push("watcher.context 必须是 minimal 或 full");
463
489
  }
464
- return { enabled, model, thinking: thinking ?? "low", context };
490
+ return { enabled, ...atom, context };
465
491
  }
466
492
 
467
493
  function reviewLanguage(value: unknown, problems: string[]): Language {
package/deliver.ts CHANGED
@@ -1,32 +1,119 @@
1
1
  /**
2
2
  * 统一投递入口(Master 事件与观察员发言共用):宿主流式中投自定义卡片、经
3
- * steer 队列在句缝送达;会话歇透时改走 sendUserMessage 前门唤起——宿主的
4
- * triggerTurn 唤醒会跳过 before_agent_start(上游缺陷,#33),前门唤醒自带
5
- * 完整开跑仪式,系统提示注入不随回合抖动。
3
+ * steer 队列在句缝送达;会话歇透时改走 sendUserMessage 前门唤起(只告知不唤醒的
4
+ * 结果改走 inform,歇透时直接追加)。唤醒走前门而非 triggerTurn 的原因(#33)与回合中不得立即追加(#28)见根 AGENTS.md 硬约束。
5
+ * 宿主的扩展 sendUserMessage 返回 void、不等回合:以宿主记录这条消息为送达,没进回合就改走 steer 补投(tests/delivery-contract.test.ts 钉住)。
6
6
  *
7
7
  * 忙闲判断与发送必须在同一事件循环节拍内完成,两者之间禁止 await:会话落定
8
8
  * 是下一节拍的事件,同节拍读到的忙闲不会骑墙;宿主在回合结束前清空 steer
9
9
  * 队列,忙时入队的消息以同回合续跑送达,不会沦为唤醒者。
10
10
  */
11
11
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
12
+ import { textOf } from "./format.js";
12
13
 
13
- export interface Envelope<T> {
14
+ /**
15
+ * 机器消息的信封是唯一事实源:模型上下文里的来源标记、卡片与折叠界面的识别都从这里来。
16
+ * 一条消息可含多个信封(并发落定的事件各占一个);整条文本恰好由信封构成才算机器消息。
17
+ */
18
+ export const ENVELOPE_TAGS = ["firecode_master_event", "firecode_watcher", "firecode_review"] as const;
19
+ export type EnvelopeTag = (typeof ENVELOPE_TAGS)[number];
20
+
21
+ export interface ParsedEnvelope {
22
+ tag: EnvelopeTag;
23
+ body: string;
24
+ }
25
+
26
+ export function wrapEnvelope(tag: EnvelopeTag, body: string): string {
27
+ return `<${tag}>\n${body}\n</${tag}>`;
28
+ }
29
+
30
+ const ENVELOPE = new RegExp(`<(${ENVELOPE_TAGS.join("|")})>\\n([\\s\\S]*?)\\n</\\1>\\s*`, "uy");
31
+
32
+ export function parseEnvelopes(text: string): ParsedEnvelope[] | undefined {
33
+ const source = text.trim();
34
+ const found: ParsedEnvelope[] = [];
35
+ let end = 0;
36
+ for (;;) {
37
+ ENVELOPE.lastIndex = end;
38
+ const match = ENVELOPE.exec(source);
39
+ if (!match) break;
40
+ found.push({ tag: match[1] as EnvelopeTag, body: match[2] });
41
+ end = ENVELOPE.lastIndex;
42
+ }
43
+ return found.length && end === source.length ? found : undefined;
44
+ }
45
+
46
+ export interface Delivery {
14
47
  customType: string;
48
+ /** 已按信封格式包好的正文,同时是卡片渲染的唯一数据源。 */
15
49
  content: string;
16
- details?: T;
17
50
  }
18
51
 
19
- export async function deliver<T>(
52
+ /**
53
+ * 统一投递,resolve 即“已交给指挥官”:
54
+ * - 主回合在跑:卡片进 steer 队列,入队即交付;
55
+ * - 主回合歇透:前门 sendUserMessage 唤起,见 wake。
56
+ */
57
+ export async function deliver(
20
58
  pi: ExtensionAPI,
21
59
  ctx: ExtensionContext,
22
- envelope: Envelope<T>,
60
+ envelope: Delivery,
23
61
  ): Promise<void> {
24
- if (ctx.isIdle?.() === true) {
25
- await pi.sendUserMessage(envelope.content);
26
- return;
27
- }
28
- pi.sendMessage<T>(
29
- { customType: envelope.customType, content: envelope.content, display: true, details: envelope.details },
62
+ if (ctx.isIdle()) return wake(pi, envelope);
63
+ steer(pi, envelope);
64
+ }
65
+
66
+ /**
67
+ * 告知不唤醒:指挥官没在等的结果(用户在子代理视图里直接派的运行)。主回合在跑时与 deliver 相同,经 steer 队列句缝送达;
68
+ * 主会话歇透时以不带 triggerTurn 的 sendMessage 追加为会话记录,下一回合自然进上下文,resolve 即已写入。
69
+ * 歇透时追加是安全的(核对宿主 AgentSession.sendCustomMessage):不在流式中且不触发回合时,宿主当场写会话树并刷新
70
+ * 上下文,追加在最后一条消息之后,不夹进工具调用与结果之间,已有前缀不变、提示词缓存不重写。#28 的快照分叉只发生在
71
+ * 回合进行中立即追加;这里也不经 triggerTurn,与 #33 无关。
72
+ */
73
+ export async function inform(
74
+ pi: ExtensionAPI,
75
+ ctx: ExtensionContext,
76
+ envelope: Delivery,
77
+ ): Promise<void> {
78
+ if (!ctx.isIdle()) return steer(pi, envelope);
79
+ pi.sendMessage({ customType: envelope.customType, content: envelope.content, display: true });
80
+ }
81
+
82
+ function steer(pi: ExtensionAPI, envelope: Delivery): void {
83
+ pi.sendMessage(
84
+ { customType: envelope.customType, content: envelope.content, display: true },
30
85
  { deliverAs: "steer" },
31
86
  );
32
87
  }
88
+
89
+ /**
90
+ * 前门唤起按事实确认送达。宿主的扩展 sendUserMessage 返回 void、不等回合;开回合前被拒时只走宿主
91
+ * emitError,扩展订阅不到。所以等下一个回合的第一条消息:
92
+ * - 正是这条信封(role=user、正文原样):它开启了这一回合,送达;
93
+ * - 是别的(用户自己发的话、其他扩展唤起的消息):这条没进来,会话此刻正忙,同一节拍改走 steer 补投,句缝送达。
94
+ * 两种情况下 resolve 时指挥官回合都已在跑,Master 扣在飞数不会让 busy.ts 误报歇下。
95
+ * 之后再没有任何回合时投递保持未完成:宁可不歇下,也不误报歇下;没有计时器。
96
+ * 不以任意 agent_start 为送达(宿主拒绝后用户自己开的回合会被误认,事件被 ack 却没进上下文);不加超时(超时后仍判断不了是否送达)。
97
+ */
98
+ function wake(pi: ExtensionAPI, envelope: Delivery): Promise<void> {
99
+ const delivered = Promise.withResolvers<void>();
100
+ let running = false;
101
+ const unsubscribe = [
102
+ pi.on("agent_start", () => {
103
+ running = true;
104
+ }),
105
+ pi.on("message_start", ({ message }) => {
106
+ if (!running) return;
107
+ for (const off of unsubscribe) off();
108
+ if (message.role !== "user" || textOf(message.content) !== envelope.content) steer(pi, envelope);
109
+ delivered.resolve();
110
+ }),
111
+ ];
112
+ try {
113
+ pi.sendUserMessage(envelope.content);
114
+ } catch (error) {
115
+ for (const off of unsubscribe) off();
116
+ throw error;
117
+ }
118
+ return delivered.promise;
119
+ }