@tekmidian/pai 0.36.1 → 0.37.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 (157) hide show
  1. package/dist/{auto-route-Byf8ENXj.mjs → auto-route-DM7GhJ8y.mjs} +2 -2
  2. package/dist/{auto-route-Byf8ENXj.mjs.map → auto-route-DM7GhJ8y.mjs.map} +1 -1
  3. package/dist/cli/index.mjs +5 -4
  4. package/dist/cli/index.mjs.map +1 -1
  5. package/dist/cli/program.d.mts.map +1 -1
  6. package/dist/cli/program.mjs +5 -4
  7. package/dist/{clusters-wZgTCYCB.mjs → clusters-Do4tEGyc.mjs} +1 -1
  8. package/dist/{clusters-wZgTCYCB.mjs.map → clusters-Do4tEGyc.mjs.map} +1 -1
  9. package/dist/{context-handover-cache-PtNvj_8D.mjs → context-handover-cache-BpUojjsi.mjs} +2 -2
  10. package/dist/{context-handover-cache-PtNvj_8D.mjs.map → context-handover-cache-BpUojjsi.mjs.map} +1 -1
  11. package/dist/daemon/index.mjs +8 -8
  12. package/dist/{daemon-BZ93KRBo.mjs → daemon-CGg1VCbA.mjs} +25 -25
  13. package/dist/{daemon-BZ93KRBo.mjs.map → daemon-CGg1VCbA.mjs.map} +1 -1
  14. package/dist/daemon-DsGGiIJM.mjs +20 -0
  15. package/dist/daemon-mcp/index.mjs +428 -1
  16. package/dist/daemon-mcp/index.mjs.map +1 -1
  17. package/dist/{detector--Gg5JRN5.mjs → detector-CMap-9vw.mjs} +1 -1
  18. package/dist/{detector--Gg5JRN5.mjs.map → detector-CMap-9vw.mjs.map} +1 -1
  19. package/dist/detector-DtLExmHN.mjs +5 -0
  20. package/dist/factory-BXzqRYVZ.mjs +3 -0
  21. package/dist/{factory-Bsp7xOpO.mjs → factory-DD2T33C9.mjs} +5 -5
  22. package/dist/{factory-Bsp7xOpO.mjs.map → factory-DD2T33C9.mjs.map} +1 -1
  23. package/dist/hooks/context-compression-hook.mjs +164 -14
  24. package/dist/hooks/context-compression-hook.mjs.map +4 -4
  25. package/dist/hooks/initialize-session.mjs +6 -0
  26. package/dist/hooks/initialize-session.mjs.map +3 -3
  27. package/dist/hooks/load-core-context.mjs +6 -0
  28. package/dist/hooks/load-core-context.mjs.map +3 -3
  29. package/dist/hooks/load-project-context.mjs +6 -0
  30. package/dist/hooks/load-project-context.mjs.map +3 -3
  31. package/dist/hooks/post-compact-inject.mjs +48 -7
  32. package/dist/hooks/post-compact-inject.mjs.map +4 -4
  33. package/dist/hooks/route-agents-to-worker.mjs +456 -0
  34. package/dist/hooks/route-agents-to-worker.mjs.map +7 -0
  35. package/dist/hooks/status-line.mjs +400 -0
  36. package/dist/hooks/status-line.mjs.map +7 -0
  37. package/dist/hooks/stop-hook.mjs +6 -0
  38. package/dist/hooks/stop-hook.mjs.map +3 -3
  39. package/dist/hooks/subagent-stop-hook.mjs +6 -0
  40. package/dist/hooks/subagent-stop-hook.mjs.map +3 -3
  41. package/dist/hooks/sync-todo-to-md.mjs +6 -0
  42. package/dist/hooks/sync-todo-to-md.mjs.map +3 -3
  43. package/dist/hooks/update-tab-on-action.mjs +6 -0
  44. package/dist/hooks/update-tab-on-action.mjs.map +3 -3
  45. package/dist/hooks/update-tab-titles.mjs +6 -0
  46. package/dist/hooks/update-tab-titles.mjs.map +3 -3
  47. package/dist/hooks/worker-proxy.mjs +796 -0
  48. package/dist/hooks/worker-proxy.mjs.map +7 -0
  49. package/dist/hooks/worker-status-line.mjs +483 -0
  50. package/dist/hooks/worker-status-line.mjs.map +7 -0
  51. package/dist/{indexer-backend-nQZuEx6N.mjs → indexer-backend-Cox9BCo-.mjs} +1 -1
  52. package/dist/{indexer-backend-nQZuEx6N.mjs.map → indexer-backend-Cox9BCo-.mjs.map} +1 -1
  53. package/dist/{kg-entity-DbOMPdF9.mjs → kg-entity-r8duqhi9.mjs} +1 -1
  54. package/dist/{kg-entity-DbOMPdF9.mjs.map → kg-entity-r8duqhi9.mjs.map} +1 -1
  55. package/dist/{latent-ideas-Bn6A5-5P.mjs → latent-ideas-BC1oINZ-.mjs} +2 -2
  56. package/dist/{latent-ideas-Bn6A5-5P.mjs.map → latent-ideas-BC1oINZ-.mjs.map} +1 -1
  57. package/dist/{link-boost-fYjUnxCN.mjs → link-boost-QFLrJwD6.mjs} +1 -1
  58. package/dist/{link-boost-fYjUnxCN.mjs.map → link-boost-QFLrJwD6.mjs.map} +1 -1
  59. package/dist/{main-resolver-BAbhKpeX.mjs → main-resolver-DlaLOFBA.mjs} +1 -1
  60. package/dist/{main-resolver-BAbhKpeX.mjs.map → main-resolver-DlaLOFBA.mjs.map} +1 -1
  61. package/dist/{main-resolver-Dxh444GO.mjs → main-resolver-IhZo4pI0.mjs} +1 -1
  62. package/dist/{neighborhood-DpaEM991.mjs → neighborhood-D9MJ1c8f.mjs} +1 -1
  63. package/dist/{neighborhood-DpaEM991.mjs.map → neighborhood-D9MJ1c8f.mjs.map} +1 -1
  64. package/dist/{note-context-DrcY4cWm.mjs → note-context-d1wT_-GA.mjs} +1 -1
  65. package/dist/{note-context-DrcY4cWm.mjs.map → note-context-d1wT_-GA.mjs.map} +1 -1
  66. package/dist/{postgres-BVme6qX0.mjs → postgres--BjPtLa0.mjs} +1 -1
  67. package/dist/{postgres-BVme6qX0.mjs.map → postgres--BjPtLa0.mjs.map} +1 -1
  68. package/dist/{program-C-fUghPv.mjs → program-CEIHn_Ma.mjs} +693 -101
  69. package/dist/program-CEIHn_Ma.mjs.map +1 -0
  70. package/dist/providers-sXcK5bDZ.mjs +3067 -0
  71. package/dist/providers-sXcK5bDZ.mjs.map +1 -0
  72. package/dist/{query-feedback-C1T6kS18.mjs → query-feedback-BUJxgw5B.mjs} +1 -1
  73. package/dist/{query-feedback-C1T6kS18.mjs.map → query-feedback-BUJxgw5B.mjs.map} +1 -1
  74. package/dist/query-feedback-DhyLOe5S.mjs +3 -0
  75. package/dist/router-1zi8jiNF.mjs +3 -0
  76. package/dist/{router-CsDm7HvK.mjs → router-DK_sLsUL.mjs} +1 -1
  77. package/dist/{router-CsDm7HvK.mjs.map → router-DK_sLsUL.mjs.map} +1 -1
  78. package/dist/skills/Worker/SKILL.md +41 -0
  79. package/dist/{sources-D8ZdNfvK.mjs → sources-Bi7--33T.mjs} +1 -1
  80. package/dist/{sources-D8ZdNfvK.mjs.map → sources-Bi7--33T.mjs.map} +1 -1
  81. package/dist/{sqlite-D1IaR8Am.mjs → sqlite-DtaL1glm.mjs} +1 -1
  82. package/dist/{sqlite-D1IaR8Am.mjs.map → sqlite-DtaL1glm.mjs.map} +1 -1
  83. package/dist/{state-qtmrBWCm.mjs → state-8Hm9E4tW.mjs} +1 -1
  84. package/dist/{state-WaXhLr6R.mjs → state-BY2L6-vX.mjs} +1 -1
  85. package/dist/{state-WaXhLr6R.mjs.map → state-BY2L6-vX.mjs.map} +1 -1
  86. package/dist/{themes-XPkj_bfP.mjs → themes-BN0a2duq.mjs} +1 -1
  87. package/dist/{themes-XPkj_bfP.mjs.map → themes-BN0a2duq.mjs.map} +1 -1
  88. package/dist/{tools-DEt6YPfc.mjs → tools-CGPqpU3A.mjs} +1 -1
  89. package/dist/{tools-ceiy7ANX.mjs → tools-y2bJpKom.mjs} +14 -14
  90. package/dist/{tools-ceiy7ANX.mjs.map → tools-y2bJpKom.mjs.map} +1 -1
  91. package/dist/{trace-DfyGmMG_.mjs → trace-h23JCcFD.mjs} +1 -1
  92. package/dist/{trace-DfyGmMG_.mjs.map → trace-h23JCcFD.mjs.map} +1 -1
  93. package/dist/{vault-indexer-CFvlPUMB.mjs → vault-indexer-DgsPjMgs.mjs} +1 -1
  94. package/dist/{vault-indexer-CFvlPUMB.mjs.map → vault-indexer-DgsPjMgs.mjs.map} +1 -1
  95. package/dist/{work-queue-worker-B8W8_3Rn.mjs → work-queue-worker-Dva_v_pI.mjs} +4 -4
  96. package/dist/{work-queue-worker-B8W8_3Rn.mjs.map → work-queue-worker-Dva_v_pI.mjs.map} +1 -1
  97. package/dist/{work-queue-worker-HN2Ufg-L.mjs → work-queue-worker-gsKd2LJa.mjs} +4 -4
  98. package/dist/{zettelkasten-CvjmMghT.mjs → zettelkasten-vo7psPdT.mjs} +3 -3
  99. package/dist/{zettelkasten-CvjmMghT.mjs.map → zettelkasten-vo7psPdT.mjs.map} +1 -1
  100. package/docs/commands/README.md +28 -0
  101. package/docs/commands/backup.md +1 -1
  102. package/docs/commands/clear-names.md +1 -1
  103. package/docs/commands/daemon.md +1 -1
  104. package/docs/commands/db.md +1 -1
  105. package/docs/commands/end.md +1 -1
  106. package/docs/commands/help.md +1 -1
  107. package/docs/commands/identity.md +1 -1
  108. package/docs/commands/kg.md +1 -1
  109. package/docs/commands/mcp.md +1 -1
  110. package/docs/commands/memory.md +1 -1
  111. package/docs/commands/notify.md +1 -1
  112. package/docs/commands/observation.md +1 -1
  113. package/docs/commands/obsidian.md +1 -1
  114. package/docs/commands/pause.md +1 -1
  115. package/docs/commands/project.md +1 -1
  116. package/docs/commands/projects.md +1 -1
  117. package/docs/commands/registry.md +1 -1
  118. package/docs/commands/restore.md +1 -1
  119. package/docs/commands/session.md +1 -1
  120. package/docs/commands/sessions.md +1 -1
  121. package/docs/commands/setup.md +1 -1
  122. package/docs/commands/shell-init.md +1 -1
  123. package/docs/commands/skill.md +1 -1
  124. package/docs/commands/task.md +1 -1
  125. package/docs/commands/topic.md +1 -1
  126. package/docs/commands/update.md +1 -1
  127. package/docs/commands/worker.md +361 -0
  128. package/docs/commands/zettel.md +1 -1
  129. package/docs/worker.md +303 -0
  130. package/package.json +1 -1
  131. package/scripts/build-hooks.mjs +46 -1
  132. package/src/hooks/pre-compact.sh +1 -0
  133. package/src/hooks/session-autosave.sh +1 -0
  134. package/src/hooks/session-stop.sh +1 -0
  135. package/src/hooks/ts/lib/context-fill.test.ts +104 -0
  136. package/src/hooks/ts/lib/context-fill.ts +73 -0
  137. package/src/hooks/ts/lib/handover-evidence.ts +227 -0
  138. package/src/hooks/ts/lib/worker-session.test.ts +24 -0
  139. package/src/hooks/ts/lib/worker-session.ts +23 -0
  140. package/src/hooks/ts/post-tool-use/sync-todo-to-md.ts +2 -0
  141. package/src/hooks/ts/post-tool-use/update-tab-on-action.ts +2 -0
  142. package/src/hooks/ts/pre-compact/context-compression-hook.ts +17 -0
  143. package/src/hooks/ts/pre-tool-use/route-agents-to-worker.ts +128 -0
  144. package/src/hooks/ts/session-start/initialize-session.ts +2 -0
  145. package/src/hooks/ts/session-start/load-core-context.ts +2 -0
  146. package/src/hooks/ts/session-start/load-project-context.ts +2 -0
  147. package/src/hooks/ts/session-start/post-compact-inject.ts +13 -0
  148. package/src/hooks/ts/stop/stop-hook.ts +2 -0
  149. package/src/hooks/ts/subagent-stop/subagent-stop-hook.ts +2 -0
  150. package/src/hooks/ts/user-prompt/update-tab-titles.ts +2 -0
  151. package/statusline-command.sh +17 -0
  152. package/dist/daemon-DRdoA489.mjs +0 -20
  153. package/dist/detector-DGAk1iBR.mjs +0 -5
  154. package/dist/factory-CrokPMk2.mjs +0 -3
  155. package/dist/program-C-fUghPv.mjs.map +0 -1
  156. package/dist/query-feedback-BBMBp96K.mjs +0 -3
  157. package/dist/router-BMkOb62X.mjs +0 -3
package/docs/worker.md ADDED
@@ -0,0 +1,303 @@
1
+ # Worker Providers
2
+
3
+ PAI can run every subagent on a provider you configure — any endpoint that
4
+ speaks the Anthropic API, any endpoint that speaks the OpenAI Chat
5
+ Completions API (through the built-in proxy), or the Codex CLI — instead of
6
+ on the Anthropic account of the main session. This replaces the earlier
7
+ hard-wired `glm` wrapper with something provider-neutral, and keeps the same
8
+ daily commands.
9
+
10
+ ```
11
+ main session (Anthropic) workers (configured provider)
12
+ ┌──────────────────────┐ ┌──────────────────────────┐
13
+ │ orchestration, │ deny │ pai worker run … │
14
+ │ review, synthesis │ ───────▶ │ (claude -p, headless, │
15
+ └──────────────────────┘ Agent │ strict MCP, streamed, │
16
+ tool │ followed in a pane) │
17
+ └──────────────────────────┘
18
+ ```
19
+
20
+ ## Config
21
+
22
+ `~/.config/pai/config.json`, `workers` section:
23
+
24
+ ```json
25
+ {
26
+ "workers": {
27
+ "enabled": true,
28
+ "active": "glm",
29
+ "providers": {
30
+ "glm": {
31
+ "baseUrl": "https://api.z.ai/api/anthropic",
32
+ "keyFile": "~/.config/zai/api_key",
33
+ "models": { "default": "glm-5.3", "fast": "glm-5.3-flash" },
34
+ "env": { "API_TIMEOUT_MS": "3000000" },
35
+ "contextWindow": 200000
36
+ },
37
+ "oai": {
38
+ "protocol": "openai",
39
+ "upstreamUrl": "https://api.openai.com/v1",
40
+ "keyFile": "~/.config/pai/keys/oai",
41
+ "models": { "default": "gpt-5.2", "fast": "gpt-5.2-mini" }
42
+ },
43
+ "codexprov": {
44
+ "engine": "codex",
45
+ "models": { "default": "gpt-5.2-codex" }
46
+ }
47
+ },
48
+ "roles": {
49
+ "implement": "glm",
50
+ "research": "glm",
51
+ "spotcheck": "glm/fast",
52
+ "docs": { "provider": "glm", "mcp": ["office"] }
53
+ },
54
+ "mcpSets": {
55
+ "office": ["memory", "github"],
56
+ "tiny": ["fetcher"]
57
+ },
58
+ "pane": { "enabled": true, "fontSize": 13, "autoExitSecs": 60 },
59
+ "logDir": "~/.claude/logs/workers",
60
+ "routing": { "order": [], "cooldownMinutes": 30, "retryOnQuota": true }
61
+ }
62
+ }
63
+ ```
64
+
65
+ - `keyFile` holds the API token (chmod 600). Keys never go into the config.
66
+ - `active` is one provider name, or `"auto"` to walk `routing.order`.
67
+ - `protocol: "openai"` routes the provider through the built-in proxy (next
68
+ section); it needs `upstreamUrl`, the Chat Completions base.
69
+ - `engine: "codex"` runs the provider on the Codex CLI instead of Claude
70
+ Code (see below).
71
+ - `contextWindow` overrides the context-meter window when the endpoint's
72
+ init event does not announce one (default 200 000).
73
+ - A role target is `"provider[/model]"` or an object with `provider` and a
74
+ `mcp` allowlist applied on top of `--mcp`.
75
+
76
+ Or add one from the CLI:
77
+
78
+ ```
79
+ pai worker providers add glm \
80
+ --base-url https://api.z.ai/api/anthropic \
81
+ --key-file ~/.config/zai/api_key \
82
+ --model glm-5.3 --fast-model glm-5.3-flash \
83
+ --env API_TIMEOUT_MS=3000000
84
+ pai worker providers add oai \
85
+ --upstream-url https://api.openai.com/v1 \
86
+ --key-file ~/.config/pai/keys/oai --model gpt-5.2
87
+ ```
88
+
89
+ The first provider also sets `enabled: true`, makes itself active and seeds
90
+ the three roles. Then:
91
+
92
+ ```
93
+ pai worker install # Agent hook in settings.json + glm* shims + cleanup
94
+ ```
95
+
96
+ ## The proxy (OpenAI-protocol providers)
97
+
98
+ A provider with `protocol: "openai"` cannot be talked to by Claude Code
99
+ directly, so PAI ships a translating proxy: Anthropic Messages API on the
100
+ front (loopback only), OpenAI Chat Completions on the back. System prompts,
101
+ multi-turn text, tool_use/tool_result ↔ tool_calls, tools ↔ functions,
102
+ streaming SSE (including streamed tool-call arguments), usage and error
103
+ mapping (429 → `rate_limit_error`, 401 → `authentication_error`, 5xx →
104
+ `api_error`) are translated in both directions.
105
+
106
+ - One proxy serves every openai provider: the provider name in the URL path
107
+ selects the upstream. `run` points `ANTHROPIC_BASE_URL` at
108
+ `http://127.0.0.1:8797/<provider>` and starts the proxy on demand
109
+ (detached, pid file under the logDir). The worker config is re-read per
110
+ request, so provider edits apply without a restart.
111
+ - The proxy holds the real token (from the provider's `keyFile`) and injects
112
+ it upstream; the worker itself runs with a placeholder, so a leaked worker
113
+ env leaks nothing.
114
+ - `pai worker proxy [--port N]` starts it by hand (default 8797, loopback
115
+ only), `pai worker proxy stop` stops it again. `providers test` on an
116
+ openai provider goes through the proxy too.
117
+
118
+ ## The codex engine
119
+
120
+ A ChatGPT plan gives no API key, only Codex CLI access — such a provider
121
+ sets `engine: "codex"` and `run` shells out to `codex exec --json <prompt>`
122
+ (non-interactive) instead of Claude Code. The Codex JSONL events are folded
123
+ into the same status fields and the same transcript shape, so `ps`, `follow`,
124
+ `replay`, the ledger and the final `--output-format` print work unchanged.
125
+
126
+ Differences: `--allowedTools` and MCP flags have no Codex equivalent and are
127
+ dropped with a `WORKER-NOTE` ledger line; resume continues a Claude session,
128
+ so it is unavailable for codex workers (their thread id is kept, but
129
+ `pai worker resume` refuses with an explanation). `providers test` reports
130
+ `codex not installed` (exit 0) when the CLI is missing.
131
+
132
+ ## Daily use
133
+
134
+ ```
135
+ pai worker run --label "fix black buttons" -p '<task spec>' \
136
+ --allowedTools 'Read,Edit,Write,Bash,Grep,Glob' --output-format json \
137
+ --mcp office
138
+ pai worker ps # this session's workers
139
+ pai worker follow [id] # live transcript (type to talk to it)
140
+ pai worker replay <id> # transcript of one worker
141
+ pai worker say <id> "<text>" # message a running worker
142
+ pai worker resume <id> "<text>" # continue a finished one, context intact
143
+ pai worker mcp list # MCP servers + sets usable in --mcp
144
+ pai worker proxy [--port N|stop] # the translating proxy, by hand
145
+ pai worker log [all|tail|<id>] # raw streams + routing ledger
146
+ ```
147
+
148
+ Roles pick the provider for a task class: `--role implement|research|spotcheck`.
149
+ `--no-pane` suppresses the iTerm follow pane; `--provider <name>` bypasses
150
+ roles entirely. If you bring your own `--append-system-prompt`, the worker
151
+ contract below is added alongside it, not instead.
152
+
153
+ The old habits keep working: `glm`, `glm-run`, `glm-ps`, `glm-log` are shims
154
+ to the pai commands (`pai worker install` moves any previous versions to
155
+ `<name>.pre-pai`).
156
+
157
+ ## Routing
158
+
159
+ A run resolves its provider as: `--provider` > `--role` > `active`.
160
+
161
+ With `active: "auto"`, providers are tried in `routing.order`, skipping:
162
+
163
+ - disabled providers,
164
+ - providers in a cooldown (set for `cooldownMinutes` after a quota failure),
165
+ - providers whose `quotaProbe` URL reports ≥ `quotaSkipAt` (default 95).
166
+
167
+ A quota failure before the first tool call is re-run on the next provider and
168
+ logged as `WORKER-REROUTE`. `pai worker providers enable <name>` clears a
169
+ cooldown by hand.
170
+
171
+ ## What a worker is
172
+
173
+ - One `claude -p … --output-format stream-json --verbose` process per call,
174
+ run with `--input-format stream-json` and its stdin held open: the task
175
+ arrives as the first user message on stdin, and further lines (see `say`
176
+ below) continue the conversation while it runs.
177
+ - Env: `ANTHROPIC_BASE_URL`/`ANTHROPIC_AUTH_TOKEN` from the provider (token
178
+ read from `keyFile`, never from the environment; openai providers point at
179
+ the local proxy instead), `ANTHROPIC_API_KEY` stripped so nothing can fall
180
+ back to Anthropic billing, the three `ANTHROPIC_DEFAULT_*_MODEL` vars, the
181
+ provider's `env`, and `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1`.
182
+ - Headless runs get `--strict-mcp-config --mcp-config <config>` and
183
+ `PAI_WORKER=1` so PAI's per-session hooks leave them alone. Interactive
184
+ runs keep full MCP and get `ENABLE_TOOL_SEARCH=true`.
185
+ - Every mirrored event carries an ISO `_ts` stamp; the stream lands in
186
+ `<logDir>/<id>.jsonl`, live state in `<id>.status`, every event in
187
+ `<logDir>/ledger.log`.
188
+
189
+ ### The worker contract
190
+
191
+ Headless runs append a system prompt that fixes the shape of the final
192
+ answer: act, verify, then stop with ONE JSON message
193
+
194
+ ```json
195
+ {"changed":[{"path":"…","summary":"…"}],"commands":["…"],
196
+ "checks":[{"name":"…","ok":true,"detail":"…"}],"open":["…"],"notes":"one line"}
197
+ ```
198
+
199
+ The runner parses it: `notes` becomes the one-line `last` the table shows,
200
+ and `--output-format json` carries the parsed `report` next to the raw
201
+ `result`. `follow`/`replay` render it as a compact block (changed paths,
202
+ ✓/✗ checks, open items). A final message that is not the contract stays raw
203
+ text — nothing is lost either way.
204
+
205
+ ### Talking to a worker (say / resume)
206
+
207
+ While a headless worker runs, `pai worker say <id> "<text>"` (or the MCP
208
+ tool `worker_say`) forwards the text to the child as a user message over the
209
+ per-worker Unix socket `<logDir>/<id>.sock`; it is mirrored into the
210
+ transcript as an `operator` event (`»` marker). After the worker's result,
211
+ stdin closes two seconds later unless another message arrives — after that
212
+ `say` refuses and points at `resume`.
213
+
214
+ `pai worker resume <id> "<text>"` continues the same Claude session (the id
215
+ recorded from the init event) on the same provider, labelled `↩ <original>`,
216
+ and prints a fresh worker id with `--print-id`. A pane running `follow` reads
217
+ its own stdin the same way: type to say while it runs, or to resume after it
218
+ finished.
219
+
220
+ ### Context meter
221
+
222
+ Status files carry `contextTokens` (input + cache read + cache creation +
223
+ output of the last assistant turn) and `contextWindow` (from the init event,
224
+ else the provider's `contextWindow`, else 200 000). The `ps` table and the
225
+ status line show `ctx 84k/200k (42%)` once it passes 60 % — yellow past
226
+ 70 %, red past 85 % — and the pane's liveness line always shows it.
227
+
228
+ ### MCP for workers
229
+
230
+ Headless workers start with **no MCP servers by default**: every server
231
+ definition lands in the system prompt and costs context (and often a
232
+ startup process) before the worker has done anything. When a task genuinely
233
+ needs servers, opt in per run:
234
+
235
+ ```
236
+ pai worker run --mcp office … # a set, or names: --mcp memory,github
237
+ ```
238
+
239
+ `--mcp` takes server names and/or `mcpSets` names (comma-separated,
240
+ repeatable); the filtered config is written from `~/.claude.json`'s
241
+ `mcpServers` to `<logDir>/<id>.mcp.json` and passed with
242
+ `--strict-mcp-config --mcp-config`. Role targets may add `"mcp": ["office"]`
243
+ on top. An unknown name fails fast, listing what exists;
244
+ `pai worker mcp list` shows servers and sets. A caller-provided
245
+ `--mcp-config` always wins; MCP is chosen at launch, not mid-run.
246
+
247
+ ## Scoping (who sees whose workers)
248
+
249
+ `ps`/`follow`/status line show the workers of the asking terminal:
250
+
251
+ 1. AIBroker session id (from `~/.aibroker/session-names.json`) — every pane of
252
+ a named session sees its workers,
253
+ 2. else the iTerm tab key (`w<n>t<n>` of `ITERM_SESSION_ID`).
254
+
255
+ `--all` (or no iTerm at all) widens to every worker.
256
+
257
+ ## Follow, replay and the pane
258
+
259
+ `follow` renders the live transcript with a `HH:MM:SS │ ` gutter (dim; the
260
+ worker's short id in front when several run at once, a `── date ──`
261
+ separator when the day changes) and, on a TTY, a liveness line
262
+ `⋯ 12s since last event · Bash: npm test` that is overwritten in place and
263
+ erased before the next event. `replay` shows a finished transcript with the
264
+ same gutter.
265
+
266
+ The pane command is `exec pai worker follow …` so the pane holds exactly one
267
+ process — signals reach the follow directly, and when the worker finishes
268
+ the pane counts down its `auto-exit` (default 60 s, `pane.autoExitSecs`).
269
+
270
+ Panes run under the `pai-worker` dynamic profile, written to
271
+ `~/Library/Application Support/iTerm2/DynamicProfiles/pai-worker.json`:
272
+ the font family of iTerm's default profile at `pane.fontSize` points
273
+ (default 13; a legacy `pane.fontScale` is ignored), inheriting everything
274
+ else from that profile. When iTerm's preferences cannot be read the profile
275
+ is still written, with `Menlo-Regular <fontSize>` and no parent, and the
276
+ reason lands on stderr. `pai worker pane <id> --check` prints the profile
277
+ file's path, whether it exists, and the font it contains or would write.
278
+
279
+ ## The Agent-tool hook
280
+
281
+ With workers on, a PreToolUse hook denies every `Agent` call and the deny
282
+ reason tells the orchestrator to delegate via `pai worker run` in the
283
+ background instead. Decisions are ledgered (`DENIED-ANTHROPIC-AGENT`,
284
+ `ALLOWED-ANTHROPIC-AGENT`).
285
+
286
+ - `pai worker off` — Agent subagents run on Anthropic again.
287
+ - `ALLOW_ANTHROPIC_AGENTS=1` — bypass for one session.
288
+
289
+ ## MCP tools
290
+
291
+ `worker_status`, `worker_providers` (list/add/remove/use/enable/disable/test),
292
+ `worker_roles`, `worker_toggle`, `worker_ps`, `worker_replay`,
293
+ `worker_say` (message a running worker), `worker_resume` (continue a
294
+ finished one) — the same library the CLI calls. `worker_providers add`
295
+ accepts a raw `key`, parks it in `~/.config/pai/keys/<name>` (mode 0600) and
296
+ stores only the path.
297
+
298
+ ## Status line
299
+
300
+ Line 4 of the statusline lists this session's running workers (provider,
301
+ label, age, current tool, context meter past 60 %) plus today's ✓/✗ tally.
302
+ It prefers the standalone `~/.claude/worker-status-line.mjs` (plain node,
303
+ built by `bun run build`) and falls back to `pai worker status-line`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tekmidian/pai",
3
- "version": "0.36.1",
3
+ "version": "0.37.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",
@@ -98,6 +98,47 @@ rmSync(STAGING, { recursive: true, force: true });
98
98
 
99
99
  console.log(`✔ ${entryPoints.length} hooks built to ${HOOKS_OUT}/`);
100
100
 
101
+ // ---------------------------------------------------------------------------
102
+ // Standalone worker status-line: src/workers/standalone/status-line.ts →
103
+ // dist/worker-status-line.mjs (same atomic-staging rules; it runs on every
104
+ // statusline refresh of every live session).
105
+ // ---------------------------------------------------------------------------
106
+
107
+ // [entry, output name in dist/hooks/]
108
+ const STANDALONE_ENTRIES = [
109
+ ["src/workers/standalone/status-line.ts", "worker-status-line.mjs"],
110
+ // detached proxy for openai-protocol providers; spawned from dist, not
111
+ // symlinked into ~/.claude, so it is NOT in the --sync list below
112
+ ["src/workers/standalone/proxy.ts", "worker-proxy.mjs"],
113
+ ];
114
+
115
+ mkdirSync(STAGING, { recursive: true });
116
+ for (const [entry, name] of STANDALONE_ENTRIES) {
117
+ const staged = join(STAGING, name);
118
+ buildSync({
119
+ entryPoints: [entry],
120
+ bundle: true,
121
+ platform: "node",
122
+ target: "node20",
123
+ format: "esm",
124
+ outfile: staged,
125
+ sourcemap: true,
126
+ });
127
+ chmodSync(staged, 0o755);
128
+ const stagedMap = `${staged}.map`;
129
+ if (existsSync(stagedMap)) {
130
+ renameSync(stagedMap, join(HOOKS_OUT, `${name}.map`));
131
+ }
132
+ mkdirSync(HOOKS_OUT, { recursive: true });
133
+ renameSync(staged, join(HOOKS_OUT, name));
134
+ }
135
+
136
+ rmSync(STAGING, { recursive: true, force: true });
137
+
138
+ console.log(
139
+ `✔ ${STANDALONE_ENTRIES.length} standalone script(s) built to ${HOOKS_OUT}/ (${STANDALONE_ENTRIES.map(([, n]) => n).join(", ")})`
140
+ );
141
+
101
142
  // ---------------------------------------------------------------------------
102
143
  // --sync: Symlink (or copy on Windows) all deployable files to ~/.claude/
103
144
  // ---------------------------------------------------------------------------
@@ -161,7 +202,8 @@ if (doSync) {
161
202
  }
162
203
 
163
204
  // 1. TypeScript hooks: dist/hooks/*.mjs → ~/.claude/Hooks/*.mjs
164
- const mjsFiles = readdirSync(HOOKS_OUT).filter((f) => f.endsWith(".mjs"));
205
+ // (worker-proxy.mjs is spawned from dist, not a hook — skip it)
206
+ const mjsFiles = readdirSync(HOOKS_OUT).filter((f) => f.endsWith(".mjs") && f !== "worker-proxy.mjs");
165
207
  for (const filename of mjsFiles) {
166
208
  syncFile(join(HOOKS_OUT, filename), join(hooksTarget, filename));
167
209
  }
@@ -186,6 +228,9 @@ if (doSync) {
186
228
  }
187
229
  }
188
230
 
231
+ // 4. Standalone worker status-line: dist/worker-status-line.mjs → ~/.claude/
232
+ syncFile(join(HOOKS_OUT, "worker-status-line.mjs"), join(claudeDir, "worker-status-line.mjs"));
233
+
189
234
  const parts = [];
190
235
  if (created > 0) parts.push(`${created} created`);
191
236
  if (updated > 0) parts.push(`${updated} updated`);
@@ -1,4 +1,5 @@
1
1
  #!/bin/bash
2
+ [ "${PAI_WORKER:-}" = "1" ] && exit 0 # disposable worker: no per-session bookkeeping
2
3
  # PAI Knowledge OS — pre-compact hook
3
4
  #
4
5
  # Called by Claude Code before context compaction.
@@ -1,4 +1,5 @@
1
1
  #!/bin/bash
2
+ [ "${PAI_WORKER:-}" = "1" ] && exit 0 # disposable worker: no per-session bookkeeping
2
3
  # PAI Knowledge OS — rolling session autosave
3
4
  #
4
5
  # Fires from live hooks (UserPromptSubmit, PostToolUse) so that a session which
@@ -1,4 +1,5 @@
1
1
  #!/bin/bash
2
+ [ "${PAI_WORKER:-}" = "1" ] && exit 0 # disposable worker: no per-session bookkeeping
2
3
  # PAI Knowledge OS — session-stop hook
3
4
  #
4
5
  # Called by Claude Code when a session ends.
@@ -13,7 +13,9 @@ import {
13
13
  contextFillThresholds,
14
14
  resolveAutocompactPct,
15
15
  measureCompactionTrigger,
16
+ modelFamily,
16
17
  selectedCompactionSamples,
18
+ transcriptModelFamily,
17
19
  crossedThresholds,
18
20
  isImmediate,
19
21
  DEFAULT_CONTEXT_WINDOW,
@@ -626,3 +628,105 @@ describe("contextFillThresholds — trigger source", () => {
626
628
  expect(t.configuredTriggerTokens).toBe(800_000);
627
629
  });
628
630
  });
631
+
632
+ // ---------------------------------------------------------------------------
633
+ // Foreign-model transcripts — a headless worker on another provider (a
634
+ // different context window) shares the project folder; its compactions must
635
+ // not shape THIS project's measured trigger. Observed 2026-09-17: two such
636
+ // workers compacting at ~151k pulled a real trigger from ~784k to ~151k.
637
+ // ---------------------------------------------------------------------------
638
+
639
+ function assistantLine(model: string): string {
640
+ return JSON.stringify({
641
+ type: "assistant",
642
+ message: { role: "assistant", model, content: [{ type: "text", text: "ok" }] },
643
+ });
644
+ }
645
+
646
+ describe("measureCompactionTrigger — foreign-model transcripts are ignored", () => {
647
+ it("drops compact_boundary samples governed by a non-claude assistant model", () => {
648
+ const projectsDir = mkdtempSync(join(tmpdir(), "pai-measured-trigger-test-"));
649
+ const cwd = "/fake/project/foreign";
650
+ const projectDir = join(projectsDir, encodeForFixture(cwd));
651
+ mkdirSync(projectDir, { recursive: true });
652
+ writeFileSync(
653
+ join(projectDir, "real.jsonl"),
654
+ [assistantLine("claude-x-1"), compactBoundaryLine(784_000, "2026-09-17T09:00:00.000Z", "u1")].join("\n") + "\n"
655
+ );
656
+ writeFileSync(
657
+ join(projectDir, "worker.jsonl"),
658
+ [
659
+ assistantLine("other-model-1"),
660
+ compactBoundaryLine(151_000, "2026-09-17T09:30:00.000Z", "u2"),
661
+ compactBoundaryLine(152_000, "2026-09-17T09:35:00.000Z", "u3"),
662
+ ].join("\n") + "\n"
663
+ );
664
+ try {
665
+ expect(selectedCompactionSamples(cwd, projectsDir).map((s) => s.preTokens)).toEqual([784_000]);
666
+ expect(measureCompactionTrigger(cwd, projectsDir)).toBe(784_000);
667
+ } finally {
668
+ rmSync(projectsDir, { recursive: true, force: true });
669
+ }
670
+ });
671
+
672
+ it("keeps samples from a transcript with no model field at all", () => {
673
+ const projectsDir = mkdtempSync(join(tmpdir(), "pai-measured-trigger-test-"));
674
+ const cwd = "/fake/project/nomodel";
675
+ const projectDir = join(projectsDir, encodeForFixture(cwd));
676
+ mkdirSync(projectDir, { recursive: true });
677
+ writeFileSync(join(projectDir, "old.jsonl"), compactBoundaryLine(790_000, "2026-09-17T09:00:00.000Z", "u1") + "\n");
678
+ try {
679
+ expect(measureCompactionTrigger(cwd, projectsDir)).toBe(790_000);
680
+ } finally {
681
+ rmSync(projectsDir, { recursive: true, force: true });
682
+ }
683
+ });
684
+
685
+ it("judges each sample by the model seen BEFORE it in its own file", () => {
686
+ const projectsDir = mkdtempSync(join(tmpdir(), "pai-measured-trigger-test-"));
687
+ const cwd = "/fake/project/order";
688
+ const projectDir = join(projectsDir, encodeForFixture(cwd));
689
+ mkdirSync(projectDir, { recursive: true });
690
+ writeFileSync(
691
+ join(projectDir, "mixed.jsonl"),
692
+ [
693
+ assistantLine("claude-x-1"),
694
+ compactBoundaryLine(780_000, "2026-09-17T09:00:00.000Z", "u1"),
695
+ assistantLine("other-model-1"),
696
+ compactBoundaryLine(150_000, "2026-09-17T09:30:00.000Z", "u2"),
697
+ ].join("\n") + "\n"
698
+ );
699
+ try {
700
+ expect(selectedCompactionSamples(cwd, projectsDir).map((s) => s.preTokens)).toEqual([780_000]);
701
+ } finally {
702
+ rmSync(projectsDir, { recursive: true, force: true });
703
+ }
704
+ });
705
+ });
706
+
707
+ describe("modelFamily / transcriptModelFamily", () => {
708
+ it("classifies claude-, foreign, synthetic and missing models", () => {
709
+ expect(modelFamily("claude-opus-5")).toBe("claude");
710
+ expect(modelFamily("other-model-1")).toBe("foreign");
711
+ expect(modelFamily("<synthetic>")).toBe("unknown");
712
+ expect(modelFamily(null)).toBe("unknown");
713
+ expect(modelFamily("")).toBe("unknown");
714
+ });
715
+
716
+ it("reads the LAST assistant model, skips synthetic turns, and is unknown for an unreadable file", () => {
717
+ const dir = mkdtempSync(join(tmpdir(), "pai-model-family-test-"));
718
+ const path = join(dir, "t.jsonl");
719
+ try {
720
+ writeFileSync(
721
+ path,
722
+ [assistantLine("other-model-1"), compactBoundaryLine(1, "2026-09-17T09:00:00.000Z"), assistantLine("claude-x-1")].join("\n") + "\n"
723
+ );
724
+ expect(transcriptModelFamily(path)).toBe("claude");
725
+ writeFileSync(path, [assistantLine("claude-x-1"), assistantLine("other-model-1"), assistantLine("<synthetic>")].join("\n") + "\n");
726
+ expect(transcriptModelFamily(path)).toBe("foreign");
727
+ expect(transcriptModelFamily(join(dir, "missing.jsonl"))).toBe("unknown");
728
+ } finally {
729
+ rmSync(dir, { recursive: true, force: true });
730
+ }
731
+ });
732
+ });
@@ -365,6 +365,51 @@ function listProjectTranscripts(cwd: string, projectsDir: string): string[] {
365
365
  return paths;
366
366
  }
367
367
 
368
+ /** Placeholder model id the platform writes on synthetic assistant turns. */
369
+ const SYNTHETIC_MODEL = "<synthetic>";
370
+
371
+ /**
372
+ * Which family of model wrote a transcript. "claude" is the platform's own;
373
+ * "foreign" is any other provider routed through the same CLI (a different
374
+ * context window, so its compactions say nothing about ours); "unknown" is
375
+ * no usable model field at all.
376
+ */
377
+ export type TranscriptModelFamily = "claude" | "foreign" | "unknown";
378
+
379
+ export function modelFamily(model: string | null | undefined): TranscriptModelFamily {
380
+ if (typeof model !== "string" || model === "" || model === SYNTHETIC_MODEL) return "unknown";
381
+ return model.startsWith("claude-") ? "claude" : "foreign";
382
+ }
383
+
384
+ /**
385
+ * The family of the LAST assistant model in a transcript — scanned from the
386
+ * end so a long transcript costs one read and a few lines of parsing.
387
+ * Unreadable or model-less transcripts are "unknown", never "foreign":
388
+ * the callers that skip work on "foreign" must not skip it on doubt.
389
+ */
390
+ export function transcriptModelFamily(path: string): TranscriptModelFamily {
391
+ let raw: string;
392
+ try {
393
+ raw = readFileSync(path, "utf-8");
394
+ } catch {
395
+ return "unknown";
396
+ }
397
+ const lines = raw.split("\n");
398
+ for (let i = lines.length - 1; i >= 0; i--) {
399
+ const line = lines[i];
400
+ if (!line.includes('"model"')) continue;
401
+ try {
402
+ const entry = JSON.parse(line) as { type?: string; message?: { model?: unknown } };
403
+ if (entry.type !== "assistant") continue;
404
+ const model = entry.message?.model;
405
+ if (typeof model === "string" && model !== SYNTHETIC_MODEL) return modelFamily(model);
406
+ } catch {
407
+ continue;
408
+ }
409
+ }
410
+ return "unknown";
411
+ }
412
+
368
413
  /**
369
414
  * Every DISTINCT compact_boundary sample found across ALL of a project's
370
415
  * transcripts (live + archived), newest first by the event's OWN timestamp
@@ -383,6 +428,17 @@ function readCompactBoundarySamples(cwd: string, projectsDir: string): CompactBo
383
428
  continue;
384
429
  }
385
430
 
431
+ // The model governing each sample: the most recent assistant
432
+ // `message.model` seen in this file before the compact_boundary. A
433
+ // transcript written by a non-Claude model (a headless worker on another
434
+ // provider, with a different context window) compacts at a different
435
+ // size and must not shape THIS project's trigger — two such workers
436
+ // compacting at ~151k pulled a real project's trigger from ~784k to
437
+ // ~151k. Samples with no model seen yet are kept: older transcripts
438
+ // may lack the field.
439
+ let lastModel: string | null = null;
440
+ let foreignDiscards = 0;
441
+
386
442
  for (const line of raw.split("\n")) {
387
443
  if (!line.trim()) continue;
388
444
  let entry: {
@@ -391,23 +447,40 @@ function readCompactBoundarySamples(cwd: string, projectsDir: string): CompactBo
391
447
  timestamp?: string;
392
448
  uuid?: string;
393
449
  compactMetadata?: { preTokens?: number };
450
+ message?: { model?: unknown };
394
451
  };
395
452
  try {
396
453
  entry = JSON.parse(line);
397
454
  } catch {
398
455
  continue;
399
456
  }
457
+ if (entry.type === "assistant") {
458
+ const model = entry.message?.model;
459
+ if (typeof model === "string" && model !== SYNTHETIC_MODEL) lastModel = model;
460
+ continue;
461
+ }
400
462
  if (entry.type !== "system" || entry.subtype !== "compact_boundary") continue;
401
463
  const preTokens = entry.compactMetadata?.preTokens;
402
464
  if (typeof preTokens !== "number" || !Number.isFinite(preTokens)) continue;
403
465
  const timestampMs = entry.timestamp ? Date.parse(entry.timestamp) : NaN;
404
466
  if (!Number.isFinite(timestampMs)) continue;
467
+ if (modelFamily(lastModel) === "foreign") {
468
+ foreignDiscards++;
469
+ continue;
470
+ }
405
471
 
406
472
  const key = entry.uuid ?? `${timestampMs}:${preTokens}`;
407
473
  if (!byKey.has(key)) {
408
474
  byKey.set(key, { preTokens, timestampMs, timestamp: entry.timestamp!, uuid: entry.uuid });
409
475
  }
410
476
  }
477
+
478
+ if (foreignDiscards > 0) {
479
+ console.error(
480
+ `[context-fill] ignored ${foreignDiscards} compaction sample(s) from a ` +
481
+ `non-Claude transcript (model=${lastModel}): ${path}`
482
+ );
483
+ }
411
484
  }
412
485
 
413
486
  return [...byKey.values()].sort((a, b) => b.timestampMs - a.timestampMs);