@tekmidian/pai 0.66.6 → 0.66.8

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 (139) hide show
  1. package/dist/{aibroker-client-WCZi_tn-.mjs → aibroker-client-BfnVwGZl.mjs} +3 -3
  2. package/dist/{aibroker-client-WCZi_tn-.mjs.map → aibroker-client-BfnVwGZl.mjs.map} +1 -1
  3. package/dist/{aibroker-client-Ck3p3k_-.mjs → aibroker-client-C-O3Fflh.mjs} +1 -1
  4. package/dist/{auto-route-BnizyALK.mjs → auto-route-D8TBDNqb.mjs} +3 -3
  5. package/dist/{auto-route-BnizyALK.mjs.map → auto-route-D8TBDNqb.mjs.map} +1 -1
  6. package/dist/auto-route-DCxTKz19.mjs +7 -0
  7. package/dist/{chain-2_uoiEdD.mjs → chain-BL4khGgy.mjs} +4 -3
  8. package/dist/{chain-2_uoiEdD.mjs.map → chain-BL4khGgy.mjs.map} +1 -1
  9. package/dist/cli/index.mjs +20 -16
  10. package/dist/cli/index.mjs.map +1 -1
  11. package/dist/cli/program.mjs +19 -15
  12. package/dist/{config-Ddc4DIPk.mjs → config-DjZELvoA.mjs} +12 -3
  13. package/dist/config-DjZELvoA.mjs.map +1 -0
  14. package/dist/{run-env-CQRnxNht.mjs → config-Dpe2JlbY.mjs} +5 -114
  15. package/dist/config-Dpe2JlbY.mjs.map +1 -0
  16. package/dist/{config-CnsDMETf.mjs → config-yomayAwe.mjs} +1 -1
  17. package/dist/{context-handover-cache-SFkWucuT.mjs → context-handover-cache-DFqxNh-H.mjs} +27 -13
  18. package/dist/context-handover-cache-DFqxNh-H.mjs.map +1 -0
  19. package/dist/daemon/index.mjs +20 -15
  20. package/dist/daemon/index.mjs.map +1 -1
  21. package/dist/{daemon-vLtT3yaI.mjs → daemon-CsR1S_Qr.mjs} +762 -24
  22. package/dist/daemon-CsR1S_Qr.mjs.map +1 -0
  23. package/dist/daemon-WrncnGua.mjs +23 -0
  24. package/dist/daemon-mcp/index.mjs +15 -12
  25. package/dist/daemon-mcp/index.mjs.map +1 -1
  26. package/dist/embedding-gate-DqbKvn56.mjs +378 -0
  27. package/dist/embedding-gate-DqbKvn56.mjs.map +1 -0
  28. package/dist/embedding-gate-SJMCPwxG.mjs +5 -0
  29. package/dist/{embeddings-DMVzpvRZ.mjs → embeddings-BUjZMD9F.mjs} +16 -2
  30. package/dist/{embeddings-DMVzpvRZ.mjs.map → embeddings-BUjZMD9F.mjs.map} +1 -1
  31. package/dist/embeddings-DrJ-ewfi.mjs +3 -0
  32. package/dist/{env-DiolswKQ.mjs → env-CPCNHz3i.mjs} +1 -1
  33. package/dist/{env-DiolswKQ.mjs.map → env-CPCNHz3i.mjs.map} +1 -1
  34. package/dist/factory-DjtXElOI.mjs +9 -0
  35. package/dist/{factory-CXFHwvcv.mjs → factory-yElzJc6f.mjs} +31 -9
  36. package/dist/factory-yElzJc6f.mjs.map +1 -0
  37. package/dist/{fallback-DNzd3jJc.mjs → fallback-2yWDDYiT.mjs} +5 -4
  38. package/dist/{fallback-DNzd3jJc.mjs.map → fallback-2yWDDYiT.mjs.map} +1 -1
  39. package/dist/{helpers-CZsi_49C.mjs → helpers-B9LHRJ8R.mjs} +1 -1
  40. package/dist/{helpers-CZsi_49C.mjs.map → helpers-B9LHRJ8R.mjs.map} +1 -1
  41. package/dist/hooks/block-sleep-poll.mjs +7 -2
  42. package/dist/hooks/block-sleep-poll.mjs.map +3 -3
  43. package/dist/hooks/context-compression-hook.mjs +2 -0
  44. package/dist/hooks/context-compression-hook.mjs.map +2 -2
  45. package/dist/hooks/load-core-context.mjs +5 -5
  46. package/dist/hooks/load-core-context.mjs.map +2 -2
  47. package/dist/hooks/load-project-context.mjs +2 -0
  48. package/dist/hooks/load-project-context.mjs.map +2 -2
  49. package/dist/hooks/post-compact-inject.mjs +2 -0
  50. package/dist/hooks/post-compact-inject.mjs.map +2 -2
  51. package/dist/hooks/route-agents-to-worker.mjs +2 -0
  52. package/dist/hooks/route-agents-to-worker.mjs.map +2 -2
  53. package/dist/hooks/security-validator.mjs +2 -0
  54. package/dist/hooks/security-validator.mjs.map +2 -2
  55. package/dist/hooks/whisper-rules.mjs +2 -0
  56. package/dist/hooks/whisper-rules.mjs.map +2 -2
  57. package/dist/hooks/worker-guard.mjs +82 -5
  58. package/dist/hooks/worker-guard.mjs.map +4 -4
  59. package/dist/hooks/worker-proxy.mjs +2 -0
  60. package/dist/hooks/worker-proxy.mjs.map +2 -2
  61. package/dist/hooks/worker-status-line.mjs +7 -2
  62. package/dist/hooks/worker-status-line.mjs.map +3 -3
  63. package/dist/hooks/worker-supervision.mjs +2 -0
  64. package/dist/hooks/worker-supervision.mjs.map +2 -2
  65. package/dist/index.d.mts.map +1 -1
  66. package/dist/index.mjs +7 -4
  67. package/dist/{ipc-client-DkxRay-o.mjs → ipc-client-DVZ_EGnu.mjs} +2 -2
  68. package/dist/{ipc-client-DkxRay-o.mjs.map → ipc-client-DVZ_EGnu.mjs.map} +1 -1
  69. package/dist/main-resolver-DjQsexge.mjs +8 -0
  70. package/dist/{main-resolver-SfGGg1DB.mjs → main-resolver-Dxvn4Mv8.mjs} +7 -6
  71. package/dist/{main-resolver-SfGGg1DB.mjs.map → main-resolver-Dxvn4Mv8.mjs.map} +1 -1
  72. package/dist/{pai-marker-DXVpFsYz.mjs → pai-marker-1B7iC7oq.mjs} +121 -3
  73. package/dist/pai-marker-1B7iC7oq.mjs.map +1 -0
  74. package/dist/{planner-C-gzg7ye.mjs → planner-CspP59ya.mjs} +13 -10
  75. package/dist/{planner-C-gzg7ye.mjs.map → planner-CspP59ya.mjs.map} +1 -1
  76. package/dist/{postgres-BxmyQHoA.mjs → postgres-CCAGq1qp.mjs} +86 -5
  77. package/dist/postgres-CCAGq1qp.mjs.map +1 -0
  78. package/dist/postgres-CzkEPfAm.mjs +7 -0
  79. package/dist/{program-CfG1T_ko.mjs → program-cWqZI0_L.mjs} +231 -125
  80. package/dist/program-cWqZI0_L.mjs.map +1 -0
  81. package/dist/{query-feedback-BPdapXKd.mjs → query-feedback-GPeVGvIa.mjs} +2 -2
  82. package/dist/{query-feedback-BPdapXKd.mjs.map → query-feedback-GPeVGvIa.mjs.map} +1 -1
  83. package/dist/{reranker-3lnggwgq.mjs → reranker-BTiOia8Y.mjs} +1 -1
  84. package/dist/{reranker-3lnggwgq.mjs.map → reranker-BTiOia8Y.mjs.map} +1 -1
  85. package/dist/{reranker-CZ2mP4cf.mjs → reranker-YwDT2n5e.mjs} +1 -1
  86. package/dist/{router-BaTbc9VX.mjs → router-Bk_ySIZT.mjs} +2 -2
  87. package/dist/{router-BaTbc9VX.mjs.map → router-Bk_ySIZT.mjs.map} +1 -1
  88. package/dist/router-DyiZNI6o.mjs +3 -0
  89. package/dist/{run-ChlIbDJ0.mjs → run-BZNdpUGQ.mjs} +226 -1797
  90. package/dist/run-BZNdpUGQ.mjs.map +1 -0
  91. package/dist/run-env-Cd0Son-Y.mjs +115 -0
  92. package/dist/run-env-Cd0Son-Y.mjs.map +1 -0
  93. package/dist/{pai-home-UncxWxlX.mjs → runtime-paths-DkZoxofH.mjs} +50 -2
  94. package/dist/runtime-paths-DkZoxofH.mjs.map +1 -0
  95. package/dist/{search-CNAGTiJP.mjs → search-cPDj-9fO.mjs} +2 -2
  96. package/dist/{search-CNAGTiJP.mjs.map → search-cPDj-9fO.mjs.map} +1 -1
  97. package/dist/{sources-CM2g-CLT.mjs → sources-CvvzNZad.mjs} +1 -1
  98. package/dist/{sources-CM2g-CLT.mjs.map → sources-CvvzNZad.mjs.map} +1 -1
  99. package/dist/{stop-words-DtxaTWU_.mjs → stop-words-LtkS-Pej.mjs} +1 -1
  100. package/dist/{stop-words-DtxaTWU_.mjs.map → stop-words-LtkS-Pej.mjs.map} +1 -1
  101. package/dist/worktree-CwMRTa0H.mjs +1578 -0
  102. package/dist/worktree-CwMRTa0H.mjs.map +1 -0
  103. package/dist/{zettelkasten-BRIaHzFm.mjs → zettelkasten-DSIe6wpF.mjs} +6 -5
  104. package/dist/zettelkasten-DSIe6wpF.mjs.map +1 -0
  105. package/dist/{zettelkasten-G0-ZTybr.mjs → zettelkasten-DtkAvA18.mjs} +5 -3
  106. package/docker/docker-compose.yml +4 -0
  107. package/docs/commands/README.md +6 -0
  108. package/docs/commands/memory.md +52 -0
  109. package/docs/commands/worker.md +22 -0
  110. package/docs/install-linux.md +1 -0
  111. package/docs/memory.md +69 -0
  112. package/package.json +1 -1
  113. package/src/hooks/ts/lib/sleep-poll-gate.test.ts +2 -2
  114. package/src/hooks/ts/lib/sleep-poll-gate.ts +4 -4
  115. package/src/hooks/ts/lib/worker-guard-longwait.test.ts +48 -0
  116. package/src/hooks/ts/lib/worker-guard.ts +50 -6
  117. package/src/hooks/ts/pre-tool-use/block-sleep-poll.test.ts +1 -1
  118. package/src/hooks/ts/session-start/load-core-context.ts +11 -8
  119. package/src/hooks/ts/session-start/session-start-worker-guard.test.ts +34 -2
  120. package/dist/auto-route-D6fW1Q8z.mjs +0 -3
  121. package/dist/config-Ddc4DIPk.mjs.map +0 -1
  122. package/dist/context-handover-cache-SFkWucuT.mjs.map +0 -1
  123. package/dist/daemon-BHQoEjt0.mjs +0 -18
  124. package/dist/daemon-vLtT3yaI.mjs.map +0 -1
  125. package/dist/embeddings-D0WOQpYR.mjs +0 -3
  126. package/dist/factory-CXFHwvcv.mjs.map +0 -1
  127. package/dist/factory-CnKsU6zX.mjs +0 -8
  128. package/dist/main-resolver-DrHtx8iW.mjs +0 -7
  129. package/dist/pai-home-UncxWxlX.mjs.map +0 -1
  130. package/dist/pai-marker-DXVpFsYz.mjs.map +0 -1
  131. package/dist/postgres-B2BEBT-J.mjs +0 -5
  132. package/dist/postgres-BxmyQHoA.mjs.map +0 -1
  133. package/dist/program-CfG1T_ko.mjs.map +0 -1
  134. package/dist/router-BK-hFeQ6.mjs +0 -3
  135. package/dist/run-ChlIbDJ0.mjs.map +0 -1
  136. package/dist/run-env-CQRnxNht.mjs.map +0 -1
  137. package/dist/runtime-paths-Bw3Jk8qJ.mjs +0 -52
  138. package/dist/runtime-paths-Bw3Jk8qJ.mjs.map +0 -1
  139. package/dist/zettelkasten-BRIaHzFm.mjs.map +0 -1
@@ -20,6 +20,8 @@ pai memory <subcommand> [options]
20
20
  | [`pai memory status [project-slug]`](#pai-memory-status-project-slug) | Show memory index statistics |
21
21
  | [`pai memory settings [key] [value]`](#pai-memory-settings-key-value) | View or modify search settings in the PAI config file (`pai config path`) |
22
22
  | [`pai memory sources`](#pai-memory-sources) | Show what the indexer has taken in: composition by source, which roots |
23
+ | [`pai memory backend`](#pai-memory-backend) | Embedding backends: detect, use, provision (an index is bound to the backend that embedded it) |
24
+ | [`pai memory reembed`](#pai-memory-reembed) | Switch the index to an embedding backend: clears all vectors (batched, resumable); `pai memory embed` refills |
23
25
 
24
26
  ### pai memory index [project-slug]
25
27
 
@@ -118,6 +120,56 @@ rewritten per day (which distinguishes a finite backlog from a treadmill).
118
120
  | `--limit <n>` | Rows per section (default 8) | `8` |
119
121
 
120
122
 
123
+ ### pai memory backend
124
+
125
+ Embedding backends: detect, use, provision (an index is bound to the backend that embedded it)
126
+
127
+
128
+ ### pai memory backend detect
129
+
130
+ Probe the available embedding backends (ollama, then transformers-cpu) and recommend the fastest
131
+
132
+
133
+ ### pai memory backend use <id>
134
+
135
+ Write the embedding backend to config (ollama-f16 | transformers-cpu-q8); does not touch the index
136
+
137
+ **Arguments**
138
+
139
+ | Argument | Kind |
140
+ |----------|------|
141
+ | `<id>` | required |
142
+
143
+ **Options**
144
+
145
+ | Option | Description | Default |
146
+ |--------|-------------|---------|
147
+ | `--model <name>` | Model name on the backend (ollama: server-side model name) | |
148
+
149
+
150
+ ### pai memory backend provision <backend>
151
+
152
+ Provision a backend's model. ollama: download the F16 GGUF of arctic-embed-m-v1.5 and `ollama create` it
153
+
154
+ **Arguments**
155
+
156
+ | Argument | Kind |
157
+ |----------|------|
158
+ | `<backend>` | required |
159
+
160
+
161
+ ### pai memory reembed
162
+
163
+ Switch the index to an embedding backend: clears all vectors (batched, resumable); `pai memory embed` refills
164
+
165
+ **Options**
166
+
167
+ | Option | Description | Default |
168
+ |--------|-------------|---------|
169
+ | `--backend <id>` | Target backend (default: the configured one); also written to config | |
170
+ | `--yes` | Confirm; without it only the plan is printed | |
171
+
172
+
121
173
  ## Examples
122
174
 
123
175
  ```bash
@@ -31,6 +31,7 @@ pai worker <subcommand> [options]
31
31
  | [`pai worker handoff <json>`](#pai-worker-handoff-json) | From inside a worker: append a handoff to the parent's inbox and (when it runs) say it to the parent. |
32
32
  | [`pai worker merge <id>`](#pai-worker-merge-id) | Merge a worker's worktree branch (worker/<id>) into the original checkout, then remove the worktree and delete the branch |
33
33
  | [`pai worker verify <id>`](#pai-worker-verify-id) | Compare a worker's committed changes with the working tree (or --against <ref>); exit 0 when every file is identical, so discarding is safe. Also reads an archived worker (refs/pai-archive/<id>) |
34
+ | [`pai worker wait-on <pid>`](#pai-worker-wait-on-pid) | Wait at most --max seconds (default 110, cap 115) for a detached job's pid; exit 0 gone, 3 still running, 2 never existed. |
34
35
  | [`pai worker wait <ids...>`](#pai-worker-wait-ids) | Poll workers until they finish; prints each result as one JSON line, exit 1 on failure or timeout |
35
36
  | [`pai worker discard <id>`](#pai-worker-discard-id) | Drop a worker's worktree and branch, keeping nothing |
36
37
  | [`pai worker gc`](#pai-worker-gc) | Archive leftover worker worktrees (state kept under refs/pai-archive/<id>; gitignored files are not kept) |
@@ -276,6 +277,27 @@ Compare a worker's committed changes with the working tree (or --against <ref>);
276
277
  | `--json` | print the result as JSON | |
277
278
 
278
279
 
280
+ ### pai worker wait-on <pid>
281
+
282
+ Wait at most --max seconds (default 110, cap 115) for a detached job's pid; exit 0 gone, 3 still running, 2 never existed.
283
+
284
+ Recipe: nohup sh -c 'CMD; echo $? > /tmp/job.rc' > /tmp/job.log 2>&1 & echo $! > /tmp/job.pid ; then repeat `pai worker wait-on $(cat /tmp/job.pid) --log /tmp/job.log` until exit 0.
285
+
286
+ **Arguments**
287
+
288
+ | Argument | Kind |
289
+ |----------|------|
290
+ | `<pid>` | required |
291
+
292
+ **Options**
293
+
294
+ | Option | Description | Default |
295
+ |--------|-------------|---------|
296
+ | `--log <file>` | Log file whose last lines are printed on return; <file>.rc holds the exit code | |
297
+ | `--max <secs>` | Maximum seconds to block (default 110, hard cap 115) | |
298
+ | `--tail <n>` | Log lines printed on return (default 20) | |
299
+
300
+
279
301
  ### pai worker wait <ids...>
280
302
 
281
303
  Poll workers until they finish; prints each result as one JSON line, exit 1 on failure or timeout
@@ -24,6 +24,7 @@ pai setup --yes --storage sqlite
24
24
  sudo apt install -y docker.io docker-compose-v2
25
25
  sudo usermod -aG docker "$USER" # then log out and in, or prefix the next command with: sg docker -c "…"
26
26
  export PAI_PG_SHARED_BUFFERS=256MB # only on small machines; the default 1GB must fit in RAM
27
+ export PAI_PG_SHM_SIZE=3g # optional; shared memory for parallel vector index builds (3g is the default)
27
28
  pai setup --yes --storage postgres
28
29
  ```
29
30
 
package/docs/memory.md CHANGED
@@ -94,3 +94,72 @@ Every chunk row carries a `last_accessed_at` timestamp updated on each `memory_g
94
94
  ### Multi-Tenant Support
95
95
 
96
96
  PAI isolates memory by project. Every chunk, entity, and observation row carries a `project_id` foreign key. Searches default to the current project; the `all_projects: true` flag (or `--all` CLI option) lifts the filter. Knowledge-graph triples carry a `project_id` as well, so cross-project tunnels (`memory_tunnels`) are detected explicitly rather than accidentally.
97
+
98
+ ## Embedding Backends
99
+
100
+ Semantic search needs vectors. PAI embeds with `Snowflake/snowflake-arctic-embed-m-v1.5` (768 dims, CLS pooling, L2-normalized, 512-token context) and can run it on two backends:
101
+
102
+ | id | what it is | speed (Apple M5) |
103
+ |----|------------|------------------|
104
+ | `transformers-cpu-q8` | in-process transformers.js, q8, CPU. Default, no server. | ~8 chunks/s |
105
+ | `ollama-f16` | local Ollama server (Metal GPU), F16 GGUF of the same model | ~50 chunks/s |
106
+
107
+ Both live behind one contract (`src/memory/backends/types.ts`: `id`, `model`, `dims`, `maxTokens`, `available()`, `embed()`); the daemon embed pass, `pai memory embed [--background]` and query embedding in search all go through the configured backend. A remote `http` backend (for example an MLX server) is a future addition behind the same contract.
108
+
109
+ ### Commands
110
+
111
+ ```
112
+ pai memory backend detect # probe ollama, then transformers-cpu; recommend the fastest available
113
+ pai memory backend provision ollama # download the F16 GGUF from Hugging Face and `ollama create` it
114
+ pai memory backend use <id> [--model <n>] # write embedding.backend (and embedding.model) to config
115
+ pai memory reembed [--backend <id>] [--yes]
116
+ ```
117
+
118
+ `pai setup` runs the same detection (also with `--yes`). A fresh install adopts the fastest available backend; an existing install is only told what is available.
119
+
120
+ - **provision** is idempotent (skipped when the model exists on the server), needs no sudo, verifies the download's size and SHA-256, and imports it under the configured model name (default `arctic-embed-m-v1.5-f16`).
121
+ - **reembed** prints the chunk count and an ETA from a quick throughput probe and refuses without `--yes`. It sets every stored vector to NULL in bounded batches (never one giant transaction; an interrupted run is resumed by running it again), then records the new binding. `pai memory embed` or the daemon pass refills the vectors; keyword search keeps working meanwhile.
122
+
123
+ Config (`pai config get embedding`):
124
+
125
+ ```yaml
126
+ embedding:
127
+ backend: ollama-f16 # default: transformers-cpu-q8
128
+ model: arctic-embed-m-v1.5-f16 # model name on the Ollama server
129
+ ollama:
130
+ baseUrl: http://127.0.0.1:11434
131
+ ```
132
+
133
+ ### Why vectors cannot be mixed
134
+
135
+ Vectors from different backends of the "same" model differ: q8-CPU against F16/fp32 vectors has a cosine of only about 0.97, so a query embedded by one backend ranks an index embedded by the other noticeably worse, and nothing signals it. The index is therefore bound to the backend that produced its vectors: backend id, model and dimensions are stored with the index (SQLite table `embedding_binding`, Postgres `pai_embedding_binding`), set on the first embed. An index that holds vectors but no binding is treated as `transformers-cpu-q8`.
136
+
137
+ When the configured backend differs from the recorded one, PAI does not embed and does not query with it. It reports `index embedded with X, configured Y: run pai memory reembed to switch`, the embed pass pauses, and searches fall back to keyword-only with that note. It never silently falls back to another backend.
138
+
139
+ When the configured backend is unavailable (for example Ollama is not running), embedding pauses with the chunks left unembedded and is retried by the next pass; queries go keyword-only with a visible note.
140
+
141
+ ### Token cap
142
+
143
+ Ollama cuts inputs longer than 510 tokens differently from the model itself (cosine about 0.45 against the reference). For `ollama-f16`, each text is therefore cut to 510 content tokens with the model's own tokenizer at embed time (the count is logged). Chunk boundaries in the database are not changed.
144
+
145
+ ## Status Dashboard
146
+
147
+ The daemon serves a read-only status page so unattended embedding and index passes can be watched, also from a phone on the tailnet. It is on by default and uses only `node:http`, with no external assets.
148
+
149
+ ```yaml
150
+ dashboard:
151
+ enabled: true # default
152
+ port: 8770 # default
153
+ bind: 127.0.0.1 # default; add the tailnet IP to reach it from other devices
154
+ ```
155
+
156
+ `pai daemon status` prints the URL. `GET /` is the page (refreshes every 10 s), `GET /api/status` the JSON behind it. Requests whose `Host` header is not the bound address, `localhost`, or this machine's tailnet name or IP (from `tailscale status --json`) get 403, which blocks DNS rebinding. Only GET and HEAD are accepted; the default bind is never `0.0.0.0`.
157
+
158
+ The page shows, with green/amber/red status:
159
+
160
+ - **Index**: files, chunks and embedding coverage, the recorded index binding against the configured backend, and the `pai memory reembed` hint on a mismatch (red).
161
+ - **Jobs** (table `pai_embedding_jobs`): phase, done/total as a bar, rate over the last minutes, ETA, and `STALLED` (red) when `done` has not moved for 5 min.
162
+ - **Backend**: the configured backend's availability (red when unavailable) and, for Ollama, the loaded models with their GPU share (`size_vram` / `size`).
163
+ - **Passes**: index, embed and vault pass state, last success, the failure with its error and next retry (red), and the next scheduled time.
164
+
165
+ Counts come from catalog statistics (`pg_class.reltuples`, `pg_stats`) and are marked "est."; real counts are refreshed in the background at most every 5 min (20 s statement timeout, estimate kept on failure). Nothing is computed per request.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tekmidian/pai",
3
- "version": "0.66.6",
3
+ "version": "0.66.8",
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",
@@ -51,7 +51,7 @@ describe("sleep-poll gate", () => {
51
51
 
52
52
  it("worker session (PAI_WORKER=1): deny reason points at a foreground timeout and a kill -0 loop instead", () => {
53
53
  const out = decisionOf(bash("sleep 590"), { PAI_WORKER: "1" });
54
- expect(out.permissionDecisionReason).toContain("FOREGROUND with a Bash timeout up to 600000 ms");
54
+ expect(out.permissionDecisionReason).toContain("pai worker wait-on $(cat /tmp/job.pid)");
55
55
  expect(out.permissionDecisionReason).not.toContain("run_in_background: true");
56
56
  });
57
57
 
@@ -71,7 +71,7 @@ describe("sleep-poll gate", () => {
71
71
  it("worker: run_in_background is denied with foreground advice; interactive keeps it", () => {
72
72
  const out = decisionOf(bash("npm test", true), { PAI_WORKER: "1" });
73
73
  expect(out.permissionDecision).toBe("deny");
74
- expect(out.permissionDecisionReason).toContain("FOREGROUND");
74
+ expect(out.permissionDecisionReason).toContain("detached");
75
75
  expect(decisionOf(bash("npm test", true), {}).permissionDecision).toBe("allow");
76
76
  expect(decisionOf(bash("npm test", false), { PAI_WORKER: "1" }).permissionDecision).toBe("allow");
77
77
  });
@@ -12,6 +12,7 @@
12
12
  */
13
13
 
14
14
  import { SLEEP_FLOOR_SECS, totalSleepSecs } from "../../../workers/status.js";
15
+ import { WAIT_RECIPE } from "../../../workers/wait-on.js";
15
16
 
16
17
  export interface SleepPollGateInput {
17
18
  tool_name?: string;
@@ -32,8 +33,7 @@ export function denyReason(secs: number, isWorker: boolean): string {
32
33
  if (isWorker) {
33
34
  return (
34
35
  lead +
35
- "In a worker, run the long command in the FOREGROUND with a Bash timeout up to 600000 ms " +
36
- "and split longer jobs into steps; run_in_background is denied here."
36
+ `In a worker, run a long job detached: ${WAIT_RECIPE}. run_in_background is denied here.`
37
37
  );
38
38
  }
39
39
  return (
@@ -50,8 +50,8 @@ export function denyReason(secs: number, isWorker: boolean): string {
50
50
  */
51
51
  const BACKGROUND_WORKER_REASON =
52
52
  "run_in_background is blocked in a worker: ending your turn ends this process and kills the job, " +
53
- "there is no completion notification. Run the command in the FOREGROUND with a Bash timeout up to " +
54
- "600000 ms, and split a longer job into steps that each fit in that.";
53
+ "there is no completion notification. Run a long job detached: " +
54
+ WAIT_RECIPE + ".";
55
55
 
56
56
  export function decideSleepPollGate(
57
57
  input: SleepPollGateInput,
@@ -0,0 +1,48 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { decideWorkerGuard, type WorkerGuardContext } from "./worker-guard.js";
3
+
4
+ const WT = "/work/wt/abc";
5
+ const ctx: WorkerGuardContext = {
6
+ cwd: WT,
7
+ home: "/home/u",
8
+ worktreeRoot: WT,
9
+ inWorktree: true,
10
+ mainCheckout: "/work/main",
11
+ testScript: () => "vitest",
12
+ };
13
+ const WORKER = { PAI_WORKER: "1" };
14
+ const run = (command: string, timeout?: number, env: Record<string, string> = WORKER) =>
15
+ decideWorkerGuard({ tool_name: "Bash", tool_input: { command, timeout } }, env, () => ctx);
16
+
17
+ const RECIPE = "nohup sh -c 'timeout 590 make; sleep 300; echo $? > /tmp/job.rc' > /tmp/job.log 2>&1 & echo $! > /tmp/job.pid";
18
+
19
+ describe("long foreground waits", () => {
20
+ it.each([
21
+ ["tool timeout 300000", "npm test", 300000],
22
+ ["the real w37274 command", "timeout 590 zsh rounds.sh | tail -40", undefined],
23
+ ["timeout with signal flag", "timeout -s KILL 590 make", undefined],
24
+ ["sleep 300", "sleep 300", undefined],
25
+ ["while + sleep 90", "while true; do sleep 90; done", undefined],
26
+ ["gtimeout 10m", "gtimeout 10m make", undefined],
27
+ ["inside bash -c", "bash -c 'sleep 300'", undefined],
28
+ ])("denies: %s", (_n, cmd, to) => {
29
+ const d = run(cmd, to);
30
+ expect(d.decision).toBe("deny");
31
+ const reason = (d as { reason: string }).reason;
32
+ expect(reason).toContain("pai worker wait-on $(cat /tmp/job.pid) --log /tmp/job.log");
33
+ expect(reason).toContain("block operator messages and supervision");
34
+ });
35
+
36
+ it.each([
37
+ ["timeout 100", "timeout 100 npm test", 120000],
38
+ ["wait-on", "pai worker wait-on 123 --log x", undefined],
39
+ ["nohup recipe", RECIPE, undefined],
40
+ ["sleep 30", "sleep 30", undefined],
41
+ ])("allows: %s", (_n, cmd, to) => {
42
+ expect(run(cmd, to).decision).toBe("allow");
43
+ });
44
+
45
+ it("allows long waits in an interactive session", () => {
46
+ expect(run("timeout 590 make", 600000, {}).decision).toBe("allow");
47
+ });
48
+ });
@@ -20,11 +20,13 @@ import { dirname, resolve } from "node:path";
20
20
  import { type GateDecision } from "./sleep-poll-gate.js";
21
21
  import { decideWorkerGitGuard } from "./worker-git-guard.js";
22
22
  import { isBrowserTool } from "../../../workers/browser-tools.js";
23
+ import { totalSleepSecs } from "../../../workers/status.js";
24
+ import { WAIT_RECIPE } from "../../../workers/wait-on.js";
23
25
 
24
26
  export interface WorkerGuardInput {
25
27
  tool_name?: string;
26
28
  cwd?: string;
27
- tool_input?: { command?: string; file_path?: string; path?: string };
29
+ tool_input?: { command?: string; file_path?: string; path?: string; timeout?: number };
28
30
  }
29
31
 
30
32
  export interface WorkerGuardContext {
@@ -200,7 +202,7 @@ const PATTERN_KILL_REASON =
200
202
  "`kill $(cat /tmp/x.pid)`), never by name or pattern; other workers' command lines contain your spec text";
201
203
 
202
204
  /** Decide one segment; `st.cwd` is the tracked working directory. */
203
- function decideSegment(seg: Segment, st: { cwd: string; raw: string }, ctx: WorkerGuardContext): GateDecision {
205
+ function decideSegment(seg: Segment, st: { cwd: string; raw: string }, ctx: WorkerGuardContext, detached = false): GateDecision {
204
206
  const words = stripPrefix(seg.words);
205
207
  const [cmd, ...args] = words;
206
208
  const boundary = ctx.worktreeRoot ?? ctx.cwd;
@@ -213,7 +215,7 @@ function decideSegment(seg: Segment, st: { cwd: string; raw: string }, ctx: Work
213
215
  if (!cmd) return ALLOW;
214
216
 
215
217
  if ((cmd === "bash" || cmd === "sh" || cmd === "zsh") && args[0] === "-c" && args[1]) {
216
- return decideCommand(args[1], { ...ctx, cwd: st.cwd });
218
+ return decideCommand(args[1], { ...ctx, cwd: st.cwd }, detached);
217
219
  }
218
220
 
219
221
  const pos = args.filter((a) => !isFlag(a));
@@ -292,10 +294,49 @@ function decideSegment(seg: Segment, st: { cwd: string; raw: string }, ctx: Work
292
294
  return ALLOW;
293
295
  }
294
296
 
295
- function decideCommand(command: string, ctx: WorkerGuardContext): GateDecision {
297
+ const MAX_FOREGROUND_SECS = 120;
298
+ const WAIT_REASON_TAIL =
299
+ "calls longer than 2 min block operator messages and supervision; long jobs run fine detached: " + WAIT_RECIPE + ".";
300
+
301
+ const unitSecs = (u: string): number => (u === "m" ? 60 : u === "h" ? 3600 : u === "d" ? 86_400 : 1);
302
+
303
+ /** Longest `timeout N` (timeout/gtimeout, any flags) among the segments, in seconds. */
304
+ function longestTimeoutSecs(segs: Segment[]): number {
305
+ let longest = 0;
306
+ for (const seg of segs) {
307
+ const [cmd, ...args] = stripPrefix(seg.words);
308
+ if (cmd !== "timeout" && cmd !== "gtimeout") continue;
309
+ for (let i = 0; i < args.length; i++) {
310
+ if (/^-[sk]$/.test(args[i])) i++;
311
+ else if (!isFlag(args[i])) {
312
+ const m = /^(\d+(?:\.\d+)?)([smhd]?)$/.exec(args[i]);
313
+ if (m) longest = Math.max(longest, Number(m[1]) * unitSecs(m[2]));
314
+ break;
315
+ }
316
+ }
317
+ }
318
+ return longest;
319
+ }
320
+
321
+ /** Deny a foreground command that would block longer than 2 min; `nohup …` segments are the detached launch and exempt. */
322
+ function longWaitReason(command: string, segs: Segment[]): string | null {
323
+ const fg = segs.filter((s) => s.words[0] !== "nohup");
324
+ const t = longestTimeoutSecs(fg);
325
+ if (t > MAX_FOREGROUND_SECS) return `timeout ${t}s blocked: ${WAIT_REASON_TAIL}`;
326
+ // Quoted text and nohup launches are not foreground sleeps; loops count at least one sleep floor (one parser: totalSleepSecs).
327
+ const bare = command.replace(/'[^']*'|"[^"]*"/g, "''").replace(/\bnohup\b[^;&|\n]*/g, "");
328
+ const s = totalSleepSecs(bare);
329
+ if (s > MAX_FOREGROUND_SECS) return `sleep ${s}s blocked: ${WAIT_REASON_TAIL}`;
330
+ return null;
331
+ }
332
+
333
+ function decideCommand(command: string, ctx: WorkerGuardContext, detached = false): GateDecision {
296
334
  const st = { cwd: ctx.cwd, raw: command };
297
- for (const seg of parseCommand(command)) {
298
- const d = decideSegment(seg, st, ctx);
335
+ const segs = parseCommand(command);
336
+ const long = detached ? null : longWaitReason(command, segs);
337
+ if (long) return deny(long);
338
+ for (const seg of segs) {
339
+ const d = decideSegment(seg, st, ctx, seg.words[0] === "nohup");
299
340
  if (d.decision === "deny") return d;
300
341
  }
301
342
  return ALLOW;
@@ -314,6 +355,9 @@ export function decideWorkerGuard(
314
355
  return deny(`${tool} blocked: this worker runs with --no-browser`);
315
356
  }
316
357
  if (tool === "Bash") {
358
+ if (typeof ti.timeout === "number" && ti.timeout > MAX_FOREGROUND_SECS * 1000) {
359
+ return deny(`Bash timeout ${ti.timeout} ms blocked: ${WAIT_REASON_TAIL}`);
360
+ }
317
361
  return typeof ti.command === "string" ? decideCommand(ti.command, ctx()) : ALLOW;
318
362
  }
319
363
  if (tool === "Edit" || tool === "Write" || tool === "MultiEdit" || tool === "NotebookEdit") {
@@ -31,7 +31,7 @@ describe("block-sleep-poll", () => {
31
31
  const out = runHook("sleep 590", {}, { PAI_WORKER: "1" });
32
32
  const parsed = JSON.parse(out).hookSpecificOutput;
33
33
  expect(parsed.permissionDecision).toBe("deny");
34
- expect(parsed.permissionDecisionReason).toContain("FOREGROUND with a Bash timeout up to 600000 ms");
34
+ expect(parsed.permissionDecisionReason).toContain("pai worker wait-on $(cat /tmp/job.pid)");
35
35
  });
36
36
 
37
37
  it("allows a short sleep (prints nothing)", () => {
@@ -4,10 +4,12 @@
4
4
  * load-core-context.ts
5
5
  *
6
6
  * Automatically loads your CORE skill context at session start by reading and injecting
7
- * the CORE SKILL.md file contents directly into Claude's context as a system-reminder.
7
+ * the core SKILL.md contents directly into Claude's context as a system-reminder.
8
+ * The file is the first existing of skills/CORE/SKILL.md, skills/PAI/SKILL.md
9
+ * (the latter is what `pai setup` installs); neither = silent no-op.
8
10
  *
9
11
  * Purpose:
10
- * - Read CORE SKILL.md file content
12
+ * - Read core SKILL.md file content
11
13
  * - Output content as system-reminder for Claude to process
12
14
  * - Ensure complete context (contacts, preferences, security, identity) available at session start
13
15
  * - Bypass skill activation logic by directly injecting context
@@ -52,13 +54,14 @@ async function main() {
52
54
  process.exit(0);
53
55
  }
54
56
 
55
- // Get CORE skill path using PAI paths library
56
- const coreSkillPath = join(SKILLS_DIR, 'CORE/SKILL.md');
57
+ // CORE wins; PAI/SKILL.md is where `pai setup` installs the core skill
58
+ const coreSkillPath = [join(SKILLS_DIR, 'CORE/SKILL.md'), join(SKILLS_DIR, 'PAI/SKILL.md')]
59
+ .find(existsSync);
57
60
 
58
61
  // CORE is an optional, user-provided skill (PAI ships none): absent = silent no-op
59
- if (!existsSync(coreSkillPath)) process.exit(0);
62
+ if (!coreSkillPath) process.exit(0);
60
63
 
61
- console.error('Reading CORE context from skill file...');
64
+ console.error(`Reading CORE context from ${coreSkillPath}...`);
62
65
 
63
66
  // Read the CORE SKILL.md file content
64
67
  let coreContent = readFileSync(coreSkillPath, 'utf-8');
@@ -75,7 +78,7 @@ async function main() {
75
78
  .replace(/\{\{DA_COLOR\}\}/g, daColor)
76
79
  .replace(/\{\{ENGINEER_NAME\}\}/g, engineerName);
77
80
 
78
- console.error(`Read ${coreContent.length} characters from CORE SKILL.md (Personalized for ${engineerName} & ${daName})`);
81
+ console.error(`Read ${coreContent.length} characters from ${coreSkillPath} (Personalized for ${engineerName} & ${daName})`);
79
82
 
80
83
  // Output the CORE content as a system-reminder, minus the frontmatter
81
84
  // (catalogue metadata the session already has from its skill list) —
@@ -98,7 +101,7 @@ ${stripFrontmatter(coreContent)}
98
101
  `<system-reminder>\n` +
99
102
  `Edit/Write on code files is denied in this session by policy (only .md/.txt and Notes/ are editable). ` +
100
103
  `For any code change: write a spec file, then ` +
101
- `\`pai worker run --provider anthropic --class implement -p "$(cat <spec>)"\`.\n` +
104
+ `\`pai worker run --class implement -p "$(cat <spec>)"\`.\n` +
102
105
  `</system-reminder>`
103
106
  );
104
107
 
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect } from "vitest";
2
2
  import { execFileSync, spawnSync } from "node:child_process";
3
- import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
3
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
 
@@ -41,7 +41,8 @@ describe("session-start hooks: worker sessions get no injected context", () => {
41
41
  // whose ambient env would otherwise leak into the child and short-circuit it.
42
42
  const stdout = runHook("src/hooks/ts/session-start/load-core-context.ts", { PAI_WORKER: "" });
43
43
  expect(stdout).toContain("Edit/Write on code files is denied in this session by policy");
44
- expect(stdout).toContain("pai worker run --provider anthropic --class implement");
44
+ expect(stdout).toContain("pai worker run --class implement");
45
+ expect(stdout).not.toContain("--provider anthropic");
45
46
  });
46
47
 
47
48
  it("load-core-context.ts omits the edit-gate notice when PAI_WORKER=1", () => {
@@ -68,4 +69,35 @@ describe("load-core-context.ts: CORE is optional", () => {
68
69
  rmSync(home, { recursive: true, force: true });
69
70
  }
70
71
  });
72
+
73
+ function runWithSkills(skills: Record<string, string>) {
74
+ const home = mkdtempSync(join(tmpdir(), "pai-core-"));
75
+ try {
76
+ for (const [name, marker] of Object.entries(skills)) {
77
+ mkdirSync(join(home, ".claude", "skills", name), { recursive: true });
78
+ writeFileSync(join(home, ".claude", "skills", name, "SKILL.md"), marker);
79
+ }
80
+ return spawnSync("bun", ["src/hooks/ts/session-start/load-core-context.ts"], {
81
+ input: JSON.stringify({ session_id: "t", hook_event_name: "SessionStart", source: "startup" }),
82
+ encoding: "utf8",
83
+ timeout: 15_000,
84
+ env: { ...process.env, HOME: home, ADAPTER_DIR: "", PAI_DIR: "", PAI_WORKER: "" },
85
+ });
86
+ } finally {
87
+ rmSync(home, { recursive: true, force: true });
88
+ }
89
+ }
90
+
91
+ it("falls back to skills/PAI/SKILL.md (the `pai setup` layout) when CORE is absent", () => {
92
+ const r = runWithSkills({ PAI: "PAI-ONLY-MARKER" });
93
+ expect(r.status).toBe(0);
94
+ expect(r.stdout).toContain("PAI CORE CONTEXT");
95
+ expect(r.stdout).toContain("PAI-ONLY-MARKER");
96
+ });
97
+
98
+ it("prefers CORE over PAI when both exist", () => {
99
+ const r = runWithSkills({ CORE: "CORE-WINS-MARKER", PAI: "PAI-LOSES-MARKER" });
100
+ expect(r.stdout).toContain("CORE-WINS-MARKER");
101
+ expect(r.stdout).not.toContain("PAI-LOSES-MARKER");
102
+ });
71
103
  });
@@ -1,3 +0,0 @@
1
- import { n as formatAutoRouteJson, t as autoRoute } from "./auto-route-BnizyALK.mjs";
2
-
3
- export { autoRoute, formatAutoRouteJson };