@tekmidian/pai 0.64.0 → 0.65.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 (180) hide show
  1. package/README.md +19 -5
  2. package/dist/{aibroker-client-BpfuGPe5.mjs → aibroker-client-B8c42Lh8.mjs} +1 -1
  3. package/dist/{aibroker-client-DRld7g6z.mjs → aibroker-client-C5Fw7DNz.mjs} +10 -4
  4. package/dist/{aibroker-client-DRld7g6z.mjs.map → aibroker-client-C5Fw7DNz.mjs.map} +1 -1
  5. package/dist/{async-CY_cd_8j.mjs → async-CNn36gm4.mjs} +2 -2
  6. package/dist/{async-CY_cd_8j.mjs.map → async-CNn36gm4.mjs.map} +1 -1
  7. package/dist/{auto-route-CPlam4iv.mjs → auto-route-sLMU-NnM.mjs} +2 -2
  8. package/dist/{auto-route-CPlam4iv.mjs.map → auto-route-sLMU-NnM.mjs.map} +1 -1
  9. package/dist/{chain-o-g8AgE3.mjs → chain-C8QO8gwj.mjs} +4 -4
  10. package/dist/{chain-o-g8AgE3.mjs.map → chain-C8QO8gwj.mjs.map} +1 -1
  11. package/dist/cli/index.mjs +19 -19
  12. package/dist/cli/program.d.mts.map +1 -1
  13. package/dist/cli/program.mjs +19 -19
  14. package/dist/{clusters-DsMf20PP.mjs → clusters-PgIUYT_v.mjs} +1 -1
  15. package/dist/{clusters-DsMf20PP.mjs.map → clusters-PgIUYT_v.mjs.map} +1 -1
  16. package/dist/{config-2wkz744W.mjs → config-BbLFD7Uf.mjs} +3 -3
  17. package/dist/{config-2wkz744W.mjs.map → config-BbLFD7Uf.mjs.map} +1 -1
  18. package/dist/{config-CUTg6VDq.mjs → config-YinjgXEJ.mjs} +1 -1
  19. package/dist/{context-handover-cache-BOpjLsKe.mjs → context-handover-cache-mGxq2-8f.mjs} +11 -29
  20. package/dist/context-handover-cache-mGxq2-8f.mjs.map +1 -0
  21. package/dist/daemon/index.mjs +16 -16
  22. package/dist/daemon-CX9JomIJ.mjs +19 -0
  23. package/dist/{daemon-BYieXduQ.mjs → daemon-D2r1AQqE.mjs} +30 -30
  24. package/dist/{daemon-BYieXduQ.mjs.map → daemon-D2r1AQqE.mjs.map} +1 -1
  25. package/dist/daemon-mcp/index.mjs +17 -17
  26. package/dist/daemon-mcp/index.mjs.map +1 -1
  27. package/dist/detector-C3Q7mxQU.mjs +3 -0
  28. package/dist/{detector-BAlmrLQ0.mjs → detector-CIHEXKsV.mjs} +1 -1
  29. package/dist/{detector-BAlmrLQ0.mjs.map → detector-CIHEXKsV.mjs.map} +1 -1
  30. package/dist/{embeddings-DK9XfQic.mjs → embeddings-BbNVXa_0.mjs} +1 -1
  31. package/dist/{embeddings-DK9XfQic.mjs.map → embeddings-BbNVXa_0.mjs.map} +1 -1
  32. package/dist/{embeddings-Xg8XLf2K.mjs → embeddings-CDfM63uC.mjs} +1 -1
  33. package/dist/{factory-BMK0tC1b.mjs → factory-CaswxuJ0.mjs} +1 -1
  34. package/dist/{factory-C4We3xR_.mjs → factory-y36qGegI.mjs} +13 -13
  35. package/dist/{factory-C4We3xR_.mjs.map → factory-y36qGegI.mjs.map} +1 -1
  36. package/dist/{fallback-ECiqCuh6.mjs → fallback-CWDQYmJi.mjs} +1205 -1204
  37. package/dist/fallback-CWDQYmJi.mjs.map +1 -0
  38. package/dist/{federation-db-CUgy0WYs.mjs → federation-db-BTyoufBh.mjs} +2 -2
  39. package/dist/{federation-db-CUgy0WYs.mjs.map → federation-db-BTyoufBh.mjs.map} +1 -1
  40. package/dist/federation-db-HfIFc7FG.mjs +3 -0
  41. package/dist/hooks/block-sleep-poll.mjs +36 -11
  42. package/dist/hooks/block-sleep-poll.mjs.map +2 -2
  43. package/dist/hooks/capture-all-events.mjs +3 -17
  44. package/dist/hooks/capture-all-events.mjs.map +3 -3
  45. package/dist/hooks/capture-session-summary.mjs +3 -17
  46. package/dist/hooks/capture-session-summary.mjs.map +3 -3
  47. package/dist/hooks/capture-tool-output.mjs +3 -17
  48. package/dist/hooks/capture-tool-output.mjs.map +3 -3
  49. package/dist/hooks/cleanup-session-files.mjs +3 -17
  50. package/dist/hooks/cleanup-session-files.mjs.map +2 -2
  51. package/dist/hooks/context-compression-hook.mjs +3 -17
  52. package/dist/hooks/context-compression-hook.mjs.map +3 -3
  53. package/dist/hooks/initialize-session.mjs +3 -17
  54. package/dist/hooks/initialize-session.mjs.map +2 -2
  55. package/dist/hooks/inject-observations.mjs +3 -17
  56. package/dist/hooks/inject-observations.mjs.map +2 -2
  57. package/dist/hooks/load-core-context.mjs +4 -22
  58. package/dist/hooks/load-core-context.mjs.map +2 -2
  59. package/dist/hooks/load-project-context.mjs +3 -17
  60. package/dist/hooks/load-project-context.mjs.map +3 -3
  61. package/dist/hooks/mcp-deferred-gate.mjs +3 -17
  62. package/dist/hooks/mcp-deferred-gate.mjs.map +2 -2
  63. package/dist/hooks/observe.mjs +3 -17
  64. package/dist/hooks/observe.mjs.map +2 -2
  65. package/dist/hooks/security-validator.mjs +3 -17
  66. package/dist/hooks/security-validator.mjs.map +3 -3
  67. package/dist/hooks/stop-hook.mjs +3 -17
  68. package/dist/hooks/stop-hook.mjs.map +3 -3
  69. package/dist/hooks/sync-todo-to-md.mjs +3 -17
  70. package/dist/hooks/sync-todo-to-md.mjs.map +2 -2
  71. package/dist/hooks/worker-guard.mjs.map +2 -2
  72. package/dist/hooks/worker-status-line.mjs +4 -4
  73. package/dist/hooks/worker-status-line.mjs.map +2 -2
  74. package/dist/{indexer-backend-CJ0RGgMz.mjs → indexer-backend-Cnc7Tf5C.mjs} +2 -2
  75. package/dist/{ipc-client-EhWY8XL0.mjs → ipc-client-D16Xw6Uo.mjs} +2 -2
  76. package/dist/{ipc-client-EhWY8XL0.mjs.map → ipc-client-D16Xw6Uo.mjs.map} +1 -1
  77. package/dist/{kg-entity-1uqCnk4u.mjs → kg-entity-COTUj1ZC.mjs} +1 -1
  78. package/dist/{kg-entity-1uqCnk4u.mjs.map → kg-entity-COTUj1ZC.mjs.map} +1 -1
  79. package/dist/{latent-ideas-CSuKfiq3.mjs → latent-ideas-ByvMjVcb.mjs} +3 -3
  80. package/dist/{latent-ideas-CSuKfiq3.mjs.map → latent-ideas-ByvMjVcb.mjs.map} +1 -1
  81. package/dist/{link-boost-BtjzfE3c.mjs → link-boost-UiE-uooT.mjs} +1 -1
  82. package/dist/{link-boost-BtjzfE3c.mjs.map → link-boost-UiE-uooT.mjs.map} +1 -1
  83. package/dist/{main-resolver-DyOrwNYw.mjs → main-resolver-CDe7DCso.mjs} +6 -6
  84. package/dist/{main-resolver-DyOrwNYw.mjs.map → main-resolver-CDe7DCso.mjs.map} +1 -1
  85. package/dist/main-resolver-DI-A7lSO.mjs +7 -0
  86. package/dist/merge-2gqRPFu2.mjs +3 -0
  87. package/dist/{merge-D58n_-LZ.mjs → merge-DgU9OgZy.mjs} +1 -1
  88. package/dist/{merge-D58n_-LZ.mjs.map → merge-DgU9OgZy.mjs.map} +1 -1
  89. package/dist/module-paths-DdRzbkUI.mjs +44 -0
  90. package/dist/module-paths-DdRzbkUI.mjs.map +1 -0
  91. package/dist/{neighborhood-DNuRelRB.mjs → neighborhood-CvRHlqdR.mjs} +2 -2
  92. package/dist/{neighborhood-DNuRelRB.mjs.map → neighborhood-CvRHlqdR.mjs.map} +1 -1
  93. package/dist/{note-context-D9JZ4-7o.mjs → note-context-BrbfUIoP.mjs} +1 -1
  94. package/dist/{note-context-D9JZ4-7o.mjs.map → note-context-BrbfUIoP.mjs.map} +1 -1
  95. package/dist/{pai-home-UncxWxlX.mjs → pai-home-Cm9rcJgX.mjs} +1 -1
  96. package/dist/{pai-home-UncxWxlX.mjs.map → pai-home-Cm9rcJgX.mjs.map} +1 -1
  97. package/dist/{planner-DgC3oSvb.mjs → planner-8-shIa8t.mjs} +7 -7
  98. package/dist/{planner-DgC3oSvb.mjs.map → planner-8-shIa8t.mjs.map} +1 -1
  99. package/dist/{postgres-CcsRKir-.mjs → postgres-DYtZg7J7.mjs} +5 -13
  100. package/dist/{postgres-CcsRKir-.mjs.map → postgres-DYtZg7J7.mjs.map} +1 -1
  101. package/dist/{program-JhF2GgJY.mjs → program-DiktbiWa.mjs} +225 -245
  102. package/dist/program-DiktbiWa.mjs.map +1 -0
  103. package/dist/{query-feedback-BPa0dISE.mjs → query-feedback-BIaZTTFO.mjs} +2 -2
  104. package/dist/{query-feedback-BPa0dISE.mjs.map → query-feedback-BIaZTTFO.mjs.map} +1 -1
  105. package/dist/query-feedback-B_iigYj-.mjs +3 -0
  106. package/dist/{registry-db-DCzdI4sC.mjs → registry-db-C7voqML9.mjs} +2 -2
  107. package/dist/{registry-db-DCzdI4sC.mjs.map → registry-db-C7voqML9.mjs.map} +1 -1
  108. package/dist/registry-db-JHPhA8vF.mjs +3 -0
  109. package/dist/{registry-postgres-BUnkHKYs.mjs → registry-postgres-DSrkxqfF.mjs} +2 -2
  110. package/dist/{registry-postgres-BUnkHKYs.mjs.map → registry-postgres-DSrkxqfF.mjs.map} +1 -1
  111. package/dist/{registry-sqlite-CKYNwkUv.mjs → registry-sqlite-B2436JgX.mjs} +2 -2
  112. package/dist/{registry-sqlite-CKYNwkUv.mjs.map → registry-sqlite-B2436JgX.mjs.map} +1 -1
  113. package/dist/router-S6C5BxzZ.mjs +3 -0
  114. package/dist/{router-CGZATlvm.mjs → router-aqLMvNMg.mjs} +1 -1
  115. package/dist/{router-CGZATlvm.mjs.map → router-aqLMvNMg.mjs.map} +1 -1
  116. package/dist/{run-W2rV_9j0.mjs → run-DUZRSnIT.mjs} +120 -42
  117. package/dist/run-DUZRSnIT.mjs.map +1 -0
  118. package/dist/{run-env-Br4Bc7Vz.mjs → run-env-BHWnXqle.mjs} +3 -3
  119. package/dist/{run-env-Br4Bc7Vz.mjs.map → run-env-BHWnXqle.mjs.map} +1 -1
  120. package/dist/{runtime-paths-QGQJAekd.mjs → runtime-paths-CHTg3ywb.mjs} +1 -1
  121. package/dist/{runtime-paths-QGQJAekd.mjs.map → runtime-paths-CHTg3ywb.mjs.map} +1 -1
  122. package/dist/{server-B71rem4q.mjs → server-DlL1QI2k.mjs} +4 -5
  123. package/dist/server-DlL1QI2k.mjs.map +1 -0
  124. package/dist/{session-keepalive-Dgil9hjw.mjs → session-keepalive-CZUwp5IQ.mjs} +7 -7
  125. package/dist/{session-keepalive-Dgil9hjw.mjs.map → session-keepalive-CZUwp5IQ.mjs.map} +1 -1
  126. package/dist/skills/Art/SKILL.md +1 -1
  127. package/dist/skills/Observability/SKILL.md +4 -4
  128. package/dist/skills/Research/SKILL.md +1 -1
  129. package/dist/{sources-LcGptLA-.mjs → sources-DX4ElmrE.mjs} +1 -1
  130. package/dist/{sources-LcGptLA-.mjs.map → sources-DX4ElmrE.mjs.map} +1 -1
  131. package/dist/{sqlite-CAJcw2zL.mjs → sqlite-BrEu3avy.mjs} +3 -3
  132. package/dist/{sqlite-CAJcw2zL.mjs.map → sqlite-BrEu3avy.mjs.map} +1 -1
  133. package/dist/{state-S9wlKarB.mjs → state-DW8zdweW.mjs} +1 -1
  134. package/dist/{state-CHltNjXI.mjs → state-HyjqTihC.mjs} +1 -1
  135. package/dist/{state-CHltNjXI.mjs.map → state-HyjqTihC.mjs.map} +1 -1
  136. package/dist/{themes-D1FQFthd.mjs → themes-BgqahYVM.mjs} +2 -2
  137. package/dist/{themes-D1FQFthd.mjs.map → themes-BgqahYVM.mjs.map} +1 -1
  138. package/dist/{tools-BW7OXf-N.mjs → tools-BbqQIHPe.mjs} +4 -4
  139. package/dist/{tools-BFC-113F.mjs → tools-gMGDIpJ9.mjs} +18 -18
  140. package/dist/{tools-BFC-113F.mjs.map → tools-gMGDIpJ9.mjs.map} +1 -1
  141. package/dist/{trace-IDKK1VFs.mjs → trace-CmAB7iJZ.mjs} +1 -1
  142. package/dist/{trace-IDKK1VFs.mjs.map → trace-CmAB7iJZ.mjs.map} +1 -1
  143. package/dist/{vault-indexer-y6YC2-mh.mjs → vault-indexer-Ddq51X20.mjs} +1 -1
  144. package/dist/{vault-indexer-y6YC2-mh.mjs.map → vault-indexer-Ddq51X20.mjs.map} +1 -1
  145. package/dist/{wakeup-CNZQZzsY.mjs → wakeup-CZw88uXf.mjs} +3 -3
  146. package/dist/{wakeup-CNZQZzsY.mjs.map → wakeup-CZw88uXf.mjs.map} +1 -1
  147. package/dist/{work-queue-worker-Dy5FT2ak.mjs → work-queue-worker-DCzfH0d1.mjs} +4 -4
  148. package/dist/{work-queue-worker-Dy5FT2ak.mjs.map → work-queue-worker-DCzfH0d1.mjs.map} +1 -1
  149. package/dist/work-queue-worker-Taba_vcA.mjs +11 -0
  150. package/dist/{zettelkasten-Dx-63BEk.mjs → zettelkasten-Dwj67e4p.mjs} +4 -4
  151. package/dist/{zettelkasten-Dx-63BEk.mjs.map → zettelkasten-Dwj67e4p.mjs.map} +1 -1
  152. package/docker/docker-compose.yml +39 -0
  153. package/docs/commands/README.md +3 -3
  154. package/docs/commands/config.md +3 -3
  155. package/docs/commands/setup.md +9 -2
  156. package/docs/commands/worker.md +2 -2
  157. package/package.json +3 -2
  158. package/scripts/build-hooks.mjs +6 -6
  159. package/src/hooks/ts/lib/pai-paths-case.test.ts +12 -0
  160. package/src/hooks/ts/lib/pai-paths-import.test.ts +25 -0
  161. package/src/hooks/ts/lib/pai-paths.ts +5 -28
  162. package/src/hooks/ts/lib/sleep-poll-gate.test.ts +17 -1
  163. package/src/hooks/ts/lib/sleep-poll-gate.ts +21 -9
  164. package/src/hooks/ts/session-start/load-core-context.ts +2 -6
  165. package/src/hooks/ts/session-start/session-start-worker-guard.test.ts +24 -1
  166. package/dist/context-handover-cache-BOpjLsKe.mjs.map +0 -1
  167. package/dist/daemon-q3xjHwa4.mjs +0 -19
  168. package/dist/detector-C-YmZsOi.mjs +0 -3
  169. package/dist/fallback-ECiqCuh6.mjs.map +0 -1
  170. package/dist/federation-db-BZ8PyxFe.mjs +0 -3
  171. package/dist/main-resolver-vN09bkXv.mjs +0 -7
  172. package/dist/merge-CXGz5wXz.mjs +0 -3
  173. package/dist/program-JhF2GgJY.mjs.map +0 -1
  174. package/dist/query-feedback-BPHFFSu1.mjs +0 -3
  175. package/dist/registry-db-Hphlj3H4.mjs +0 -3
  176. package/dist/router-CoA8Uy1m.mjs +0 -3
  177. package/dist/run-W2rV_9j0.mjs.map +0 -1
  178. package/dist/server-B71rem4q.mjs.map +0 -1
  179. package/dist/work-queue-worker-CkShUa0z.mjs +0 -11
  180. /package/dist/{indexer-backend-DotpxHJd.mjs → indexer-backend-isSLg6yE.mjs} +0 -0
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Importing the shared path module must never exit the host process, even on
3
+ * a fresh machine with no ~/.claude — `pai setup` is what creates it.
4
+ */
5
+
6
+ import { it, expect } from "vitest";
7
+ import { mkdtempSync } from "node:fs";
8
+ import { spawnSync } from "node:child_process";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+
12
+ it("importing pai-paths with an empty HOME does not exit", () => {
13
+ const home = mkdtempSync(join(tmpdir(), "pai-paths-import-"));
14
+ const env: NodeJS.ProcessEnv = { ...process.env, HOME: home };
15
+ delete env.PAI_DIR;
16
+ delete env.ADAPTER_DIR;
17
+ const r = spawnSync(
18
+ process.execPath,
19
+ ["--import", "tsx", "-e", "await import('./src/hooks/ts/lib/pai-paths.ts'); console.log('ok')"],
20
+ { env, encoding: "utf8" },
21
+ );
22
+ expect(r.stderr).not.toContain("not found");
23
+ expect(r.stdout.trim()).toBe("ok");
24
+ expect(r.status).toBe(0);
25
+ });
@@ -6,8 +6,8 @@
6
6
  * Two different things live here, and they must not be confused:
7
7
  *
8
8
  * - ADAPTER_DIR (~/.claude by default) is the Claude Code harness adapter —
9
- * fixed, hardcoded paths the harness itself loads from (Hooks/, Skills/,
10
- * Agents/, Commands/, settings.json, statusline-command.sh,
9
+ * fixed, hardcoded paths the harness itself loads from (Hooks/, skills/,
10
+ * agents/, commands/, settings.json, statusline-command.sh,
11
11
  * tab-color-command.sh). It is harness-specific: a future harness would
12
12
  * need its own adapter directory with its own conventions.
13
13
  * - PAI_HOME (~/.claude/pai by default — see ../../../config/pai-home.ts) is
@@ -165,32 +165,9 @@ export const PAI_DIR = ADAPTER_DIR;
165
165
  * from. These stay under ADAPTER_DIR; they are not PAI state.
166
166
  */
167
167
  export const HOOKS_DIR = join(ADAPTER_DIR, 'Hooks');
168
- export const SKILLS_DIR = join(ADAPTER_DIR, 'Skills');
169
- export const AGENTS_DIR = join(ADAPTER_DIR, 'Agents');
170
- export const COMMANDS_DIR = join(ADAPTER_DIR, 'Commands');
171
-
172
- /**
173
- * Validate PAI directory structure on first import
174
- * This fails fast with a clear error if PAI is misconfigured
175
- */
176
- function validatePAIStructure(): void {
177
- if (!existsSync(ADAPTER_DIR)) {
178
- console.error(`ADAPTER_DIR does not exist: ${ADAPTER_DIR}`);
179
- console.error(` Expected ~/.claude or set ADAPTER_DIR environment variable`);
180
- process.exit(1);
181
- }
182
-
183
- if (!existsSync(HOOKS_DIR)) {
184
- console.error(`PAI hooks directory not found: ${HOOKS_DIR}`);
185
- console.error(` Your ADAPTER_DIR may be misconfigured`);
186
- console.error(` Current ADAPTER_DIR: ${ADAPTER_DIR}`);
187
- process.exit(1);
188
- }
189
- }
190
-
191
- // Run validation on module import
192
- // This ensures any hook that imports this module will fail fast if paths are wrong
193
- validatePAIStructure();
168
+ export const SKILLS_DIR = join(ADAPTER_DIR, 'skills');
169
+ export const AGENTS_DIR = join(ADAPTER_DIR, 'agents');
170
+ export const COMMANDS_DIR = join(ADAPTER_DIR, 'commands');
194
171
 
195
172
  // ---------------------------------------------------------------------------
196
173
  // PAI state written by hooks — resolves under PAI_HOME, falling back to the
@@ -52,11 +52,27 @@ describe("sleep-poll gate", () => {
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
54
  expect(out.permissionDecisionReason).toContain("FOREGROUND with a Bash timeout up to 600000 ms");
55
- expect(out.permissionDecisionReason).toContain("while kill -0 <pid> 2>/dev/null; do sleep 15; done");
56
55
  expect(out.permissionDecisionReason).not.toContain("run_in_background: true");
57
56
  });
58
57
 
59
58
  it("names the exact offending duration in the reason", () => {
60
59
  expect(decisionOf(bash("sleep 2m")).permissionDecisionReason).toContain("sleep 120s blocked");
61
60
  });
61
+
62
+ it("sums sleeps across one command", () => {
63
+ for (const cmd of ["sleep 58; sleep 58", "sleep 30 && sleep 30", "sleep 1m\nsleep 1", "sleep 0.5m; sleep 31", "for i in 1 2 3; do sleep 30; done", "until false; do sleep 5; done"]) {
64
+ expect(decisionOf(bash(cmd)).permissionDecision, cmd).toBe("deny");
65
+ }
66
+ for (const cmd of ["sleep 30; sleep 20", "for i in 1 2; do sleep 20; done", "for i in {1..3}; do sleep 10; done", "while kill -0 123 2>/dev/null; do sleep 15; done"]) {
67
+ expect(decisionOf(bash(cmd)).permissionDecision, cmd).toBe("allow");
68
+ }
69
+ });
70
+
71
+ it("worker: run_in_background is denied with foreground advice; interactive keeps it", () => {
72
+ const out = decisionOf(bash("npm test", true), { PAI_WORKER: "1" });
73
+ expect(out.permissionDecision).toBe("deny");
74
+ expect(out.permissionDecisionReason).toContain("FOREGROUND");
75
+ expect(decisionOf(bash("npm test", true), {}).permissionDecision).toBe("allow");
76
+ expect(decisionOf(bash("npm test", false), { PAI_WORKER: "1" }).permissionDecision).toBe("allow");
77
+ });
62
78
  });
@@ -6,12 +6,12 @@
6
6
  * same split as lib/agent-gate.ts and lib/edit-gate.ts.
7
7
  *
8
8
  * CLAUDE.md already says "never sleep-poll"; prose does not enforce it, a
9
- * PreToolUse deny does. `run_in_background: true` is always allowed — a
9
+ * PreToolUse deny does. `run_in_background: true` is allowed in an interactive session (denied in a worker) — a
10
10
  * backgrounded sleep blocks nothing, it is the harness's own long-command
11
11
  * pattern.
12
12
  */
13
13
 
14
- import { longestSleepSecs } from "../../../workers/status.js";
14
+ import { SLEEP_FLOOR_SECS, totalSleepSecs } from "../../../workers/status.js";
15
15
 
16
16
  export interface SleepPollGateInput {
17
17
  tool_name?: string;
@@ -32,9 +32,8 @@ export function denyReason(secs: number, isWorker: boolean): string {
32
32
  if (isWorker) {
33
33
  return (
34
34
  lead +
35
- "In a worker, run the long command in the FOREGROUND with a Bash timeout up to 600000 ms. " +
36
- "If it can run longer, start it in the background and wait with a foreground loop that ends " +
37
- "when the job ends: while kill -0 <pid> 2>/dev/null; do sleep 15; done"
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."
38
37
  );
39
38
  }
40
39
  return (
@@ -44,20 +43,33 @@ export function denyReason(secs: number, isWorker: boolean): string {
44
43
  );
45
44
  }
46
45
 
46
+ /**
47
+ * A headless worker (`claude -p`) has no re-invocation: ending its turn ends
48
+ * the process and kills the background job, whatever it says about waiting for
49
+ * a completion notification.
50
+ */
51
+ const BACKGROUND_WORKER_REASON =
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.";
55
+
47
56
  export function decideSleepPollGate(
48
57
  input: SleepPollGateInput,
49
58
  env: Record<string, string | undefined>
50
59
  ): GateDecision {
51
60
  if (input.tool_name && input.tool_name !== "Bash") return { decision: "allow" };
52
- if (input.tool_input?.run_in_background === true) return { decision: "allow" };
61
+ const isWorker = env.PAI_WORKER === "1";
62
+ if (input.tool_input?.run_in_background === true) {
63
+ return isWorker ? { decision: "deny", reason: BACKGROUND_WORKER_REASON } : { decision: "allow" };
64
+ }
53
65
 
54
66
  const command = input.tool_input?.command;
55
67
  if (typeof command !== "string") return { decision: "allow" };
56
68
 
57
- const secs = longestSleepSecs(command);
58
- if (secs === null) return { decision: "allow" };
69
+ const secs = totalSleepSecs(command);
70
+ if (secs < SLEEP_FLOOR_SECS) return { decision: "allow" };
59
71
 
60
- return { decision: "deny", reason: denyReason(secs, env.PAI_WORKER === "1") };
72
+ return { decision: "deny", reason: denyReason(secs, isWorker) };
61
73
  }
62
74
 
63
75
  /** Render a decision as the JSON the harness reads on the hook's stdout. */
@@ -55,12 +55,8 @@ async function main() {
55
55
  // Get CORE skill path using PAI paths library
56
56
  const coreSkillPath = join(SKILLS_DIR, 'CORE/SKILL.md');
57
57
 
58
- // Verify CORE skill file exists
59
- if (!existsSync(coreSkillPath)) {
60
- console.error(`CORE skill not found at: ${coreSkillPath}`);
61
- console.error(`Ensure CORE/SKILL.md exists or check ADAPTER_DIR environment variable`);
62
- process.exit(1);
63
- }
58
+ // CORE is an optional, user-provided skill (PAI ships none): absent = silent no-op
59
+ if (!existsSync(coreSkillPath)) process.exit(0);
64
60
 
65
61
  console.error('Reading CORE context from skill file...');
66
62
 
@@ -1,5 +1,8 @@
1
1
  import { describe, it, expect } from "vitest";
2
- import { execFileSync } from "node:child_process";
2
+ import { execFileSync, spawnSync } from "node:child_process";
3
+ import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
3
6
 
4
7
  // Every SessionStart hook entrypoint calls main() unconditionally at module
5
8
  // scope and (for most of them) blocks reading fd 0 — importing them in-process
@@ -46,3 +49,23 @@ describe("session-start hooks: worker sessions get no injected context", () => {
46
49
  expect(stdout).not.toContain("Edit/Write on code files is denied");
47
50
  });
48
51
  });
52
+
53
+ describe("load-core-context.ts: CORE is optional", () => {
54
+ it("exits 0 with empty stdout/stderr when ~/.claude/skills/CORE/SKILL.md is absent", () => {
55
+ const home = mkdtempSync(join(tmpdir(), "pai-core-"));
56
+ try {
57
+ mkdirSync(join(home, ".claude", "skills"), { recursive: true });
58
+ const r = spawnSync("bun", ["src/hooks/ts/session-start/load-core-context.ts"], {
59
+ input: JSON.stringify({ session_id: "t", hook_event_name: "SessionStart", source: "startup" }),
60
+ encoding: "utf8",
61
+ timeout: 15_000,
62
+ env: { ...process.env, HOME: home, ADAPTER_DIR: "", PAI_DIR: "", PAI_WORKER: "" },
63
+ });
64
+ expect(r.status).toBe(0);
65
+ expect(r.stdout).toBe("");
66
+ expect(r.stderr).not.toContain("CORE skill not found");
67
+ } finally {
68
+ rmSync(home, { recursive: true, force: true });
69
+ }
70
+ });
71
+ });