@tekmidian/pai 0.48.0 → 0.50.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 (107) hide show
  1. package/dist/{aibroker-client-D5NT0S1N.mjs → aibroker-client-Bu72yPKj.mjs} +1 -1
  2. package/dist/{aibroker-client-Bn9SnGMa.mjs → aibroker-client-CDIXmq-e.mjs} +1 -1
  3. package/dist/{aibroker-client-Bn9SnGMa.mjs.map → aibroker-client-CDIXmq-e.mjs.map} +1 -1
  4. package/dist/{chain-CNSa0A3G.mjs → chain-BBbVbWVv.mjs} +4 -4
  5. package/dist/{chain-CNSa0A3G.mjs.map → chain-BBbVbWVv.mjs.map} +1 -1
  6. package/dist/cli/index.mjs +14 -14
  7. package/dist/cli/program.mjs +14 -14
  8. package/dist/config-Bn9jpblO.mjs +653 -0
  9. package/dist/config-Bn9jpblO.mjs.map +1 -0
  10. package/dist/{config-DxMZjpvY.mjs → config-DYXARgk8.mjs} +180 -151
  11. package/dist/config-DYXARgk8.mjs.map +1 -0
  12. package/dist/config-RjeVGJ7K.mjs +3 -0
  13. package/dist/{context-handover-cache-B9NKSjwR.mjs → context-handover-cache-yOuK9RIk.mjs} +5 -5
  14. package/dist/{context-handover-cache-B9NKSjwR.mjs.map → context-handover-cache-yOuK9RIk.mjs.map} +1 -1
  15. package/dist/daemon/index.mjs +13 -12
  16. package/dist/daemon/index.mjs.map +1 -1
  17. package/dist/{daemon-DZ4Q0jrs.mjs → daemon-CBTQYtHF.mjs} +49 -30
  18. package/dist/daemon-CBTQYtHF.mjs.map +1 -0
  19. package/dist/daemon-Vf7pHnjR.mjs +25 -0
  20. package/dist/daemon-mcp/index.mjs +186 -23
  21. package/dist/daemon-mcp/index.mjs.map +1 -1
  22. package/dist/factory-Ba8j9K7q.mjs +3 -0
  23. package/dist/{factory-DNYAqjH0.mjs → factory-Bt7TWQje.mjs} +3 -3
  24. package/dist/{factory-DNYAqjH0.mjs.map → factory-Bt7TWQje.mjs.map} +1 -1
  25. package/dist/{fallback-IeR2FrFn.mjs → fallback-CIGspCSi.mjs} +260 -10
  26. package/dist/fallback-CIGspCSi.mjs.map +1 -0
  27. package/dist/hooks/capture-all-events.mjs +80 -8
  28. package/dist/hooks/capture-all-events.mjs.map +4 -4
  29. package/dist/hooks/context-compression-hook.mjs +54 -39
  30. package/dist/hooks/context-compression-hook.mjs.map +4 -4
  31. package/dist/hooks/post-compact-inject.mjs +54 -39
  32. package/dist/hooks/post-compact-inject.mjs.map +4 -4
  33. package/dist/hooks/route-agents-to-worker.mjs +204 -109
  34. package/dist/hooks/route-agents-to-worker.mjs.map +4 -4
  35. package/dist/hooks/whisper-rules.mjs +7613 -9
  36. package/dist/hooks/whisper-rules.mjs.map +4 -4
  37. package/dist/hooks/worker-proxy.mjs +204 -109
  38. package/dist/hooks/worker-proxy.mjs.map +4 -4
  39. package/dist/hooks/worker-status-line.mjs +211 -116
  40. package/dist/hooks/worker-status-line.mjs.map +4 -4
  41. package/dist/hooks/worker-supervision.mjs +208 -113
  42. package/dist/hooks/worker-supervision.mjs.map +4 -4
  43. package/dist/{kg-entity-BSjM5xZh.mjs → kg-entity-BSW7ra8i.mjs} +1 -1
  44. package/dist/{kg-entity-BSjM5xZh.mjs.map → kg-entity-BSW7ra8i.mjs.map} +1 -1
  45. package/dist/main-resolver-BTastpp4.mjs +7 -0
  46. package/dist/{main-resolver-DvYu9jbl.mjs → main-resolver-BmZPCmgj.mjs} +3 -3
  47. package/dist/{main-resolver-DvYu9jbl.mjs.map → main-resolver-BmZPCmgj.mjs.map} +1 -1
  48. package/dist/{planner-Cn8HmT7l.mjs → planner-BiH9msqq.mjs} +6 -6
  49. package/dist/{planner-Cn8HmT7l.mjs.map → planner-BiH9msqq.mjs.map} +1 -1
  50. package/dist/{program-Bgv1lFFA.mjs → program-BX67-4I2.mjs} +536 -260
  51. package/dist/program-BX67-4I2.mjs.map +1 -0
  52. package/dist/query-feedback-Bm6XvCmg.mjs +3 -0
  53. package/dist/{query-feedback-CYyOGi3y.mjs → query-feedback-CTs_jx6i.mjs} +1 -1
  54. package/dist/{query-feedback-CYyOGi3y.mjs.map → query-feedback-CTs_jx6i.mjs.map} +1 -1
  55. package/dist/{run-Cls0YGgV.mjs → run-TChOnMO_.mjs} +1388 -1183
  56. package/dist/run-TChOnMO_.mjs.map +1 -0
  57. package/dist/{server-Ch99xt5K.mjs → server-R9UkCn3v.mjs} +42 -3
  58. package/dist/server-R9UkCn3v.mjs.map +1 -0
  59. package/dist/session-keepalive-BuG3wvik.mjs +579 -0
  60. package/dist/session-keepalive-BuG3wvik.mjs.map +1 -0
  61. package/dist/skills/VaultContext/SKILL.md +1 -1
  62. package/dist/{state-DnYGRmS7.mjs → state-Ca0ltLgI.mjs} +1 -1
  63. package/dist/{state-PJrgfDLW.mjs → state-DwrsOoMi.mjs} +1 -1
  64. package/dist/{state-PJrgfDLW.mjs.map → state-DwrsOoMi.mjs.map} +1 -1
  65. package/dist/{tools-B77WHeVZ.mjs → tools-BEqiJt_j.mjs} +13 -13
  66. package/dist/{tools-B77WHeVZ.mjs.map → tools-BEqiJt_j.mjs.map} +1 -1
  67. package/dist/{tools-NnR_n-Ja.mjs → tools-BwsOTmjh.mjs} +2 -2
  68. package/dist/{wakeup-UrhiW-KU.mjs → wakeup-Yk5lQ0jb.mjs} +1 -1
  69. package/dist/{wakeup-UrhiW-KU.mjs.map → wakeup-Yk5lQ0jb.mjs.map} +1 -1
  70. package/dist/{work-queue-worker-BA5IDBwQ.mjs → work-queue-worker-B9zVWImP.mjs} +3 -3
  71. package/dist/{work-queue-worker-BA5IDBwQ.mjs.map → work-queue-worker-B9zVWImP.mjs.map} +1 -1
  72. package/dist/work-queue-worker-D-DnQ7oh.mjs +14 -0
  73. package/dist/{zettelkasten-BBjA7GcP.mjs → zettelkasten-DezbXghl.mjs} +2 -2
  74. package/dist/{zettelkasten-BBjA7GcP.mjs.map → zettelkasten-DezbXghl.mjs.map} +1 -1
  75. package/docs/cache-keepalive.md +138 -0
  76. package/docs/commands/README.md +7 -0
  77. package/docs/commands/audit.md +1 -0
  78. package/docs/commands/config.md +84 -0
  79. package/docs/commands/daemon.md +6 -0
  80. package/docs/commands/worker.md +37 -2
  81. package/docs/config.md +171 -0
  82. package/docs/model-catalogue.md +502 -0
  83. package/docs/provider-independence-details.md +1 -1
  84. package/docs/task-bus.md +1 -1
  85. package/docs/worker.md +33 -5
  86. package/docs/workers-config.md +105 -12
  87. package/package.json +1 -1
  88. package/src/hooks/ts/capture-all-events.test.ts +71 -0
  89. package/src/hooks/ts/capture-all-events.ts +10 -3
  90. package/src/hooks/ts/user-prompt/whisper-rules.test.ts +56 -1
  91. package/src/hooks/ts/user-prompt/whisper-rules.ts +24 -0
  92. package/statusline-command.sh +81 -6
  93. package/dist/config-DCu8THKi.mjs +0 -228
  94. package/dist/config-DCu8THKi.mjs.map +0 -1
  95. package/dist/config-DeqFqa1N.mjs +0 -3
  96. package/dist/config-DxMZjpvY.mjs.map +0 -1
  97. package/dist/daemon-CYpVy_Dw.mjs +0 -24
  98. package/dist/daemon-DZ4Q0jrs.mjs.map +0 -1
  99. package/dist/factory-47Qf_df7.mjs +0 -3
  100. package/dist/fallback-IeR2FrFn.mjs.map +0 -1
  101. package/dist/indexer-C5Le7XE7.mjs +0 -1
  102. package/dist/main-resolver-CnfnWa8C.mjs +0 -7
  103. package/dist/program-Bgv1lFFA.mjs.map +0 -1
  104. package/dist/query-feedback-C7rSfiXU.mjs +0 -3
  105. package/dist/run-Cls0YGgV.mjs.map +0 -1
  106. package/dist/server-Ch99xt5K.mjs.map +0 -1
  107. package/dist/work-queue-worker-DLM2wlqM.mjs +0 -14
@@ -1,119 +1,10 @@
1
1
  import { a as paiHomePath, o as resolvePaiFile, r as migratePaiFile } from "./pai-home-ChAOupZc.mjs";
2
- import { n as CONFIG_FILE } from "./config-DCu8THKi.mjs";
3
- import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { _ as writeYamlFileAtomic, d as readMainConfigRaw, f as writeMainConfigRaw, g as YamlStoreError, n as CONFIG_FILE, v as readJsonStrict, y as writeJsonAtomic } from "./config-Bn9jpblO.mjs";
3
+ import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs";
4
4
  import { homedir } from "node:os";
5
5
  import { dirname, join } from "node:path";
6
6
  import { Document, Scalar, parseDocument } from "yaml";
7
7
 
8
- //#region src/config/json-store.ts
9
- /**
10
- * json-store.ts — read/write JSON config files without destroying them
11
- *
12
- * The failure this exists to prevent:
13
- *
14
- * try { return JSON.parse(read(path)); } catch { return {}; }
15
- * ... later ...
16
- * write(path, JSON.stringify(ourData));
17
- *
18
- * An unreadable file becomes an empty object, and the next write makes that
19
- * permanent. Silently, exit code 0. This shape appeared three times in this
20
- * repo against three different files, and twice in AIBroker.
21
- *
22
- * The distinction that matters is between *missing* and *unreadable*:
23
- *
24
- * missing — legitimate first run. Start fresh; writing is safe.
25
- * unreadable — the file exists and we could not parse it. Those bytes are
26
- * the only copy of something. Never overwrite them.
27
- *
28
- * Collapsing the second into the first is the bug.
29
- *
30
- * NOT everything deserves this guard. For a transient buffer — an undelivered
31
- * message queue, a cache — starting fresh IS the correct recovery, and
32
- * refusing to write would disable the feature permanently. Use `writeJsonAtomic`
33
- * alone there: it still prevents a crash from truncating a good file, without
34
- * blocking recovery. Reserve `readJsonStrict` for data a user cannot rebuild.
35
- */
36
- /**
37
- * Read a JSON file, distinguishing "absent" from "damaged".
38
- *
39
- * @param path file to read
40
- * @param label how to name it to the user, e.g. "~/.claude.json"
41
- * @throws if the file exists but cannot be read or parsed
42
- */
43
- function readJsonStrict(path, label = path) {
44
- if (!existsSync(path)) return {};
45
- let raw;
46
- try {
47
- raw = readFileSync(path, "utf8");
48
- } catch (e) {
49
- throw new Error(`Could not read ${label}: ${e instanceof Error ? e.message : String(e)}\nRefusing to continue — writing now would replace its contents with ours alone.`);
50
- }
51
- try {
52
- return JSON.parse(raw);
53
- } catch (e) {
54
- throw new Error(`${label} exists but is not valid JSON: ${e instanceof Error ? e.message : String(e)}\nRefusing to continue — overwriting it would destroy whatever it holds.\nRepair the file, or move it aside and re-run this command.`);
55
- }
56
- }
57
- /**
58
- * Leaf values that mean "type word", not data.
59
- *
60
- * A config whose top-level keys map to bare type words is a serialized schema,
61
- * not a configuration — the exact shape found over the live config on
62
- * 2026-09-18 (399 bytes: `socketPath: string`, `indexIntervalSecs: int`, …).
63
- * No real config carries three of these at its top level; a dump carries
64
- * nothing else.
65
- */
66
- const SCHEMA_TYPE_WORDS = new Set([
67
- "string",
68
- "int",
69
- "integer",
70
- "number",
71
- "bool",
72
- "boolean",
73
- "float",
74
- "double",
75
- "object",
76
- "array",
77
- "any",
78
- "unknown"
79
- ]);
80
- function schemaDumpHits(data) {
81
- let hits = 0;
82
- for (const v of Object.values(data)) if (typeof v === "string" && SCHEMA_TYPE_WORDS.has(v.trim())) hits += 1;
83
- return hits;
84
- }
85
- /**
86
- * Write JSON without risking the existing file.
87
- *
88
- * Keeps a `.bak-pai` copy of the previous contents, then writes to a temp file
89
- * and renames. Rename is atomic within a filesystem, so a crash mid-write
90
- * leaves the original intact rather than truncated — which is how these files
91
- * become corrupt in the first place.
92
- */
93
- function writeJsonAtomic(path, data, opts = {}) {
94
- const { backup = true, label = path } = opts;
95
- if (schemaDumpHits(data) >= 3) throw new Error(`${label}: refusing to write — the object looks like a type schema, not values (several top-level keys map to bare type words). That is the corruption signature of 2026-09-18: the caller serialized the schema instead of the config. Nothing was written.`);
96
- const serialized = JSON.stringify(data, null, 2) + "\n";
97
- const dir = dirname(path);
98
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
99
- if (backup && existsSync(path)) try {
100
- copyFileSync(path, `${path}.bak-pai`);
101
- } catch (e) {
102
- throw new Error(`Could not back up ${label}: ${e instanceof Error ? e.message : String(e)}\nRefusing to write without a backup.`);
103
- }
104
- const tmp = `${path}.tmp-pai-${process.pid}`;
105
- try {
106
- writeFileSync(tmp, serialized, "utf8");
107
- renameSync(tmp, path);
108
- } catch (e) {
109
- try {
110
- if (existsSync(tmp)) unlinkSync(tmp);
111
- } catch {}
112
- throw new Error(`Failed to write ${label}: ${e instanceof Error ? e.message : String(e)}\nThe original is unchanged.`);
113
- }
114
- }
115
-
116
- //#endregion
117
8
  //#region src/utils/model-window.ts
118
9
  /**
119
10
  * model-window.ts — facts derivable from a bare model id.
@@ -321,11 +212,21 @@ function parseWorkersYamlDocument(doc, yamlPath) {
321
212
  const provider = typeof target === "string" ? target.split("/")[0] : target.provider;
322
213
  const alias = typeof target === "string" ? target.split("/")[1] : void 0;
323
214
  if (provider !== void 0) {
324
- if (provider !== ANTHROPIC_NATIVE && !providers[provider]) withLine(doc, ["classes", cls], yamlPath, new WorkersConfigError(`classes.${cls}: no provider named "${provider}" (configured: ${Object.keys(providers).join(", ") || "(none)"})`));
325
- if (alias !== void 0 && !isModelCapability(alias)) withLine(doc, ["classes", cls], yamlPath, new WorkersConfigError(`classes.${cls}: unknown model role "${alias}" (from: ${MODEL_CAPABILITIES.join(", ")})`));
215
+ const native = provider === ANTHROPIC_NATIVE;
216
+ if (!native && !providers[provider]) withLine(doc, ["classes", cls], yamlPath, new WorkersConfigError(`classes.${cls}: no provider named "${provider}" (configured: ${Object.keys(providers).join(", ") || "(none)"})`));
217
+ if (alias !== void 0) {
218
+ if (!isModelCapability(alias)) withLine(doc, ["classes", cls], yamlPath, new WorkersConfigError(`classes.${cls}: "${alias}" is not a valid capability name (must match ^[a-z][a-z0-9-]*$)`));
219
+ else if (!native && providers[provider] && alias !== "default" && !providers[provider].models[alias]) withLine(doc, ["classes", cls], yamlPath, new WorkersConfigError(`classes.${cls}: provider "${provider}" has no "${alias}" model configured`));
220
+ }
326
221
  }
327
222
  }
328
223
  }
224
+ let capabilities = {};
225
+ if (r.capabilities !== void 0) try {
226
+ capabilities = parseCapabilitiesValue(r.capabilities, "capabilities");
227
+ } catch (e) {
228
+ withLine(doc, ["capabilities"], yamlPath, e);
229
+ }
329
230
  let mcpSets = {};
330
231
  if (r.mcp_sets !== void 0) try {
331
232
  mcpSets = parseMcpSetsValue(r.mcp_sets, "mcp_sets");
@@ -339,6 +240,7 @@ function parseWorkersYamlDocument(doc, yamlPath) {
339
240
  active,
340
241
  providers,
341
242
  classes,
243
+ capabilities,
342
244
  mcpSets,
343
245
  nativeModels
344
246
  };
@@ -392,6 +294,8 @@ function syncWorkersYamlDocument(doc, before, after) {
392
294
  for (const name of Object.keys(before.providers)) if (!(name in after.providers)) doc.deleteIn(["providers", name]);
393
295
  for (const [cls, target] of Object.entries(after.classes)) if (!shallowEqual(before.classes[cls], target)) doc.setIn(["classes", cls], target);
394
296
  for (const cls of Object.keys(before.classes)) if (!(cls in after.classes)) doc.deleteIn(["classes", cls]);
297
+ for (const [cap, prefs] of Object.entries(after.capabilities)) if (!shallowEqual(before.capabilities[cap], prefs)) doc.setIn(["capabilities", cap], [...prefs]);
298
+ for (const cap of Object.keys(before.capabilities)) if (!(cap in after.capabilities)) doc.deleteIn(["capabilities", cap]);
395
299
  for (const [set, servers] of Object.entries(after.mcpSets)) if (!shallowEqual(before.mcpSets[set], servers)) doc.setIn(["mcp_sets", set], [...servers]);
396
300
  for (const set of Object.keys(before.mcpSets)) if (!(set in after.mcpSets)) doc.deleteIn(["mcp_sets", set]);
397
301
  if (before.active !== after.active) if (after.active === null) doc.deleteIn(["active"]);
@@ -410,12 +314,16 @@ function writeWorkersYaml(yamlPath, after) {
410
314
  active: null,
411
315
  providers: {},
412
316
  classes: {},
317
+ capabilities: {},
413
318
  mcpSets: {},
414
319
  nativeModels: { ...NATIVE_ANTHROPIC_MODELS }
415
320
  }, after);
416
321
  writeWorkersYamlText(yamlPath, String(doc));
417
322
  }
418
- /** Atomic write with a .bak-pai backup and a re-validate-or-restore guard. */
323
+ /** Atomic write with a .bak-pai backup and a re-validate-or-restore guard.
324
+ * The file-I/O tail (backup, temp write at 0600, rename, chmod) is shared
325
+ * with every other PAI YAML file via writeYamlFileAtomic; only the
326
+ * workers.yaml-specific validation (parseWorkersYamlDocument) lives here. */
419
327
  function writeWorkersYamlText(yamlPath, text) {
420
328
  const probe = parseDocument(text);
421
329
  if (probe.errors.length) throw new WorkersConfigError(`refusing to write ${yamlPath}: ${probe.errors[0].message} (previous file left unchanged)`);
@@ -424,26 +332,10 @@ function writeWorkersYamlText(yamlPath, text) {
424
332
  } catch (e) {
425
333
  throw new WorkersConfigError(`refusing to write ${yamlPath}: ${e instanceof Error ? e.message : String(e)} (previous file left unchanged)`);
426
334
  }
427
- const dir = dirname(yamlPath);
428
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
429
- if (existsSync(yamlPath)) try {
430
- copyFileSync(yamlPath, `${yamlPath}.bak-pai`);
431
- } catch (e) {
432
- throw new WorkersConfigError(`Could not back up ${yamlPath}: ${e instanceof Error ? e.message : String(e)}\nRefusing to write without a backup.`);
433
- }
434
- const tmp = `${yamlPath}.tmp-pai-${process.pid}`;
435
335
  try {
436
- writeFileSync(tmp, text, {
437
- encoding: "utf8",
438
- mode: 384
439
- });
440
- renameSync(tmp, yamlPath);
441
- chmodSync(yamlPath, 384);
336
+ writeYamlFileAtomic(yamlPath, text, { label: yamlPath });
442
337
  } catch (e) {
443
- try {
444
- if (existsSync(tmp)) unlinkSync(tmp);
445
- } catch {}
446
- throw new WorkersConfigError(`Failed to write ${yamlPath}: ${e instanceof Error ? e.message : String(e)}`);
338
+ throw new WorkersConfigError(e instanceof YamlStoreError ? e.message : String(e instanceof Error ? e.message : e));
447
339
  }
448
340
  }
449
341
  /** The exact commented starter shipped as examples/workers.yaml and by `init`. */
@@ -481,6 +373,18 @@ providers:
481
373
  default: k3[1m]
482
374
  fast: kimi-for-coding[1m]
483
375
 
376
+ # An \`engine: image\` provider does not spawn Claude — \`pai worker run
377
+ # --capability image\` POSTs straight to its OpenAI-compatible images API and
378
+ # writes the PNG it gets back. Uncomment and point it at a real provider
379
+ # (still nested under providers:, above) to enable \`--capability image\`:
380
+ # pictures:
381
+ # engine: image
382
+ # url: https://api.example.com/v1
383
+ # key: "<your-api-key>"
384
+ # models:
385
+ # default: example-image-model
386
+ # image: example-image-model
387
+
484
388
  # A class names a provider, or provider/role to pick a non-default model.
485
389
  # Workers exist to parallelise and to save cost: the default is Sonnet,
486
390
  # Haiku for the mechanical classes, and nothing here inherits the
@@ -496,6 +400,13 @@ classes:
496
400
  simple: anthropic/fast
497
401
  image: glm/image
498
402
 
403
+ # Cross-provider capability preference: which provider serves a --capability
404
+ # request, in order, when more than one declares it. Unlisted capabilities
405
+ # fall back to the active provider, then any provider that declares them.
406
+ # capabilities:
407
+ # image: [pictures, glm]
408
+ # fast: [anthropic]
409
+
499
410
  mcp_sets:
500
411
  desktop: [clickr]
501
412
  `;
@@ -541,6 +452,12 @@ function buildWorkersYamlText(data) {
541
452
  lines.push(classEntries.length ? "classes:" : "classes: {}");
542
453
  for (const [cls, target] of classEntries) lines.push(` ${cls}: ${typeof target === "string" ? target : yamlScalar(target)}`);
543
454
  lines.push("");
455
+ const capEntries = Object.entries(data.capabilities);
456
+ if (capEntries.length) {
457
+ lines.push("capabilities:");
458
+ for (const [cap, prefs] of capEntries) lines.push(` ${cap}: [${prefs.join(", ")}]`);
459
+ lines.push("");
460
+ }
544
461
  const mcpEntries = Object.entries(data.mcpSets);
545
462
  lines.push(mcpEntries.length ? "mcp_sets:" : "mcp_sets: {}");
546
463
  for (const [set, servers] of mcpEntries) lines.push(` ${set}: [${servers.join(", ")}]`);
@@ -572,6 +489,7 @@ function migrateWorkersToYaml(jsonPath, opts = {}) {
572
489
  active: workers.active,
573
490
  providers: workers.providers,
574
491
  classes: workers.classes,
492
+ capabilities: workers.capabilities,
575
493
  mcpSets: workers.mcpSets,
576
494
  nativeModels: workers.nativeModels
577
495
  });
@@ -679,8 +597,8 @@ function inlineWorkersYamlKeys(opts = {}) {
679
597
  //#region src/workers/config.ts
680
598
  /**
681
599
  * config.ts — the `workers` section of the PAI config file (CONFIG_FILE,
682
- * see src/daemon/config.ts's paiConfigFilePath — ~/.claude/pai/config.json
683
- * today, pre-2026-09-19 ~/.config/pai/config.json).
600
+ * see src/daemon/config.ts's paiConfigFilePath — ~/.claude/pai/config.yaml
601
+ * today, config.json until `pai config yaml` runs).
684
602
  *
685
603
  * Everything that knows about worker providers reads this module: the CLI
686
604
  * (`pai worker …`), the MCP tools (worker_*), and the Agent-routing hook.
@@ -772,20 +690,26 @@ const WORKER_CLASSES = [
772
690
  "image"
773
691
  ];
774
692
  /**
775
- * The named model capabilities a provider may carry a preference for, set
776
- * per provider under `workers.providers.<name>.models`. "default" is the
777
- * required catch-all; the others name what a model is *for* — "fast" the
778
- * cheap tier (spotcheck, haiku-tier spawns), "image" the image class.
779
- * Everything resolves through resolveModelCapability, falling back to default.
693
+ * The well-known model capabilities — documented, and what the starter
694
+ * workers.yaml and `pai worker model`'s listing show by name. The set is
695
+ * open, though: a provider's `models` block may carry any capability name
696
+ * matching CAPABILITY_NAME_RE (see isModelCapability), e.g. "vision" or
697
+ * "longcontext" for a provider that serves them. "default" is the required
698
+ * catch-all; the others name what a model is *for* — "fast" the cheap tier
699
+ * (spotcheck, haiku-tier spawns), "image" the image class/capability.
700
+ * Everything resolves through resolveModelCapability, falling back to
701
+ * default; cross-provider preference for a capability is `capabilities:` in
702
+ * workers.yaml (see resolveCapability).
780
703
  */
781
704
  const MODEL_CAPABILITIES = [
782
705
  "default",
783
706
  "fast",
784
707
  "image"
785
708
  ];
786
- /** Is this string the name of a known model capability? */
709
+ const CAPABILITY_NAME_RE = /^[a-z][a-z0-9-]*$/;
710
+ /** Is this string a syntactically valid capability name (open set)? */
787
711
  function isModelCapability(v) {
788
- return MODEL_CAPABILITIES.includes(v);
712
+ return CAPABILITY_NAME_RE.test(v);
789
713
  }
790
714
  /**
791
715
  * Which model capability a class runs on when its target names no alias: the
@@ -849,6 +773,7 @@ function defaultWorkersConfig() {
849
773
  providers: {},
850
774
  classes: {},
851
775
  mcpSets: { ...DEFAULT_MCP_SETS },
776
+ capabilities: {},
852
777
  pane: { ...DEFAULT_PANE },
853
778
  logDir: DEFAULT_LOG_DIR,
854
779
  routing: {
@@ -882,7 +807,7 @@ function parseModelsBlock(pathPrefix, modelsRaw) {
882
807
  const models = { default: defaultModel };
883
808
  for (const key of Object.keys(m)) {
884
809
  if (key === "default") continue;
885
- if (!isModelCapability(key)) bad(`${pathPrefix}.${key}`, `"${key}" is not a model capability (from: ${MODEL_CAPABILITIES.join(", ")})`);
810
+ if (!isModelCapability(key)) bad(`${pathPrefix}.${key}`, `"${key}" is not a valid capability name (must match ^[a-z][a-z0-9-]*$; well-known: ${MODEL_CAPABILITIES.join(", ")}, but any name in that shape is accepted)`);
886
811
  const id = str(m[key]);
887
812
  if (!id) bad(`${pathPrefix}.${key}`, "must be a non-empty model id");
888
813
  models[key] = id;
@@ -897,7 +822,7 @@ function parseProvider(name, raw) {
897
822
  const protocol = p.protocol === void 0 ? "anthropic" : str(p.protocol);
898
823
  if (protocol !== "anthropic" && protocol !== "openai") bad(`.providers.${name}.protocol`, `"${str(p.protocol)}" is neither "anthropic" nor "openai"`);
899
824
  const engine = p.engine === void 0 ? "claude" : str(p.engine);
900
- if (engine !== "claude" && engine !== "codex") bad(`.providers.${name}.engine`, `"${str(p.engine)}" is neither "claude" nor "codex"`);
825
+ if (engine !== "claude" && engine !== "codex" && engine !== "image") bad(`.providers.${name}.engine`, `"${str(p.engine)}" is none of "claude", "codex", "image"`);
901
826
  const baseUrl = str(p.baseUrl);
902
827
  if (!baseUrl && protocol !== "openai") bad(`.providers.${name}.baseUrl`, "is required");
903
828
  const keyFile = p.keyFile === void 0 || p.keyFile === null || str(p.keyFile) === "" ? null : str(p.keyFile);
@@ -1059,6 +984,24 @@ function parseMcpSetsValue(raw, badPathPrefix) {
1059
984
  return mcpSets;
1060
985
  }
1061
986
  /**
987
+ * Parse a `capabilities` value shared by JSON and YAML loading: capability
988
+ * name (open set, see isModelCapability) → provider names in preference
989
+ * order. Provider names are not cross-checked here — a name that stops
990
+ * existing is simply skipped by resolveCapability at resolve time, the same
991
+ * way routing.order tolerates a removed provider.
992
+ */
993
+ function parseCapabilitiesValue(raw, badPathPrefix) {
994
+ const capabilities = {};
995
+ if (raw === void 0) return capabilities;
996
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) bad(badPathPrefix, "must be an object of capability name → [provider names]");
997
+ for (const [name, order] of Object.entries(raw)) {
998
+ if (!isModelCapability(name)) bad(`${badPathPrefix}.${name}`, `"${name}" is not a valid capability name (must match ^[a-z][a-z0-9-]*$)`);
999
+ if (!Array.isArray(order) || order.length === 0 || order.some((x) => typeof x !== "string" || !x)) bad(`${badPathPrefix}.${name}`, "must be a non-empty array of provider names");
1000
+ capabilities[name] = order;
1001
+ }
1002
+ return capabilities;
1003
+ }
1004
+ /**
1062
1005
  * Parse and validate a raw `workers` value. Missing section → defaults.
1063
1006
  * Unknown-but-typed garbage → WorkersConfigError naming the offending field.
1064
1007
  */
@@ -1074,6 +1017,7 @@ function parseWorkersConfig(raw) {
1074
1017
  }
1075
1018
  const classes = parseClassesValue(w.classes !== void 0 ? w.classes : w.roles, w.classes !== void 0 ? ".classes" : ".roles");
1076
1019
  const mcpSets = parseMcpSetsValue(w.mcpSets, ".mcpSets");
1020
+ const capabilities = parseCapabilitiesValue(w.capabilities, ".capabilities");
1077
1021
  let pane = { ...DEFAULT_PANE };
1078
1022
  if (w.pane !== void 0) {
1079
1023
  if (typeof w.pane !== "object" || w.pane === null) bad(".pane", "must be an object");
@@ -1157,6 +1101,7 @@ function parseWorkersConfig(raw) {
1157
1101
  providers,
1158
1102
  classes,
1159
1103
  mcpSets,
1104
+ capabilities,
1160
1105
  pane,
1161
1106
  logDir: str(w.logDir) || d.logDir,
1162
1107
  routing,
@@ -1172,24 +1117,28 @@ function workersYamlDataOf(workers) {
1172
1117
  providers: workers.providers,
1173
1118
  classes: workers.classes,
1174
1119
  mcpSets: workers.mcpSets,
1120
+ capabilities: workers.capabilities,
1175
1121
  nativeModels: workers.nativeModels
1176
1122
  };
1177
1123
  }
1178
1124
  /**
1179
1125
  * Read the whole config file and return (raw, workers) — the raw record so
1180
1126
  * callers can rewrite it preserving every other section, the parsed+validated
1181
- * workers section. Unreadable config throws (readJsonStrict), missing is fine.
1182
- * `path` overrides the config location (tests, CLAUDE_SETTINGS_PATH-style
1183
- * dry runs); default CONFIG_FILE (see paiConfigFilePath).
1127
+ * workers section. Unreadable config throws, missing is fine. `path`
1128
+ * overrides the config location (tests, CLAUDE_SETTINGS_PATH-style dry runs);
1129
+ * default CONFIG_FILE (see paiConfigFilePath). Reads via readMainConfigRaw,
1130
+ * so a config.yaml next to `path` is preferred over JSON, same as every
1131
+ * other main-config reader.
1184
1132
  *
1185
1133
  * Providers, classes, mcpSets and active load from workers.yaml (next to
1186
1134
  * `path`) when it exists; otherwise they fall back to the JSON `workers`
1187
1135
  * section, and then to built-in defaults — nothing breaks before migration
1188
1136
  * (`pai worker config migrate`). Everything else (pane, routing, tree,
1189
- * cacheKeepaliveSecs, fallback, enabled, logDir) always comes from JSON.
1137
+ * cacheKeepaliveSecs, fallback, enabled, logDir) always comes from the main
1138
+ * config (JSON or YAML).
1190
1139
  */
1191
1140
  function readWorkersSection(path = CONFIG_FILE) {
1192
- const raw = readJsonStrict(path, path);
1141
+ const raw = readMainConfigRaw(path);
1193
1142
  const workers = parseWorkersConfig(raw.workers);
1194
1143
  const yaml = readWorkersYaml(workersYamlPath());
1195
1144
  if (yaml) {
@@ -1197,6 +1146,7 @@ function readWorkersSection(path = CONFIG_FILE) {
1197
1146
  workers.providers = yaml.data.providers;
1198
1147
  workers.classes = yaml.data.classes;
1199
1148
  workers.mcpSets = yaml.data.mcpSets;
1149
+ workers.capabilities = yaml.data.capabilities;
1200
1150
  workers.nativeModels = yaml.data.nativeModels;
1201
1151
  }
1202
1152
  return {
@@ -1226,7 +1176,7 @@ function writeWorkersSection(raw, workers, path = CONFIG_FILE) {
1226
1176
  ...workers,
1227
1177
  fallback: void 0
1228
1178
  };
1229
- writeJsonAtomic(path, raw, { label: path });
1179
+ writeMainConfigRaw(raw, path);
1230
1180
  }
1231
1181
  /** Expand a leading ~ (config values are written with `~` to stay portable). */
1232
1182
  function expandHome(p) {
@@ -1279,11 +1229,90 @@ function providerCostTier(p) {
1279
1229
  function resolveModelCapability(p, capability) {
1280
1230
  return p.models[capability] ?? p.models.default;
1281
1231
  }
1232
+ /** Runner behind a provider, defaulting to the Claude Code harness. */
1233
+ function providerEngine(p) {
1234
+ return p.engine ?? "claude";
1235
+ }
1236
+ /** Does this provider name a model for the capability ("default" always counts)? */
1237
+ function providerDeclaresCapability(p, capability) {
1238
+ return capability === "default" || p.models[capability] !== void 0;
1239
+ }
1240
+ /** A provider usable for this capability: declares it, enabled, and passes assertProviderRunnable. */
1241
+ function providerUsableForCapability(p, capability) {
1242
+ if (!p || !p.enabled || !providerDeclaresCapability(p, capability)) return false;
1243
+ try {
1244
+ assertProviderRunnable("", p);
1245
+ return true;
1246
+ } catch {
1247
+ return false;
1248
+ }
1249
+ }
1250
+ /**
1251
+ * Cross-provider capability resolution (`workers.yaml`'s `capabilities:`
1252
+ * map): which provider+model serves a named capability (e.g. "image"),
1253
+ * independent of any one provider's own `models` table.
1254
+ *
1255
+ * Resolution order:
1256
+ * 1. `capabilities.<name>` (explicit preference list) — first provider
1257
+ * that declares the capability, is enabled, and passes
1258
+ * assertProviderRunnable.
1259
+ * 2. No preference list: the active provider, if it declares the
1260
+ * capability.
1261
+ * 3. Still nothing: any enabled provider that declares the capability
1262
+ * (stable order — provider names sorted, "anthropic" included).
1263
+ * 4. Nothing anywhere: the active provider's default model, `fellBack: true`.
1264
+ */
1265
+ function resolveCapability(workers, capability) {
1266
+ const order = workers.capabilities[capability];
1267
+ if (order?.length) {
1268
+ for (const name of order) {
1269
+ const p = getProviderOrNative(workers, name);
1270
+ if (providerUsableForCapability(p, capability)) return {
1271
+ provider: name,
1272
+ model: resolveModelCapability(p, capability),
1273
+ engine: providerEngine(p),
1274
+ fellBack: false
1275
+ };
1276
+ }
1277
+ throw new WorkersConfigError(`no configured provider for capability "${capability}" is usable right now (checked, in order: ${order.join(", ")}) — set one with: pai worker capability ${capability} <provider>`);
1278
+ }
1279
+ const activeName = workers.active && workers.active !== "auto" ? workers.active : null;
1280
+ if (activeName) {
1281
+ const p = getProviderOrNative(workers, activeName);
1282
+ if (providerUsableForCapability(p, capability)) return {
1283
+ provider: activeName,
1284
+ model: resolveModelCapability(p, capability),
1285
+ engine: providerEngine(p),
1286
+ fellBack: false
1287
+ };
1288
+ }
1289
+ const candidates = {
1290
+ [ANTHROPIC_NATIVE]: nativeAnthropicProvider(workers.nativeModels),
1291
+ ...workers.providers
1292
+ };
1293
+ for (const name of Object.keys(candidates).sort()) {
1294
+ const p = candidates[name];
1295
+ if (providerUsableForCapability(p, capability)) return {
1296
+ provider: name,
1297
+ model: resolveModelCapability(p, capability),
1298
+ engine: providerEngine(p),
1299
+ fellBack: false
1300
+ };
1301
+ }
1302
+ const fallbackName = activeName ?? ANTHROPIC_NATIVE;
1303
+ const fallback = getProviderOrNative(workers, fallbackName) ?? nativeAnthropicProvider(workers.nativeModels);
1304
+ return {
1305
+ provider: fallbackName,
1306
+ model: fallback.models.default,
1307
+ engine: providerEngine(fallback),
1308
+ fellBack: true
1309
+ };
1310
+ }
1282
1311
  /** Directory under which inline keys (MCP `key` field) are stored, 0600. */
1283
1312
  function keysDir() {
1284
1313
  return paiHomePath("keys");
1285
1314
  }
1286
1315
 
1287
1316
  //#endregion
1288
- export { DEFAULT_CONTEXT_WINDOW as A, inlineWorkersYamlKeys as C, relocateWorkersYaml as D, parseWorkersYamlDocument as E, stripModelVariant as M, readJsonStrict as N, workersYamlLegacyNotice as O, writeJsonAtomic as P, initWorkersYaml as S, needsWorkersYamlRelocation as T, providerKeyPath as _, WORKER_CLASSES as a, resolveProviderKey as b, classModelCapability as c, getProviderOrNative as d, isModelCapability as f, providerCostTier as g, nativeAnthropicProvider as h, PROVIDER_TAGS as i, contextWindowFromModelId as j, workersYamlPath as k, defaultWorkersConfig as l, maskKey as m, DEFAULT_LOG_DIR as n, WorkersConfigError as o, keysDir as p, MODEL_CAPABILITIES as r, assertProviderRunnable as s, ANTHROPIC_NATIVE as t, expandHome as u, readWorkersSection as v, migrateWorkersToYaml as w, writeWorkersSection as x, resolveModelCapability as y };
1289
- //# sourceMappingURL=config-DxMZjpvY.mjs.map
1317
+ export { migrateWorkersToYaml as A, readWorkersSection as C, writeWorkersSection as D, resolveProviderKey as E, workersYamlPath as F, DEFAULT_CONTEXT_WINDOW as I, contextWindowFromModelId as L, parseWorkersYamlDocument as M, relocateWorkersYaml as N, initWorkersYaml as O, workersYamlLegacyNotice as P, stripModelVariant as R, providerKeyPath as S, resolveModelCapability as T, isModelCapability as _, DEFAULT_PANE as a, nativeAnthropicProvider as b, MODEL_CAPABILITIES as c, WorkersConfigError as d, assertProviderRunnable as f, getProviderOrNative as g, expandHome as h, DEFAULT_LOG_DIR as i, needsWorkersYamlRelocation as j, inlineWorkersYamlKeys as k, PROVIDER_TAGS as l, defaultWorkersConfig as m, CAPABILITY_NAME_RE as n, DEFAULT_ROUTING as o, classModelCapability as p, DEFAULT_CACHE_KEEPALIVE_SECS as r, DEFAULT_TREE as s, ANTHROPIC_NATIVE as t, WORKER_CLASSES as u, keysDir as v, resolveCapability as w, providerCostTier as x, maskKey as y };
1318
+ //# sourceMappingURL=config-DYXARgk8.mjs.map