@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
@@ -2,26 +2,33 @@
2
2
 
3
3
  One hand-editable file: which providers PAI can run workers on, the model
4
4
  each provider uses per role, and which provider every `--class` goes to.
5
- Before this, that lived inside `~/.config/pai/config.json`'s `workers`
5
+ Before this, that lived inside `~/.claude/pai/config.json`'s `workers`
6
6
  section — JSON, unrelated to the rest of that file, and undocumented in
7
7
  place. Adding a provider meant reading code to find out what fields existed.
8
8
 
9
9
  ## Where it lives
10
10
 
11
11
  `~/.claude/pai/workers.yaml` — under the PAI_HOME namespace dir, beside
12
- PAI's other per-user files (`config.json`, `whisper-rules.md`,
12
+ PAI's other per-user files (`config.yaml`, `whisper-rules.md`,
13
13
  `advisor-mode.json`), so nothing PAI writes can collide with a file Claude
14
14
  Code itself introduces under `~/.claude`. Set `PAI_WORKERS_YAML=<path>` to
15
15
  override this — tests and power users only, and `PAI_HOME=<dir>` to move
16
16
  the whole namespace. Only providers, model roles, class routing and MCP
17
17
  sets live here. Everything else worker-related — the follow-pane profile,
18
18
  log dir, routing cooldown, sub-worker caps, cache-keepalive cadence, the
19
- machine-wide fallback switch — stays in `config.json`'s `workers` section;
19
+ machine-wide fallback switch — stays in `config.yaml` (or `config.json` until you run `pai config yaml`)'s `workers` section;
20
20
  none of it is provider-specific enough to want hand comments.
21
21
 
22
22
  This file is per-user state: it can hold API keys (`key:`, below), is kept
23
23
  mode 0600, and is never committed or shared.
24
24
 
25
+ The non-provider settings mentioned above — `pane`, `routing`, `tree`,
26
+ `cacheKeepaliveSecs`, `logDir`, the machine-wide fallback switch — live in
27
+ the main PAI config instead, under its `workers` section: see
28
+ [docs/config.md](config.md) for `config.yaml`'s layout and `pai config
29
+ get workers.cacheKeepaliveSecs` / `pai config set workers.pane.rows 40`
30
+ style access.
31
+
25
32
  Before 2026-09-19 it lived at `~/.config/pai/workers.yaml`, and briefly at
26
33
  `~/.claude/workers.yaml` in between. If yours is still at either old
27
34
  location, `pai worker config check` and `pai worker providers` say so;
@@ -65,6 +72,18 @@ providers:
65
72
  default: k3[1m]
66
73
  fast: kimi-for-coding[1m]
67
74
 
75
+ # An `engine: image` provider does not spawn Claude — `pai worker run
76
+ # --capability image` POSTs straight to its OpenAI-compatible images API and
77
+ # writes the PNG it gets back. Uncomment and point it at a real provider
78
+ # (still nested under providers:, above) to enable `--capability image`:
79
+ # pictures:
80
+ # engine: image
81
+ # url: https://api.example.com/v1
82
+ # key: "<your-api-key>"
83
+ # models:
84
+ # default: example-image-model
85
+ # image: example-image-model
86
+
68
87
  # A class names a provider, or provider/role to pick a non-default model.
69
88
  # Workers exist to parallelise and to save cost: the default is Sonnet,
70
89
  # Haiku for the mechanical classes, and nothing here inherits the
@@ -80,6 +99,13 @@ classes:
80
99
  simple: anthropic/fast
81
100
  image: glm/image
82
101
 
102
+ # Cross-provider capability preference: which provider serves a --capability
103
+ # request, in order, when more than one declares it. Unlisted capabilities
104
+ # fall back to the active provider, then any provider that declares them.
105
+ # capabilities:
106
+ # image: [pictures, glm]
107
+ # fast: [anthropic]
108
+
83
109
  mcp_sets:
84
110
  desktop: [clickr]
85
111
  ```
@@ -107,8 +133,10 @@ init` writes on a machine with no workers.yaml yet.
107
133
  - **`providers.<name>.tier`** — cost/quality tier, 1 (cheapest) … 5 (most
108
134
  expensive), default 3. Classes can constrain auto-routing to a max tier.
109
135
  - **`providers.<name>.models`** — model id per capability. `default` is
110
- required; `fast` and `image` are optional and fall back to `default` when
111
- a class asks for them and the provider has none.
136
+ required; every other name is open (`fast`, `image`, or a name of your own
137
+ choosing, matching `^[a-z][a-z0-9-]*$`) and falls back to `default` when a
138
+ class or run asks for it and the provider has none — see "Capabilities"
139
+ below.
112
140
  - **`classes.<name>`** — `provider` or `provider/role` (`role` one of
113
141
  `default`, `fast`, `image`). An object form
114
142
  (`{provider, mcp, maxCostTier, requireTags, order}`) is also accepted for
@@ -123,10 +151,75 @@ Not in the starter, but read when present (same names `pai worker providers
123
151
  add` accepts, snake_cased): `enabled: false` (skip in auto-routing without
124
152
  deleting the entry), `protocol: openai` + `upstream_url` (routes through the
125
153
  built-in Anthropic↔OpenAI proxy), `engine: codex` (runs the provider on the
126
- Codex CLI instead of Claude Code), `env` (extra environment for runs through
127
- this provider), `note` (shown in `pai worker providers`), `quota_probe` +
128
- `quota_skip_at`, `context_window`, `tags`, `usage`, `model_tiers`. Full field
129
- semantics: `src/workers/config.ts`.
154
+ Codex CLI instead of Claude Code) or `engine: image` (see "Capabilities"
155
+ below), `env` (extra environment for runs through this provider), `note`
156
+ (shown in `pai worker providers`), `quota_probe` + `quota_skip_at`,
157
+ `context_window`, `tags`, `usage`, `model_tiers`. Full field semantics:
158
+ `src/workers/config.ts`.
159
+
160
+ ## Capabilities
161
+
162
+ A provider's `models` table (above) picks a model *within* that provider —
163
+ `glm/image` runs on whatever `glm.models.image` names. The top-level
164
+ `capabilities:` map instead picks *which provider* serves a capability at
165
+ all, across the whole config:
166
+
167
+ ```yaml
168
+ capabilities:
169
+ image: [pictures, glm] # first usable one wins
170
+ fast: [anthropic]
171
+ ```
172
+
173
+ `pai worker run --capability image -p '<prompt>'` (alias `--for`) resolves
174
+ through this list: the first entry that is enabled, declares an `image`
175
+ model, and passes the same runnable checks as any other provider. A `--class`
176
+ whose implied capability is not `default` (e.g. a class named `image` with no
177
+ provider of its own) resolves the same way; a class already pinned to a
178
+ provider (`image: glm/image`, the starter default) is unaffected — it always
179
+ runs that provider unchanged. An explicit `--provider` always wins over both.
180
+
181
+ Without a `capabilities:` entry, resolution falls back to the active
182
+ provider (if it declares the capability), then any provider that declares it
183
+ (stable order). If nothing anywhere does, the run refuses to guess: it
184
+ errors naming the config key to set, rather than silently running a normal
185
+ model on a prompt it cannot answer. `pai worker capability` (no args) lists
186
+ every preference and what it resolves to right now; `describeCapabilities`
187
+ truthfully reports "unresolved" or "fallback" in the same situation without
188
+ throwing, so listing your config never fails just because you have not
189
+ finished it.
190
+
191
+ ### The image engine
192
+
193
+ A provider with `engine: image` (see the commented `pictures:` example
194
+ above) does not spawn Claude Code at all. `pai worker run --capability
195
+ image -p "<prompt>" [--size WxH] [--out <file>]` POSTs one
196
+ OpenAI-compatible request straight to `{url}/images/generations`:
197
+
198
+ ```json
199
+ {"model": "<the resolved model>", "prompt": "<prompt>", "size": "1024x1024", "n": 1, "response_format": "b64_json"}
200
+ ```
201
+
202
+ ...decodes the base64 PNG it gets back, and writes it to `--out` (default:
203
+ `<logDir>/<worker-id>.png`). No worktree, no MCP, no follow pane — one
204
+ request, one file. stdout is the same JSON shape `pai worker run` always
205
+ prints for a headless run: `{ok, path, model, provider, durationMs, bytes}`.
206
+ Asking a `claude`/`codex`-engine provider to run `--capability image` is not
207
+ an error by itself — it just means no image-generating provider is
208
+ configured, which the "nothing anywhere does" case above already reports
209
+ clearly.
210
+
211
+ CLI and MCP agree on the same preferences:
212
+
213
+ ```
214
+ pai worker capability # list every preference
215
+ pai worker capability image pictures,glm # set (first usable wins)
216
+ pai worker capability image --unset # remove it
217
+ ```
218
+
219
+ The `worker_capability` MCP tool takes the same `action: list|set|unset`,
220
+ `capability`, `providers` shape; `worker_run`'s `capability` field and
221
+ `worker_model`'s `capability` field (now an open set, not just `default` /
222
+ `fast` / `image`) round out the chat-side surface.
130
223
 
131
224
  ## Provider protocol shapes
132
225
 
@@ -311,7 +404,7 @@ that resolves `anthropic` or `anthropic/fast` picks up the override.
311
404
  all come from here, validated as a whole (an unknown provider named by a
312
405
  class is a load error naming the file and line, not a spawn-time
313
406
  surprise).
314
- 2. Else the JSON `workers` section in `config.json` (`providers`, `classes`
407
+ 2. Else the JSON `workers` section in `config.json` (or `config.yaml` if it exists) (`providers`, `classes`
315
408
  — or the pre-rename `roles` — `mcpSets`, `active`), unchanged from
316
409
  before this file existed. Nothing breaks on a machine that has not
317
410
  migrated yet.
@@ -346,7 +439,7 @@ pai worker config inline-keys # move every key_file's contents inline as key: (q
346
439
  - **no workers.yaml yet**: reads the JSON `workers` section, writes
347
440
  workers.yaml with the same header/section comments as the starter, backs
348
441
  the pre-migration JSON section up to `workers.json.migrated-<date>` next
349
- to `config.json`, and strips `providers`/`classes`/`roles`/`mcpSets`/
442
+ to `config.json` (or `config.yaml` if it exists), and strips `providers`/`classes`/`roles`/`mcpSets`/
350
443
  `active` from the JSON (everything else in that section — pane, log dir,
351
444
  routing, tree, cache-keepalive, fallback — stays).
352
445
 
@@ -363,7 +456,7 @@ writing anything.
363
456
 
364
457
  PAI is not a Claude Code plugin — it is a self-contained system that Claude
365
458
  Code happens to be the first harness for. `~/.claude/pai/` (`PAI_HOME`) is
366
- the single home for everything PAI owns: `config.json`, `workers.yaml`,
459
+ the single home for everything PAI owns: `config.yaml`, `workers.yaml`,
367
460
  `whisper-rules.md`, `advisor-mode.json`, `session-state/`, `queries/`,
368
461
  `logs/workers/` and the rest of PAI's per-user state and caches. `pai config
369
462
  path` prints where every one of these currently resolves to; `pai config
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tekmidian/pai",
3
- "version": "0.48.0",
3
+ "version": "0.50.0",
4
4
  "description": "PAI Knowledge OS — Personal AI Infrastructure with federated memory and project management",
5
5
  "type": "module",
6
6
  "main": "dist/index.mjs",
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Tests for capture-all-events hook
3
+ */
4
+
5
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
6
+ import { readFileSync, writeFileSync, rmSync, mkdirSync, existsSync } from 'fs';
7
+ import { join } from 'path';
8
+ import { tmpdir } from 'os';
9
+
10
+ describe('capture-all-events: agent-sessions.json hardening', () => {
11
+ let tempDir: string;
12
+
13
+ beforeEach(() => {
14
+ // Create a temporary directory for each test
15
+ tempDir = join(tmpdir(), `test-agent-sessions-${Date.now()}-${Math.random()}`);
16
+ mkdirSync(tempDir, { recursive: true });
17
+ });
18
+
19
+ afterEach(() => {
20
+ // Clean up temp directory
21
+ if (existsSync(tempDir)) {
22
+ rmSync(tempDir, { recursive: true, force: true });
23
+ }
24
+ });
25
+
26
+ it('setAgentForSession recovers from corrupt JSON file', async () => {
27
+ // Import the functions dynamically to inject our test path
28
+ const sessionFile = join(tempDir, 'agent-sessions.json');
29
+
30
+ // Create a corrupt JSON file (truncated in the middle of a string)
31
+ writeFileSync(sessionFile, '{"session1": "pai", "session2": "designer", "session3": "un');
32
+
33
+ // Now simulate setAgentForSession behavior: read the file, handle parse error, write fresh map
34
+ let mappings: Record<string, string> = {};
35
+ try {
36
+ mappings = JSON.parse(readFileSync(sessionFile, 'utf-8'));
37
+ } catch {
38
+ // If file exists but is corrupt, start fresh
39
+ mappings = {};
40
+ }
41
+
42
+ mappings['session4'] = 'researcher';
43
+
44
+ // Write with atomic helper would succeed
45
+ // For this test, we just verify the recovery logic works
46
+ expect(Object.keys(mappings)).toEqual(['session4']);
47
+ expect(mappings['session4']).toBe('researcher');
48
+
49
+ // Verify we could write valid JSON
50
+ const testJson = JSON.stringify(mappings, null, 2);
51
+ expect(() => JSON.parse(testJson)).not.toThrow();
52
+ });
53
+
54
+ it('setAgentForSession preserves existing mappings when adding new one', async () => {
55
+ const sessionFile = join(tempDir, 'agent-sessions.json');
56
+
57
+ // Create a valid sessions file
58
+ const initialMappings = { session1: 'pai', session2: 'designer' };
59
+ writeFileSync(sessionFile, JSON.stringify(initialMappings, null, 2));
60
+
61
+ // Simulate reading and adding a new session
62
+ let mappings = JSON.parse(readFileSync(sessionFile, 'utf-8'));
63
+ mappings['session3'] = 'researcher';
64
+
65
+ // Verify all mappings are present
66
+ expect(Object.keys(mappings).length).toBe(3);
67
+ expect(mappings['session1']).toBe('pai');
68
+ expect(mappings['session2']).toBe('designer');
69
+ expect(mappings['session3']).toBe('researcher');
70
+ });
71
+ });
@@ -6,11 +6,12 @@
6
6
  * Enhanced with agent instance metadata extraction
7
7
  */
8
8
 
9
- import { readFileSync, appendFileSync, mkdirSync, existsSync, writeFileSync } from 'fs';
9
+ import { readFileSync, appendFileSync, mkdirSync, existsSync } from 'fs';
10
10
  import { join } from 'path';
11
11
  import { historyDir, agentSessionsPath } from './lib/pai-paths';
12
12
  import { enrichEventWithAgentMetadata, isAgentSpawningCall } from './lib/metadata-extraction';
13
13
  import { isProbeSession } from './lib/project-utils';
14
+ import { writeJsonAtomic } from '../../config/json-store.js';
14
15
 
15
16
  interface HookEvent {
16
17
  source_app: string;
@@ -80,11 +81,17 @@ function setAgentForSession(sessionId: string, agentName: string): void {
80
81
  let mappings: Record<string, string> = {};
81
82
 
82
83
  if (existsSync(mappingFile)) {
83
- mappings = JSON.parse(readFileSync(mappingFile, 'utf-8'));
84
+ try {
85
+ mappings = JSON.parse(readFileSync(mappingFile, 'utf-8'));
86
+ } catch {
87
+ // If file exists but is corrupt, start fresh with the new mapping
88
+ mappings = {};
89
+ }
84
90
  }
85
91
 
86
92
  mappings[sessionId] = agentName;
87
- writeFileSync(mappingFile, JSON.stringify(mappings, null, 2), 'utf-8');
93
+ // Fires concurrently across sessions; non-atomic write truncated the file on 2026-09-20
94
+ writeJsonAtomic(mappingFile, mappings, { backup: false, label: 'agent-sessions.json' });
88
95
  } catch (error) {
89
96
  // Silently fail - don't block
90
97
  }
@@ -1,5 +1,8 @@
1
- import { describe, it, expect } from "vitest";
1
+ import { describe, it, expect, afterEach } from "vitest";
2
2
  import { execFileSync } from "node:child_process";
3
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
3
6
 
4
7
  // whisper-rules.ts calls main() unconditionally at module scope (it's a hook
5
8
  // entrypoint, always run as a subprocess) — including a blocking read of fd 0.
@@ -46,4 +49,56 @@ describe("user-prompt/whisper-rules entrypoint", () => {
46
49
  expect(stdout).toContain("<system-reminder>");
47
50
  expect(stdout).toContain("CURRENT LOCAL TIME");
48
51
  });
52
+
53
+ describe("cache-keepalive beat prompt", () => {
54
+ const dirs: string[] = [];
55
+
56
+ afterEach(() => {
57
+ for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true });
58
+ });
59
+
60
+ function configuredEnv(config: Record<string, unknown>): NodeJS.ProcessEnv {
61
+ const dir = mkdtempSync(join(tmpdir(), "pai-whisper-keepalive-"));
62
+ dirs.push(dir);
63
+ const file = join(dir, "config.json");
64
+ writeFileSync(file, JSON.stringify(config), "utf8");
65
+ return { ...process.env, PAI_CONFIG_FILE: file };
66
+ }
67
+
68
+ it("replies with only the minimal tool-free instruction, bypassing rules/advisor", () => {
69
+ const env = configuredEnv({ sessions: { cacheKeepalive: { enabled: true, prompt: "keepalive" } } });
70
+ const stdout = execFileSync("bun", [ENTRYPOINT], {
71
+ input: JSON.stringify({ prompt: "keepalive" }),
72
+ encoding: "utf8",
73
+ timeout: 15_000,
74
+ env,
75
+ });
76
+ expect(stdout).toBe(
77
+ "<system-reminder>\nReply with a single period and nothing else. Do not call tools.\n</system-reminder>\n"
78
+ );
79
+ expect(stdout).not.toContain("CURRENT LOCAL TIME");
80
+ });
81
+
82
+ it("falls through to the normal rule block when the feature is disabled", () => {
83
+ const env = configuredEnv({ sessions: { cacheKeepalive: { enabled: false, prompt: "keepalive" } } });
84
+ const stdout = execFileSync("bun", [ENTRYPOINT], {
85
+ input: JSON.stringify({ prompt: "keepalive" }),
86
+ encoding: "utf8",
87
+ timeout: 15_000,
88
+ env,
89
+ });
90
+ expect(stdout).toContain("CURRENT LOCAL TIME");
91
+ });
92
+
93
+ it("falls through to the normal rule block for a prompt that doesn't match the configured word", () => {
94
+ const env = configuredEnv({ sessions: { cacheKeepalive: { enabled: true, prompt: "keepalive" } } });
95
+ const stdout = execFileSync("bun", [ENTRYPOINT], {
96
+ input: JSON.stringify({ prompt: "please fix the login bug" }),
97
+ encoding: "utf8",
98
+ timeout: 15_000,
99
+ env,
100
+ });
101
+ expect(stdout).toContain("CURRENT LOCAL TIME");
102
+ });
103
+ });
49
104
  });
@@ -24,6 +24,7 @@ import { join } from "node:path";
24
24
  import { tmpdir } from "node:os";
25
25
  import { resolveAdvisorMode, type AdvisorConfig } from "../lib/advisor-budget.js";
26
26
  import { whisperRulesPath, advisorModePath } from "../../../config/pai-files.js";
27
+ import { loadConfig } from "../../../daemon/config.js";
27
28
 
28
29
  const WHISPER_FILE = whisperRulesPath();
29
30
  const ADVISOR_FILE = advisorModePath();
@@ -181,6 +182,24 @@ function resetReinjectCounter(sessionId: string): void {
181
182
  * several times per turn during worker-heavy work, so injecting the ~5KB rule
182
183
  * block on each one was landing it 3-5 times in a single turn.
183
184
  */
185
+ /**
186
+ * True when this prompt is the daemon's own idle-triggered cache-keepalive
187
+ * beat (sessions.cacheKeepalive, src/daemon/config.ts): the daemon typed the
188
+ * configured word into this session purely to refresh its prompt-cache TTL,
189
+ * not to ask anything. The whole point is a beat that costs as little output
190
+ * as possible, so this bypasses every other rule/advisor injection below —
191
+ * adding ~5KB of context to answer nothing would erase the saving the beat
192
+ * exists to capture.
193
+ */
194
+ export function isCacheKeepaliveBeat(prompt: string): boolean {
195
+ try {
196
+ const { enabled, prompt: word } = loadConfig().sessions.cacheKeepalive;
197
+ return enabled && prompt.trim() === word;
198
+ } catch {
199
+ return false;
200
+ }
201
+ }
202
+
184
203
  export function isRelayedPrompt(prompt: string): boolean {
185
204
  return (
186
205
  /^\s*\[Session:[^\]]+\]/.test(prompt) ||
@@ -204,6 +223,11 @@ function main() {
204
223
 
205
224
  resetReinjectCounter(sessionId);
206
225
 
226
+ if (isCacheKeepaliveBeat(prompt)) {
227
+ console.log("<system-reminder>\nReply with a single period and nothing else. Do not call tools.\n</system-reminder>");
228
+ return;
229
+ }
230
+
207
231
  if (isRelayedPrompt(prompt)) return;
208
232
 
209
233
  const isWorker = process.env.PAI_WORKER === "1";
@@ -114,15 +114,90 @@ if [ ! -f "$pai_config" ]; then
114
114
  pai_config="${XDG_CONFIG_HOME:-$HOME/.config}/pai/config.json"
115
115
  fi
116
116
  fi
117
+
118
+ # Providers moved to $pai_home_dir/workers.yaml (top-level `providers:`; see
119
+ # src/workers/config.ts readWorkersSection) on 2026-09-19 — jq never sees the
120
+ # YAML directly (no yq on this machine), so a small node script converts it
121
+ # via the `yaml` package sitting next to this script's own real location
122
+ # (readlink -f "$0": this file is usually reached through a symlink at
123
+ # ~/.claude/statusline-command.sh). The conversion is cached under
124
+ # pai_cache_dir, the cache kept only while it is newer than workers.yaml's own
125
+ # mtime, so node is spawned only when the YAML actually changed. Resolved once
126
+ # into $providers_file (holding just the providers object, unwrapped) so both
127
+ # this lookup and the usage block below read the same data — never re-derived.
128
+ # Any failure (no workers.yaml, no node, parse error) falls back silently to
129
+ # the legacy `.workers.providers` of $pai_config, then to anthropic.
130
+ providers_file=""
131
+ _workers_yaml="${pai_home_dir}/workers.yaml"
132
+ if [ -f "$_workers_yaml" ] && command -v node >/dev/null 2>&1; then
133
+ _sl_script="$(readlink -f "$0" 2>/dev/null || echo "$0")"
134
+ _sl_script_dir="$(dirname "$_sl_script")"
135
+ _providers_cache="${pai_cache_dir}/statusline-providers.json"
136
+ _yaml_mtime=$(stat -f %m "$_workers_yaml" 2>/dev/null || echo 0)
137
+ _cache_mtime=$(stat -f %m "$_providers_cache" 2>/dev/null || echo -1)
138
+ if [ ! -s "$_providers_cache" ] || [ "$_cache_mtime" -lt "$_yaml_mtime" ] 2>/dev/null; then
139
+ mkdir -p "$pai_cache_dir" 2>/dev/null
140
+ # umask 077 so the temp file is born 0600 — it must never exist at a
141
+ # world-readable mode even transiently, since providers carry inline
142
+ # `key:` secrets and only the allow-listed fields below leave this
143
+ # subshell.
144
+ (
145
+ umask 077
146
+ NODE_PATH="${_sl_script_dir}/node_modules" node - "$_workers_yaml" \
147
+ > "${_providers_cache}.tmp" 2>/dev/null <<'PAI_YAML_PROVIDERS'
148
+ const fs = require("fs");
149
+ try {
150
+ const YAML = require("yaml");
151
+ const doc = YAML.parse(fs.readFileSync(process.argv[2], "utf8"));
152
+ const providers = (doc && doc.providers) || {};
153
+ // Allow-list: only what the status line ever reads from a provider.
154
+ // workers.yaml is 0600 because providers carry inline `key:` secrets —
155
+ // this cache must never repeat them (or `env`, `url`, `headers`, …).
156
+ const safe = {};
157
+ for (const [name, p] of Object.entries(providers || {})) {
158
+ safe[name] = {
159
+ models: (p && p.models) || {},
160
+ usage: (p && p.usage) || undefined,
161
+ keyFile: (p && p.keyFile) || undefined,
162
+ };
163
+ }
164
+ process.stdout.write(JSON.stringify(safe));
165
+ } catch {
166
+ process.exit(1);
167
+ }
168
+ PAI_YAML_PROVIDERS
169
+ )
170
+ if [ -s "${_providers_cache}.tmp" ]; then
171
+ mv "${_providers_cache}.tmp" "$_providers_cache"
172
+ else
173
+ rm -f "${_providers_cache}.tmp"
174
+ fi
175
+ fi
176
+ [ -s "$_providers_cache" ] && providers_file="$_providers_cache"
177
+ fi
178
+ if [ -z "$providers_file" ] && [ -f "$pai_config" ] && command -v jq >/dev/null 2>&1; then
179
+ _providers_legacy="${pai_cache_dir}/statusline-providers-legacy.json"
180
+ mkdir -p "$pai_cache_dir" 2>/dev/null
181
+ # Same allow-list and 0600-from-birth handling as the YAML path above:
182
+ # this legacy config can carry the same inline `key:` secrets.
183
+ if (umask 077; jq -c '(.workers.providers // {}) | with_entries(.value |= {models, usage, keyFile})' "$pai_config" > "${_providers_legacy}.tmp" 2>/dev/null) \
184
+ && [ -s "${_providers_legacy}.tmp" ]; then
185
+ mv "${_providers_legacy}.tmp" "$_providers_legacy"
186
+ providers_file="$_providers_legacy"
187
+ else
188
+ rm -f "${_providers_legacy}.tmp"
189
+ fi
190
+ fi
191
+
117
192
  model_base="${model_id%%\[*}"
118
193
  session_provider=anthropic
119
- if [ -n "$model_base" ] && [ -f "$pai_config" ] && command -v jq >/dev/null 2>&1; then
194
+ if [ -n "$model_base" ] && [ -n "$providers_file" ] && command -v jq >/dev/null 2>&1; then
120
195
  _matched_provider=$(jq -r --arg mb "$model_base" '
121
- (.workers.providers // {}) | to_entries[] as $p
196
+ (. // {}) | to_entries[] as $p
122
197
  | (($p.value.models // {}) | to_entries[]) as $m
123
198
  | ($m.value | tostring) as $v
124
199
  | select($v == $mb or ($v | split("[")[0]) == $mb)
125
- | $p.key' "$pai_config" 2>/dev/null | head -1)
200
+ | $p.key' "$providers_file" 2>/dev/null | head -1)
126
201
  [ -n "$_matched_provider" ] && session_provider="$_matched_provider"
127
202
  fi
128
203
 
@@ -738,9 +813,9 @@ _fetch_provider_usage() {
738
813
  if [ "$session_provider" != "anthropic" ]; then
739
814
  provider_usage=""
740
815
  provider_keyfile=""
741
- if [ -f "$pai_config" ] && command -v jq >/dev/null 2>&1; then
742
- provider_usage=$(jq -c --arg p "$session_provider" '.workers.providers[$p].usage // empty' "$pai_config" 2>/dev/null)
743
- provider_keyfile=$(jq -r --arg p "$session_provider" '.workers.providers[$p].keyFile // empty' "$pai_config" 2>/dev/null)
816
+ if [ -n "$providers_file" ] && command -v jq >/dev/null 2>&1; then
817
+ provider_usage=$(jq -c --arg p "$session_provider" '.[$p].usage // empty' "$providers_file" 2>/dev/null)
818
+ provider_keyfile=$(jq -r --arg p "$session_provider" '.[$p].keyFile // empty' "$providers_file" 2>/dev/null)
744
819
  case "$provider_keyfile" in "~/"*) provider_keyfile="$HOME/${provider_keyfile#\~/}" ;; esac
745
820
  fi
746
821