@tekmidian/pai 0.36.2 → 0.38.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 +549 -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 +6 -0
  32. package/dist/hooks/post-compact-inject.mjs.map +3 -3
  33. package/dist/hooks/route-agents-to-worker.mjs +518 -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 +858 -0
  48. package/dist/hooks/worker-proxy.mjs.map +7 -0
  49. package/dist/hooks/worker-status-line.mjs +545 -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-Y0hAiVy8.mjs} +851 -101
  69. package/dist/program-Y0hAiVy8.mjs.map +1 -0
  70. package/dist/providers-DUshcB-d.mjs +4031 -0
  71. package/dist/providers-DUshcB-d.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 +63 -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 +29 -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 +398 -0
  128. package/docs/commands/zettel.md +1 -1
  129. package/docs/worker.md +434 -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 +2 -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,434 @@
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
+ "costTier": 2,
37
+ "tags": ["code", "long-context"]
38
+ },
39
+ "oai": {
40
+ "protocol": "openai",
41
+ "upstreamUrl": "https://api.openai.com/v1",
42
+ "keyFile": "~/.config/pai/keys/oai",
43
+ "models": { "default": "gpt-5.2", "fast": "gpt-5.2-mini" },
44
+ "costTier": 4,
45
+ "tags": ["reasoning", "vision"]
46
+ },
47
+ "codexprov": {
48
+ "engine": "codex",
49
+ "models": { "default": "gpt-5.2-codex" }
50
+ }
51
+ },
52
+ "classes": {
53
+ "implement": "glm",
54
+ "research": { "maxCostTier": 2, "requireTags": ["long-context"] },
55
+ "spotcheck": "glm/fast",
56
+ "docs": { "provider": "glm", "mcp": ["office"] }
57
+ },
58
+ "mcpSets": {
59
+ "office": ["memory", "github"],
60
+ "tiny": ["fetcher"]
61
+ },
62
+ "pane": { "enabled": true, "fontSize": 13, "autoExitSecs": 60 },
63
+ "logDir": "~/.claude/logs/workers",
64
+ "routing": { "order": [], "cooldownMinutes": 30, "retryOnQuota": true }
65
+ }
66
+ }
67
+ ```
68
+
69
+ - `keyFile` holds the API token (chmod 600). Keys never go into the config.
70
+ - `active` is one provider name, or `"auto"` to walk `routing.order`.
71
+ - `protocol: "openai"` routes the provider through the built-in proxy (next
72
+ section); it needs `upstreamUrl`, the Chat Completions base.
73
+ - `engine: "codex"` runs the provider on the Codex CLI instead of Claude
74
+ Code (see below).
75
+ - `contextWindow` overrides the context-meter window when the endpoint's
76
+ init event does not announce one (default 200 000).
77
+ - `costTier` (1 cheapest … 5 most expensive, default 3) and `tags` (from:
78
+ `code`, `vision`, `image-gen`, `long-context`, `fast`, `reasoning`) describe
79
+ a provider; classes use them to constrain routing (next section).
80
+ - A class target is `"provider[/model]"` or an object with `provider` and a
81
+ `mcp` allowlist applied on top of `--mcp`, or an object with only routing
82
+ constraints (`maxCostTier`, `requireTags`, `order`).
83
+
84
+ Or add one from the CLI:
85
+
86
+ ```
87
+ pai worker providers add glm \
88
+ --base-url https://api.z.ai/api/anthropic \
89
+ --key-file ~/.config/zai/api_key \
90
+ --model glm-5.3 --fast-model glm-5.3-flash \
91
+ --env API_TIMEOUT_MS=3000000 \
92
+ --cost-tier 2 --tags code,long-context
93
+ pai worker providers add oai \
94
+ --upstream-url https://api.openai.com/v1 \
95
+ --key-file ~/.config/pai/keys/oai --model gpt-5.2
96
+ pai worker providers update glm --cost-tier 1 # tiers/tags change later
97
+ ```
98
+
99
+ The first provider also sets `enabled: true`, makes itself active and seeds
100
+ the nine classes. Then:
101
+
102
+ ```
103
+ pai worker install # Agent hook in settings.json + glm* shims + cleanup
104
+ ```
105
+
106
+ ## Task classes
107
+
108
+ Roles were renamed to **classes** — task classes pick the provider for a kind
109
+ of work. The nine standard classes: `draft`, `plan`, `implement`, `review`,
110
+ `research`, `spotcheck`, `simple`, `complex`, `image` (any other name can be
111
+ defined too). Configs with the old `roles` key keep parsing; the key migrates
112
+ to `classes` on the first write.
113
+
114
+ ```
115
+ pai worker classes # list
116
+ pai worker classes set implement glm # pin a provider
117
+ pai worker classes set spotcheck glm/fast # …its fast model
118
+ pai worker classes set research --max-cost-tier 2 --require-tags long-context
119
+ pai worker classes unset research
120
+ ```
121
+
122
+ Every provider carries a **cost tier** (1 cheapest … 5 most expensive,
123
+ default 3) and **tags** (`code`, `vision`, `image-gen`, `long-context`,
124
+ `fast`, `reasoning`). A class resolves its provider as:
125
+
126
+ 1. `--provider` (explicit flag) wins;
127
+ 2. else the class mapping when it pins a provider;
128
+ 3. else auto-routing (see below) restricted to providers within the class's
129
+ `maxCostTier` and carrying all its `requireTags`;
130
+ 4. nothing qualifies → the run fails with a message listing why every
131
+ provider was excluded.
132
+
133
+ `classes.<name>.order` overrides `routing.order` for that class. Cooldown and
134
+ quota logic is unchanged.
135
+
136
+ ### Preferences from chat
137
+
138
+ The Worker skill maps phrases onto the MCP tools — the answer is one or two
139
+ lines, and the user is never told to edit a file:
140
+
141
+ - "use X for image generation" / "route research to kimi" →
142
+ `worker_classes set <class>=<provider>`
143
+ - "prefer the flash model for simple tasks" / "cheap only for drafts" →
144
+ `worker_classes set simple|draft=<provider>/fast` (or `max_cost_tier`)
145
+ - "reviews should use a reasoning model" → `worker_classes set review` with
146
+ `require_tags: ["reasoning"]`
147
+ - "what handles reviews" / "show the routing table" → `worker_classes list`
148
+
149
+ ## The proxy (OpenAI-protocol providers)
150
+
151
+ A provider with `protocol: "openai"` cannot be talked to by Claude Code
152
+ directly, so PAI ships a translating proxy: Anthropic Messages API on the
153
+ front (loopback only), OpenAI Chat Completions on the back. System prompts,
154
+ multi-turn text, tool_use/tool_result ↔ tool_calls, tools ↔ functions,
155
+ streaming SSE (including streamed tool-call arguments), usage and error
156
+ mapping (429 → `rate_limit_error`, 401 → `authentication_error`, 5xx →
157
+ `api_error`) are translated in both directions.
158
+
159
+ - One proxy serves every openai provider: the provider name in the URL path
160
+ selects the upstream. `run` points `ANTHROPIC_BASE_URL` at
161
+ `http://127.0.0.1:8797/<provider>` and starts the proxy on demand
162
+ (detached, pid file under the logDir). The worker config is re-read per
163
+ request, so provider edits apply without a restart.
164
+ - The proxy holds the real token (from the provider's `keyFile`) and injects
165
+ it upstream; the worker itself runs with a placeholder, so a leaked worker
166
+ env leaks nothing.
167
+ - `pai worker proxy [--port N]` starts it by hand (default 8797, loopback
168
+ only), `pai worker proxy stop` stops it again. `providers test` on an
169
+ openai provider goes through the proxy too.
170
+
171
+ ## The codex engine
172
+
173
+ A ChatGPT plan gives no API key, only Codex CLI access — such a provider
174
+ sets `engine: "codex"` and `run` shells out to `codex exec --json <prompt>`
175
+ (non-interactive) instead of Claude Code. The Codex JSONL events are folded
176
+ into the same status fields and the same transcript shape, so `ps`, `follow`,
177
+ `replay`, the ledger and the final `--output-format` print work unchanged.
178
+
179
+ Differences: `--allowedTools` and MCP flags have no Codex equivalent and are
180
+ dropped with a `WORKER-NOTE` ledger line; resume continues a Claude session,
181
+ so it is unavailable for codex workers (their thread id is kept, but
182
+ `pai worker resume` refuses with an explanation). `providers test` reports
183
+ `codex not installed` (exit 0) when the CLI is missing.
184
+
185
+ ## Daily use
186
+
187
+ ```
188
+ pai worker run --label "fix black buttons" -p '<task spec>' \
189
+ --allowedTools 'Read,Edit,Write,Bash,Grep,Glob' --output-format json \
190
+ --mcp office
191
+ pai worker run --chain draft,implement -p '<brief>' # spec-first (below)
192
+ pai worker run --agent engineer -p '<task>' # agent library (below)
193
+ pai worker ps # this session's workers
194
+ pai worker follow [id] # live transcript (type to talk to it)
195
+ pai worker replay <id> # transcript of one worker
196
+ pai worker say <id> "<text>" # message a running worker
197
+ pai worker resume <id> "<text>" # continue a finished one, context intact
198
+ pai worker mcp list # MCP servers + sets usable in --mcp
199
+ pai worker proxy [--port N|stop] # the translating proxy, by hand
200
+ pai worker log [all|tail|<id>] # raw streams + routing ledger
201
+ ```
202
+
203
+ Classes pick the provider for a task class: `--class implement|research|spotcheck|…`
204
+ (`--role` still works as its alias). `--no-pane` suppresses the iTerm follow
205
+ pane; `--provider <name>` bypasses classes entirely. If you bring your own
206
+ `--append-system-prompt`, the worker contract below is added alongside it, not
207
+ instead.
208
+
209
+ ## Chains (draft → implement → review)
210
+
211
+ ```
212
+ pai worker run --chain draft,implement -p '<brief>'
213
+ pai worker run --chain draft,implement,review -p '<brief>' # + review pass
214
+ ```
215
+
216
+ - The **draft** class turns the brief into a full spec file under
217
+ `<logDir>/specs/<chain id>.md` — goal, constraints, files likely touched,
218
+ acceptance checks, verification commands. It reads the repository first and
219
+ implements nothing.
220
+ - **implement** (or any other stage class) runs with that spec as its prompt
221
+ and the original brief attached.
222
+ - **review** reads the spec and the working-tree diff and produces the
223
+ structured report.
224
+ - Each stage is its own worker: own id, own pane, `parent` set to the chain
225
+ id — `ps` shows the chain as a tree.
226
+ - A stage that fails stops the chain (the exit code is the first failing
227
+ stage's); a draft that produces no spec stops it with a message telling the
228
+ caller to write the spec and re-run without the draft stage.
229
+ - `--class` alongside `--chain` overrides the class of every stage; `--label`
230
+ names the chain (stages render as `<label> · <stage>`).
231
+
232
+ ## Agent definitions as workers
233
+
234
+ `pai worker run --agent <name>` loads `~/.claude/agents/<name>.md` and runs it
235
+ on a worker: the front matter's `model` maps to a class (haiku→simple,
236
+ sonnet→implement, opus→complex — `--class` overrides), `tools` becomes
237
+ `--allowedTools`, and the body is passed via `--append-system-prompt` (your
238
+ own flags on the command line still win). The label defaults to
239
+ `<agent>: <first 50 chars of prompt>`.
240
+
241
+ The agent library therefore runs on workers, not on the orchestrator's
242
+ Anthropic account — same hooks, same classes, same `ps`/`follow`/`replay`.
243
+
244
+ The old habits keep working: `glm`, `glm-run`, `glm-ps`, `glm-log` are shims
245
+ to the pai commands (`pai worker install` moves any previous versions to
246
+ `<name>.pre-pai`).
247
+
248
+ ## Routing
249
+
250
+ A run resolves its provider as: `--provider` > `--class` > `active`.
251
+
252
+ With `active: "auto"`, providers are tried in `routing.order` (or the class's
253
+ own `order`), skipping:
254
+
255
+ - disabled providers,
256
+ - providers in a cooldown (set for `cooldownMinutes` after a quota failure),
257
+ - providers whose `quotaProbe` URL reports ≥ `quotaSkipAt` (default 95),
258
+ - providers above the class's `maxCostTier` or missing one of its
259
+ `requireTags`.
260
+
261
+ Nothing qualifying fails the run with the exclusion reason of every provider
262
+ in the order.
263
+
264
+ A quota failure before the first tool call is re-run on the next provider and
265
+ logged as `WORKER-REROUTE`. `pai worker providers enable <name>` clears a
266
+ cooldown by hand.
267
+
268
+ ## What a worker is
269
+
270
+ - One `claude -p … --output-format stream-json --verbose` process per call,
271
+ run with `--input-format stream-json` and its stdin held open: the task
272
+ arrives as the first user message on stdin, and further lines (see `say`
273
+ below) continue the conversation while it runs.
274
+ - Env: `ANTHROPIC_BASE_URL`/`ANTHROPIC_AUTH_TOKEN` from the provider (token
275
+ read from `keyFile`, never from the environment; openai providers point at
276
+ the local proxy instead), `ANTHROPIC_API_KEY` stripped so nothing can fall
277
+ back to Anthropic billing, the three `ANTHROPIC_DEFAULT_*_MODEL` vars, the
278
+ provider's `env`, and `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1`.
279
+ - Headless runs get `--strict-mcp-config --mcp-config <config>` and
280
+ `PAI_WORKER=1` so PAI's per-session hooks leave them alone. Interactive
281
+ runs keep full MCP and get `ENABLE_TOOL_SEARCH=true`.
282
+ - Every mirrored event carries an ISO `_ts` stamp; the stream lands in
283
+ `<logDir>/<id>.jsonl`, live state in `<id>.status`, every event in
284
+ `<logDir>/ledger.log`.
285
+
286
+ ### The worker contract
287
+
288
+ Headless runs append a system prompt that fixes the shape of the final
289
+ answer: act, verify, then stop with ONE JSON message
290
+
291
+ ```json
292
+ {"changed":[{"path":"…","summary":"…"}],"commands":["…"],
293
+ "checks":[{"name":"…","ok":true,"detail":"…"}],"open":["…"],"notes":"one line"}
294
+ ```
295
+
296
+ The runner parses it: `notes` becomes the one-line `last` the table shows,
297
+ and `--output-format json` carries the parsed `report` next to the raw
298
+ `result`. `follow`/`replay` render it as a compact block (changed paths,
299
+ ✓/✗ checks, open items). A final message that is not the contract stays raw
300
+ text — nothing is lost either way.
301
+
302
+ ### Talking to a worker (say / resume)
303
+
304
+ While a headless worker runs, `pai worker say <id> "<text>"` (or the MCP
305
+ tool `worker_say`) forwards the text to the child as a user message over the
306
+ per-worker Unix socket `<logDir>/<id>.sock`; it is mirrored into the
307
+ transcript as an `operator` event (`»` marker). After the worker's result,
308
+ stdin closes two seconds later unless another message arrives — after that
309
+ `say` refuses and points at `resume`.
310
+
311
+ `pai worker resume <id> "<text>"` continues the same Claude session (the id
312
+ recorded from the init event) on the same provider, labelled `↩ <original>`,
313
+ and prints a fresh worker id with `--print-id`.
314
+
315
+ A `follow <id>` pane on a TTY is a chat, not a tail: the transcript lives in
316
+ a scroll region that ends two rows above the pane's bottom, the last two rows
317
+ are fixed — the prompt row (`› `, full readline editing: arrows, backspace,
318
+ Ctrl-A/E, Ctrl-U) and the ticker row — and every transcript line is inserted
319
+ above them with a save-cursor / restore-cursor write, so the cursor never
320
+ leaves the prompt. Enter sends the line: said to the worker while it runs,
321
+ `resume`d into the same session once it has finished (the pane follows the
322
+ fresh run id and keeps the chat). The sent line is echoed into the transcript
323
+ as a `»` row with its time gutter, exactly once — the mirrored `operator`
324
+ event is swallowed. `/help` lists the commands:
325
+
326
+ | key | action |
327
+ | --- | --- |
328
+ | `/quit` | close the pane |
329
+ | `/resume <text>` | resume the finished worker with `<text>` |
330
+ | `/status` | one-line worker status |
331
+ | anything else | a message — said, or resumed |
332
+
333
+ Ctrl-C on an empty prompt leaves the pane, on a draft it clears the prompt;
334
+ Ctrl-D leaves. The auto-exit countdown never fires while the prompt holds
335
+ unsent text. Without a TTY (piped output) the pane keeps the plain scrolling
336
+ behaviour — no prompt row, no ticker, stdin still the operator channel.
337
+
338
+ ### Context meter
339
+
340
+ Status files carry `contextTokens` (input + cache read + cache creation +
341
+ output of the last assistant turn) and `contextWindow` (from the init event,
342
+ else the provider's `contextWindow`, else 200 000). The `ps` table and the
343
+ status line show `ctx 84k/200k (42%)` once it passes 60 % — yellow past
344
+ 70 %, red past 85 % — and the pane's liveness line always shows it.
345
+
346
+ ### MCP for workers
347
+
348
+ Headless workers start with **no MCP servers by default**: every server
349
+ definition lands in the system prompt and costs context (and often a
350
+ startup process) before the worker has done anything. When a task genuinely
351
+ needs servers, opt in per run:
352
+
353
+ ```
354
+ pai worker run --mcp office … # a set, or names: --mcp memory,github
355
+ ```
356
+
357
+ `--mcp` takes server names and/or `mcpSets` names (comma-separated,
358
+ repeatable); the filtered config is written from `~/.claude.json`'s
359
+ `mcpServers` to `<logDir>/<id>.mcp.json` and passed with
360
+ `--strict-mcp-config --mcp-config`. Class targets may add `"mcp": ["office"]`
361
+ on top. An unknown name fails fast, listing what exists;
362
+ `pai worker mcp list` shows servers and sets. A caller-provided
363
+ `--mcp-config` always wins; MCP is chosen at launch, not mid-run.
364
+
365
+ ## Scoping (who sees whose workers)
366
+
367
+ `ps`/`follow`/status line show the workers of the asking terminal:
368
+
369
+ 1. AIBroker session id (from `~/.aibroker/session-names.json`) — every pane of
370
+ a named session sees its workers,
371
+ 2. else the iTerm tab key (`w<n>t<n>` of `ITERM_SESSION_ID`).
372
+
373
+ `--all` (or no iTerm at all) widens to every worker.
374
+
375
+ ## Follow, replay and the pane
376
+
377
+ `follow` renders the live transcript with a `HH:MM:SS │ ` gutter (dim; the
378
+ worker's short id in front when several run at once, a `── date ──`
379
+ separator when the day changes) and, on a TTY, a liveness line
380
+ `⋯ 12s since last event · Bash: npm test` that is overwritten in place and
381
+ erased before the next event. `replay` shows a finished transcript with the
382
+ same gutter.
383
+
384
+ The pane wraps rows itself at the terminal width (re-read on resize, so a
385
+ narrower pane re-wraps what arrives after the resize): breaks on whitespace
386
+ where it can, hard-wraps a long token otherwise, never splits an ANSI escape
387
+ (it measures printable columns, not string length), and carries diff colours
388
+ onto every continuation row. Each continuation row carries a blank-time
389
+ gutter with the `│` bar kept — the bar runs unbroken down the pane and no
390
+ content ever lands left of it. In the chat layout the transcript scrolls
391
+ inside an ANSI scroll region (`ESC[1;rows-2r`, reset on exit and re-set on
392
+ resize) so the prompt and ticker rows stay fixed; piped output keeps the
393
+ terminal's own wrapping instead.
394
+
395
+ The pane command is `exec pai worker follow …` so the pane holds exactly one
396
+ process — signals reach the follow directly, and when the worker finishes
397
+ the pane counts down its `auto-exit` (default 60 s, `pane.autoExitSecs`).
398
+
399
+ Panes run under the `pai-worker` dynamic profile, written to
400
+ `~/Library/Application Support/iTerm2/DynamicProfiles/pai-worker.json`:
401
+ the font family of iTerm's default profile at `pane.fontSize` points
402
+ (default 13; a legacy `pane.fontScale` is ignored), inheriting everything
403
+ else from that profile. When iTerm's preferences cannot be read the profile
404
+ is still written, with `Menlo-Regular <fontSize>` and no parent, and the
405
+ reason lands on stderr. `pai worker pane <id> --check` prints the profile
406
+ file's path, whether it exists, and the font it contains or would write.
407
+
408
+ ## The Agent-tool hook
409
+
410
+ With workers on, a PreToolUse hook denies every `Agent` call and the deny
411
+ reason tells the orchestrator to delegate via `pai worker run` in the
412
+ background instead. Decisions are ledgered (`DENIED-ANTHROPIC-AGENT`,
413
+ `ALLOWED-ANTHROPIC-AGENT`).
414
+
415
+ - `pai worker off` — Agent subagents run on Anthropic again.
416
+ - `ALLOW_ANTHROPIC_AGENTS=1` — bypass for one session.
417
+
418
+ ## MCP tools
419
+
420
+ `worker_status`, `worker_providers`
421
+ (list/add/update/remove/use/enable/disable/test — `update` changes
422
+ `cost_tier`/`tags`), `worker_classes` (list/set/unset), `worker_run` (start a
423
+ worker or chain from chat, returns the id immediately), `worker_toggle`,
424
+ `worker_ps`, `worker_replay`, `worker_say` (message a running worker),
425
+ `worker_resume` (continue a finished one) — the same library the CLI calls.
426
+ `worker_providers add` accepts a raw `key`, parks it in
427
+ `~/.config/pai/keys/<name>` (mode 0600) and stores only the path.
428
+
429
+ ## Status line
430
+
431
+ Line 4 of the statusline lists this session's running workers (provider,
432
+ label, age, current tool, context meter past 60 %) plus today's ✓/✗ tally.
433
+ It prefers the standalone `~/.claude/worker-status-line.mjs` (plain node,
434
+ 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.2",
3
+ "version": "0.38.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
+ });