@tekmidian/pai 0.49.0 → 0.51.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 (109) 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-D9-wfmkZ.mjs → program-srUeFUfW.mjs} +5806 -5615
  51. package/dist/program-srUeFUfW.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 -1
  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/lib/context-fill.test.ts +124 -46
  91. package/src/hooks/ts/lib/context-fill.ts +57 -14
  92. package/src/hooks/ts/user-prompt/whisper-rules.test.ts +56 -1
  93. package/src/hooks/ts/user-prompt/whisper-rules.ts +24 -0
  94. package/statusline-command.sh +103 -16
  95. package/dist/config-DCu8THKi.mjs +0 -228
  96. package/dist/config-DCu8THKi.mjs.map +0 -1
  97. package/dist/config-DeqFqa1N.mjs +0 -3
  98. package/dist/config-DxMZjpvY.mjs.map +0 -1
  99. package/dist/daemon-CYpVy_Dw.mjs +0 -24
  100. package/dist/daemon-DZ4Q0jrs.mjs.map +0 -1
  101. package/dist/factory-47Qf_df7.mjs +0 -3
  102. package/dist/fallback-IeR2FrFn.mjs.map +0 -1
  103. package/dist/indexer-C5Le7XE7.mjs +0 -1
  104. package/dist/main-resolver-CnfnWa8C.mjs +0 -7
  105. package/dist/program-D9-wfmkZ.mjs.map +0 -1
  106. package/dist/query-feedback-C7rSfiXU.mjs +0 -3
  107. package/dist/run-Cls0YGgV.mjs.map +0 -1
  108. package/dist/server-Ch99xt5K.mjs.map +0 -1
  109. 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.49.0",
3
+ "version": "0.51.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
  }
@@ -11,6 +11,7 @@ import {
11
11
  formatContextFill,
12
12
  statuslineStateFilePath,
13
13
  contextFillThresholds,
14
+ configuredTrigger,
14
15
  resolveAutocompactPct,
15
16
  measureCompactionTrigger,
16
17
  modelFamily,
@@ -269,40 +270,33 @@ function readingAt(windowSize: number, source: ContextFillReading["source"] = "s
269
270
  }
270
271
 
271
272
  describe("contextFillThresholds", () => {
272
- // Fixture requested verbatim: override=80 explicit, 1M window → warmUp
273
- // 684,000. That does not hold up: effectiveTrigger = 1,000,000 * 80/100 =
274
- // 800,000, and warmUp = effectiveTrigger - 100,000 = 700,000 by the stated
275
- // formula — 684,000 has no derivation from these inputs, and an EXPLICIT
276
- // override of 80 cannot legitimately produce a different effectiveTrigger
277
- // than an ABSENT override that defaults to the same 80, which the next
278
- // fixture below requires to be 700,000. Asserting 684,000 here would only
279
- // be possible by special-casing "override was explicitly set" versus
280
- // "override defaulted", which the formula gives no basis for. Testing the
281
- // formula as specified instead: both explicit-80 and absent-defaults-to-80
282
- // must agree, and they do.
283
- it("derives warmup from windowSize * (override/100) - 100k, for an explicit override", () => {
273
+ // effectiveTrigger = configuredTrigger(windowSize, pct) = pct/100 *
274
+ // (windowSize - 20,000) — NOT windowSize * pct/100. An EXPLICIT override
275
+ // of 80 and an ABSENT override that defaults to the same 80 must agree,
276
+ // and they do: both give 784,000 on a 1M window, warmUp 684,000.
277
+ it("derives warmup from configuredTrigger(windowSize, override) - 100k, for an explicit override", () => {
284
278
  const t = contextFillThresholds(readingAt(1_000_000), { CLAUDE_AUTOCOMPACT_PCT_OVERRIDE: "80" });
285
- expect(t.effectiveTriggerTokens).toBe(800_000);
286
- expect(t.warmupTokens).toBe(700_000);
279
+ expect(t.effectiveTriggerTokens).toBe(784_000);
280
+ expect(t.warmupTokens).toBe(684_000);
287
281
  });
288
282
 
289
283
  it("derives the same effective trigger when the override is absent (defaults to 80)", () => {
290
284
  const t = contextFillThresholds(readingAt(1_000_000), {});
291
285
  expect(t.autocompactPct).toBe(80);
292
- expect(t.effectiveTriggerTokens).toBe(800_000);
293
- expect(t.warmupTokens).toBe(700_000);
286
+ expect(t.effectiveTriggerTokens).toBe(784_000);
287
+ expect(t.warmupTokens).toBe(684_000);
294
288
  });
295
289
 
296
290
  it("scales down correctly for a 200k window with the override absent", () => {
297
291
  const t = contextFillThresholds(readingAt(200_000), {});
298
- expect(t.effectiveTriggerTokens).toBe(160_000);
299
- expect(t.warmupTokens).toBe(60_000);
292
+ expect(t.effectiveTriggerTokens).toBe(144_000);
293
+ expect(t.warmupTokens).toBe(44_000);
300
294
  });
301
295
 
302
296
  it("computes refresh 40k and immediate 15k below the effective trigger", () => {
303
297
  const t = contextFillThresholds(readingAt(1_000_000), { CLAUDE_AUTOCOMPACT_PCT_OVERRIDE: "80" });
304
- expect(t.refreshTokens).toBe(760_000);
305
- expect(t.immediateTokens).toBe(785_000);
298
+ expect(t.refreshTokens).toBe(744_000);
299
+ expect(t.immediateTokens).toBe(769_000);
306
300
  });
307
301
 
308
302
  it("marks the window confirmed only for a statusline-sourced reading", () => {
@@ -313,8 +307,15 @@ describe("contextFillThresholds", () => {
313
307
  it("clamps a threshold that would go negative instead of returning it", () => {
314
308
  // A tiny window where even the trigger itself is below the warmup margin.
315
309
  const t = contextFillThresholds(readingAt(50_000), { CLAUDE_AUTOCOMPACT_PCT_OVERRIDE: "80" });
316
- expect(t.effectiveTriggerTokens).toBe(40_000);
317
- expect(t.warmupTokens).toBe(0); // 40,000 - 100,000 would be negative
310
+ expect(t.effectiveTriggerTokens).toBe(24_000); // 0.8 * (50,000 - 20,000)
311
+ expect(t.warmupTokens).toBe(0); // 24,000 - 100,000 would be negative
312
+ });
313
+ });
314
+
315
+ describe("configuredTrigger", () => {
316
+ it("is pct/100 * (windowSize - 20,000), not windowSize * pct/100", () => {
317
+ expect(configuredTrigger(1_000_000, 80)).toBe(784_000);
318
+ expect(configuredTrigger(200_000, 80)).toBe(144_000);
318
319
  });
319
320
  });
320
321
 
@@ -570,8 +571,8 @@ describe("contextFillThresholds — trigger source", () => {
570
571
  it("reports triggerSource 'configured' and falls back to the env chain when there is no measured value", () => {
571
572
  const t = contextFillThresholds(readingAt(1_000_000), { CLAUDE_AUTOCOMPACT_PCT_OVERRIDE: "80" }, { measuredTrigger: null });
572
573
  expect(t.triggerSource).toBe("configured");
573
- expect(t.effectiveTriggerTokens).toBe(800_000);
574
- expect(t.warmupTokens).toBe(700_000);
574
+ expect(t.effectiveTriggerTokens).toBe(784_000);
575
+ expect(t.warmupTokens).toBe(684_000);
575
576
  });
576
577
 
577
578
  it("falls back to configured when cwd is given but the project has no compaction history (real scan, empty fixture)", () => {
@@ -583,7 +584,7 @@ describe("contextFillThresholds — trigger source", () => {
583
584
  measuredTrigger: measureCompactionTrigger(cwd, projectsDir),
584
585
  });
585
586
  expect(t.triggerSource).toBe("configured");
586
- expect(t.effectiveTriggerTokens).toBe(800_000);
587
+ expect(t.effectiveTriggerTokens).toBe(784_000);
587
588
  } finally {
588
589
  rmSync(projectsDir, { recursive: true, force: true });
589
590
  }
@@ -600,27 +601,27 @@ describe("contextFillThresholds — trigger source", () => {
600
601
 
601
602
  it("CLAMPS a stale-HIGH measured value down to the configured one — the real CaseLeaf case", () => {
602
603
  // measured=998,267 (this project's real, but stale, most-recent trigger)
603
- // configured=800,000 (80% of a 1,000,000 window)
604
+ // configured=784,000 (80% of a 1,000,000 window, platform formula)
604
605
  const t = contextFillThresholds(readingAt(1_000_000), {}, { measuredTrigger: 998_267 });
605
606
  expect(t.measuredTriggerTokens).toBe(998_267);
606
- expect(t.configuredTriggerTokens).toBe(800_000);
607
+ expect(t.configuredTriggerTokens).toBe(784_000);
607
608
  expect(t.triggerSource).toBe("measured-clamped");
608
- expect(t.effectiveTriggerTokens).toBe(800_000); // min(998267, 800000)
609
- expect(t.warmupTokens).toBe(700_000); // fires well before the real ~784,000 boundary
609
+ expect(t.effectiveTriggerTokens).toBe(784_000); // min(998267, 784000)
610
+ expect(t.warmupTokens).toBe(684_000);
610
611
  });
611
612
 
612
613
  it("uses the measured value directly when it is LOWER than configured — no clamp needed", () => {
613
- // measured=784,000, configured=800,000 -> measured wins, tighter warm-up.
614
- const t = contextFillThresholds(readingAt(1_000_000), {}, { measuredTrigger: 784_000 });
614
+ // measured=700,000, configured=784,000 -> measured wins, tighter warm-up.
615
+ const t = contextFillThresholds(readingAt(1_000_000), {}, { measuredTrigger: 700_000 });
615
616
  expect(t.triggerSource).toBe("measured");
616
- expect(t.effectiveTriggerTokens).toBe(784_000);
617
- expect(t.warmupTokens).toBe(684_000);
617
+ expect(t.effectiveTriggerTokens).toBe(700_000);
618
+ expect(t.warmupTokens).toBe(600_000);
618
619
  });
619
620
 
620
621
  it("uses measured directly when it exactly equals configured (boundary case, not clamped)", () => {
621
- const t = contextFillThresholds(readingAt(1_000_000), {}, { measuredTrigger: 800_000 });
622
+ const t = contextFillThresholds(readingAt(1_000_000), {}, { measuredTrigger: 784_000 });
622
623
  expect(t.triggerSource).toBe("measured");
623
- expect(t.effectiveTriggerTokens).toBe(800_000);
624
+ expect(t.effectiveTriggerTokens).toBe(784_000);
624
625
  });
625
626
 
626
627
  it("reports both raw values on the result even when one of them wasn't used, so the clamp is visible", () => {
@@ -628,7 +629,7 @@ describe("contextFillThresholds — trigger source", () => {
628
629
  // Both numbers are on the object — a caller can see what was measured
629
630
  // AND what was configured, not just the winner.
630
631
  expect(t.measuredTriggerTokens).toBe(998_267);
631
- expect(t.configuredTriggerTokens).toBe(800_000);
632
+ expect(t.configuredTriggerTokens).toBe(784_000);
632
633
  });
633
634
  });
634
635
 
@@ -642,36 +643,113 @@ describe("contextFillThresholds — trigger source", () => {
642
643
  // ---------------------------------------------------------------------------
643
644
 
644
645
  describe("contextFillThresholds — 2026-09-20 regime change (override 80 -> 20)", () => {
645
- it("measured 784k + configured 200k (override=20, 1M window) -> clamps to 200k", () => {
646
+ it("measured 784k + configured 196k (override=20, 1M window) -> clamps to 196k", () => {
646
647
  const t = contextFillThresholds(readingAt(1_000_000), { CLAUDE_AUTOCOMPACT_PCT_OVERRIDE: "20" }, { measuredTrigger: 784_000 });
647
- expect(t.configuredTriggerTokens).toBe(200_000);
648
+ expect(t.configuredTriggerTokens).toBe(196_000); // 0.2 * (1,000,000 - 20,000)
648
649
  expect(t.triggerSource).toBe("measured-clamped");
649
- expect(t.effectiveTriggerTokens).toBe(200_000);
650
- expect(t.warmupTokens).toBe(100_000); // 200,000 - 100,000
650
+ expect(t.effectiveTriggerTokens).toBe(196_000);
651
+ expect(t.warmupTokens).toBe(96_000); // 196,000 - 100,000
651
652
  });
652
653
 
653
- it("measured 784k + configured 800k (override=80, 1M window) -> measured wins at 784k", () => {
654
+ it("measured 784k + configured 784k (override=80, 1M window) -> measured wins at 784k", () => {
654
655
  const t = contextFillThresholds(readingAt(1_000_000), { CLAUDE_AUTOCOMPACT_PCT_OVERRIDE: "80" }, { measuredTrigger: 784_000 });
655
- expect(t.configuredTriggerTokens).toBe(800_000);
656
+ expect(t.configuredTriggerTokens).toBe(784_000);
656
657
  expect(t.triggerSource).toBe("measured");
657
658
  expect(t.effectiveTriggerTokens).toBe(784_000);
658
659
  });
659
660
 
660
- it("measured null + configured 200k (override=20, no compaction history yet) -> 200k", () => {
661
+ it("measured null + configured 196k (override=20, no compaction history yet) -> 196k", () => {
661
662
  const t = contextFillThresholds(readingAt(1_000_000), { CLAUDE_AUTOCOMPACT_PCT_OVERRIDE: "20" }, { measuredTrigger: null });
662
663
  expect(t.triggerSource).toBe("configured");
663
- expect(t.effectiveTriggerTokens).toBe(200_000);
664
+ expect(t.effectiveTriggerTokens).toBe(196_000);
664
665
  });
665
666
 
666
- it("measured 784k + override unset (defaults to 80, configured 800k) -> measured wins at 784k", () => {
667
+ it("measured 784k + override unset (defaults to 80, configured 784k) -> measured wins at 784k", () => {
667
668
  const t = contextFillThresholds(readingAt(1_000_000), {}, { measuredTrigger: 784_000 });
668
669
  expect(t.autocompactPct).toBe(80);
669
- expect(t.configuredTriggerTokens).toBe(800_000);
670
+ expect(t.configuredTriggerTokens).toBe(784_000);
670
671
  expect(t.triggerSource).toBe("measured");
671
672
  expect(t.effectiveTriggerTokens).toBe(784_000);
672
673
  });
673
674
  });
674
675
 
676
+ // ---------------------------------------------------------------------------
677
+ // measureCompactionTrigger — admissibility filter (Fault 1, 2026-09-20):
678
+ // samples from a different override regime must not win just because they
679
+ // are numerically <= the CURRENT configured trigger. A compaction that
680
+ // fired far below the current configured trigger came from a lower
681
+ // override or a smaller window; it is discarded, not averaged in.
682
+ // ---------------------------------------------------------------------------
683
+
684
+ describe("measureCompactionTrigger — other-regime samples are discarded", () => {
685
+ it("discards all samples below half of configured and falls back to configured (measured=null, source configured)", () => {
686
+ const projectsDir = mkdtempSync(join(tmpdir(), "pai-measured-trigger-test-"));
687
+ const cwd = "/fake/project/other-regime-all-discarded";
688
+ const projectDir = join(projectsDir, encodeForFixture(cwd));
689
+ mkdirSync(projectDir, { recursive: true });
690
+ writeFileSync(
691
+ join(projectDir, "a.jsonl"),
692
+ [
693
+ compactBoundaryLine(196_765, "2026-09-20T13:10:00.000Z", "u1"),
694
+ compactBoundaryLine(195_075, "2026-09-20T15:00:00.000Z", "u2"),
695
+ compactBoundaryLine(197_902, "2026-09-20T19:00:00.000Z", "u3"),
696
+ ].join("\n") + "\n"
697
+ );
698
+ try {
699
+ // configured = configuredTrigger(1,000,000, 80) = 784,000; floor = 392,000.
700
+ const measured = measureCompactionTrigger(cwd, projectsDir, undefined, 784_000);
701
+ expect(measured).toBeNull();
702
+
703
+ const t = contextFillThresholds(readingAt(1_000_000), { CLAUDE_AUTOCOMPACT_PCT_OVERRIDE: "80" }, { measuredTrigger: measured });
704
+ expect(t.triggerSource).toBe("configured");
705
+ expect(t.effectiveTriggerTokens).toBe(784_000);
706
+ } finally {
707
+ rmSync(projectsDir, { recursive: true, force: true });
708
+ }
709
+ });
710
+
711
+ it("keeps admissible samples and discards only the other-regime one (measured=780,000, source measured)", () => {
712
+ const projectsDir = mkdtempSync(join(tmpdir(), "pai-measured-trigger-test-"));
713
+ const cwd = "/fake/project/other-regime-partial";
714
+ const projectDir = join(projectsDir, encodeForFixture(cwd));
715
+ mkdirSync(projectDir, { recursive: true });
716
+ writeFileSync(
717
+ join(projectDir, "a.jsonl"),
718
+ [
719
+ compactBoundaryLine(780_000, "2026-09-19T09:00:00.000Z", "u1"),
720
+ compactBoundaryLine(790_000, "2026-09-19T10:00:00.000Z", "u2"),
721
+ compactBoundaryLine(195_000, "2026-09-20T15:00:00.000Z", "u3"), // other-regime, discarded
722
+ ].join("\n") + "\n"
723
+ );
724
+ try {
725
+ const measured = measureCompactionTrigger(cwd, projectsDir, undefined, 784_000);
726
+ expect(measured).toBe(780_000);
727
+
728
+ const t = contextFillThresholds(readingAt(1_000_000), { CLAUDE_AUTOCOMPACT_PCT_OVERRIDE: "80" }, { measuredTrigger: measured });
729
+ expect(t.triggerSource).toBe("measured");
730
+ expect(t.effectiveTriggerTokens).toBe(780_000);
731
+ } finally {
732
+ rmSync(projectsDir, { recursive: true, force: true });
733
+ }
734
+ });
735
+
736
+ it("applies no filter at all when configuredTriggerTokens is omitted (back-compat with the raw scan)", () => {
737
+ const projectsDir = mkdtempSync(join(tmpdir(), "pai-measured-trigger-test-"));
738
+ const cwd = "/fake/project/no-filter";
739
+ const projectDir = join(projectsDir, encodeForFixture(cwd));
740
+ mkdirSync(projectDir, { recursive: true });
741
+ writeFileSync(
742
+ join(projectDir, "a.jsonl"),
743
+ compactBoundaryLine(195_000, "2026-09-20T15:00:00.000Z", "u1") + "\n"
744
+ );
745
+ try {
746
+ expect(measureCompactionTrigger(cwd, projectsDir)).toBe(195_000);
747
+ } finally {
748
+ rmSync(projectsDir, { recursive: true, force: true });
749
+ }
750
+ });
751
+ });
752
+
675
753
  // ---------------------------------------------------------------------------
676
754
  // Foreign-model transcripts — a headless worker on another provider (a
677
755
  // different context window) shares the project folder; its compactions must