@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.
- package/dist/{aibroker-client-D5NT0S1N.mjs → aibroker-client-Bu72yPKj.mjs} +1 -1
- package/dist/{aibroker-client-Bn9SnGMa.mjs → aibroker-client-CDIXmq-e.mjs} +1 -1
- package/dist/{aibroker-client-Bn9SnGMa.mjs.map → aibroker-client-CDIXmq-e.mjs.map} +1 -1
- package/dist/{chain-CNSa0A3G.mjs → chain-BBbVbWVv.mjs} +4 -4
- package/dist/{chain-CNSa0A3G.mjs.map → chain-BBbVbWVv.mjs.map} +1 -1
- package/dist/cli/index.mjs +14 -14
- package/dist/cli/program.mjs +14 -14
- package/dist/config-Bn9jpblO.mjs +653 -0
- package/dist/config-Bn9jpblO.mjs.map +1 -0
- package/dist/{config-DxMZjpvY.mjs → config-DYXARgk8.mjs} +180 -151
- package/dist/config-DYXARgk8.mjs.map +1 -0
- package/dist/config-RjeVGJ7K.mjs +3 -0
- package/dist/{context-handover-cache-B9NKSjwR.mjs → context-handover-cache-yOuK9RIk.mjs} +5 -5
- package/dist/{context-handover-cache-B9NKSjwR.mjs.map → context-handover-cache-yOuK9RIk.mjs.map} +1 -1
- package/dist/daemon/index.mjs +13 -12
- package/dist/daemon/index.mjs.map +1 -1
- package/dist/{daemon-DZ4Q0jrs.mjs → daemon-CBTQYtHF.mjs} +49 -30
- package/dist/daemon-CBTQYtHF.mjs.map +1 -0
- package/dist/daemon-Vf7pHnjR.mjs +25 -0
- package/dist/daemon-mcp/index.mjs +186 -23
- package/dist/daemon-mcp/index.mjs.map +1 -1
- package/dist/factory-Ba8j9K7q.mjs +3 -0
- package/dist/{factory-DNYAqjH0.mjs → factory-Bt7TWQje.mjs} +3 -3
- package/dist/{factory-DNYAqjH0.mjs.map → factory-Bt7TWQje.mjs.map} +1 -1
- package/dist/{fallback-IeR2FrFn.mjs → fallback-CIGspCSi.mjs} +260 -10
- package/dist/fallback-CIGspCSi.mjs.map +1 -0
- package/dist/hooks/capture-all-events.mjs +80 -8
- package/dist/hooks/capture-all-events.mjs.map +4 -4
- package/dist/hooks/context-compression-hook.mjs +54 -39
- package/dist/hooks/context-compression-hook.mjs.map +4 -4
- package/dist/hooks/post-compact-inject.mjs +54 -39
- package/dist/hooks/post-compact-inject.mjs.map +4 -4
- package/dist/hooks/route-agents-to-worker.mjs +204 -109
- package/dist/hooks/route-agents-to-worker.mjs.map +4 -4
- package/dist/hooks/whisper-rules.mjs +7613 -9
- package/dist/hooks/whisper-rules.mjs.map +4 -4
- package/dist/hooks/worker-proxy.mjs +204 -109
- package/dist/hooks/worker-proxy.mjs.map +4 -4
- package/dist/hooks/worker-status-line.mjs +211 -116
- package/dist/hooks/worker-status-line.mjs.map +4 -4
- package/dist/hooks/worker-supervision.mjs +208 -113
- package/dist/hooks/worker-supervision.mjs.map +4 -4
- package/dist/{kg-entity-BSjM5xZh.mjs → kg-entity-BSW7ra8i.mjs} +1 -1
- package/dist/{kg-entity-BSjM5xZh.mjs.map → kg-entity-BSW7ra8i.mjs.map} +1 -1
- package/dist/main-resolver-BTastpp4.mjs +7 -0
- package/dist/{main-resolver-DvYu9jbl.mjs → main-resolver-BmZPCmgj.mjs} +3 -3
- package/dist/{main-resolver-DvYu9jbl.mjs.map → main-resolver-BmZPCmgj.mjs.map} +1 -1
- package/dist/{planner-Cn8HmT7l.mjs → planner-BiH9msqq.mjs} +6 -6
- package/dist/{planner-Cn8HmT7l.mjs.map → planner-BiH9msqq.mjs.map} +1 -1
- package/dist/{program-Bgv1lFFA.mjs → program-BX67-4I2.mjs} +536 -260
- package/dist/program-BX67-4I2.mjs.map +1 -0
- package/dist/query-feedback-Bm6XvCmg.mjs +3 -0
- package/dist/{query-feedback-CYyOGi3y.mjs → query-feedback-CTs_jx6i.mjs} +1 -1
- package/dist/{query-feedback-CYyOGi3y.mjs.map → query-feedback-CTs_jx6i.mjs.map} +1 -1
- package/dist/{run-Cls0YGgV.mjs → run-TChOnMO_.mjs} +1388 -1183
- package/dist/run-TChOnMO_.mjs.map +1 -0
- package/dist/{server-Ch99xt5K.mjs → server-R9UkCn3v.mjs} +42 -3
- package/dist/server-R9UkCn3v.mjs.map +1 -0
- package/dist/session-keepalive-BuG3wvik.mjs +579 -0
- package/dist/session-keepalive-BuG3wvik.mjs.map +1 -0
- package/dist/skills/VaultContext/SKILL.md +1 -1
- package/dist/{state-DnYGRmS7.mjs → state-Ca0ltLgI.mjs} +1 -1
- package/dist/{state-PJrgfDLW.mjs → state-DwrsOoMi.mjs} +1 -1
- package/dist/{state-PJrgfDLW.mjs.map → state-DwrsOoMi.mjs.map} +1 -1
- package/dist/{tools-B77WHeVZ.mjs → tools-BEqiJt_j.mjs} +13 -13
- package/dist/{tools-B77WHeVZ.mjs.map → tools-BEqiJt_j.mjs.map} +1 -1
- package/dist/{tools-NnR_n-Ja.mjs → tools-BwsOTmjh.mjs} +2 -2
- package/dist/{wakeup-UrhiW-KU.mjs → wakeup-Yk5lQ0jb.mjs} +1 -1
- package/dist/{wakeup-UrhiW-KU.mjs.map → wakeup-Yk5lQ0jb.mjs.map} +1 -1
- package/dist/{work-queue-worker-BA5IDBwQ.mjs → work-queue-worker-B9zVWImP.mjs} +3 -3
- package/dist/{work-queue-worker-BA5IDBwQ.mjs.map → work-queue-worker-B9zVWImP.mjs.map} +1 -1
- package/dist/work-queue-worker-D-DnQ7oh.mjs +14 -0
- package/dist/{zettelkasten-BBjA7GcP.mjs → zettelkasten-DezbXghl.mjs} +2 -2
- package/dist/{zettelkasten-BBjA7GcP.mjs.map → zettelkasten-DezbXghl.mjs.map} +1 -1
- package/docs/cache-keepalive.md +138 -0
- package/docs/commands/README.md +7 -0
- package/docs/commands/audit.md +1 -0
- package/docs/commands/config.md +84 -0
- package/docs/commands/daemon.md +6 -0
- package/docs/commands/worker.md +37 -2
- package/docs/config.md +171 -0
- package/docs/model-catalogue.md +502 -0
- package/docs/provider-independence-details.md +1 -1
- package/docs/task-bus.md +1 -1
- package/docs/worker.md +33 -5
- package/docs/workers-config.md +105 -12
- package/package.json +1 -1
- package/src/hooks/ts/capture-all-events.test.ts +71 -0
- package/src/hooks/ts/capture-all-events.ts +10 -3
- package/src/hooks/ts/user-prompt/whisper-rules.test.ts +56 -1
- package/src/hooks/ts/user-prompt/whisper-rules.ts +24 -0
- package/statusline-command.sh +81 -6
- package/dist/config-DCu8THKi.mjs +0 -228
- package/dist/config-DCu8THKi.mjs.map +0 -1
- package/dist/config-DeqFqa1N.mjs +0 -3
- package/dist/config-DxMZjpvY.mjs.map +0 -1
- package/dist/daemon-CYpVy_Dw.mjs +0 -24
- package/dist/daemon-DZ4Q0jrs.mjs.map +0 -1
- package/dist/factory-47Qf_df7.mjs +0 -3
- package/dist/fallback-IeR2FrFn.mjs.map +0 -1
- package/dist/indexer-C5Le7XE7.mjs +0 -1
- package/dist/main-resolver-CnfnWa8C.mjs +0 -7
- package/dist/program-Bgv1lFFA.mjs.map +0 -1
- package/dist/query-feedback-C7rSfiXU.mjs +0 -3
- package/dist/run-Cls0YGgV.mjs.map +0 -1
- package/dist/server-Ch99xt5K.mjs.map +0 -1
- package/dist/work-queue-worker-DLM2wlqM.mjs +0 -14
package/docs/workers-config.md
CHANGED
|
@@ -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 `~/.
|
|
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.
|
|
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
|
|
111
|
-
|
|
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)
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
|
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.
|
|
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
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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";
|
package/statusline-command.sh
CHANGED
|
@@ -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" ] && [ -
|
|
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
|
-
(.
|
|
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' "$
|
|
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 [ -
|
|
742
|
-
provider_usage=$(jq -c --arg p "$session_provider" '.
|
|
743
|
-
provider_keyfile=$(jq -r --arg p "$session_provider" '.
|
|
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
|
|