@desplega.ai/agent-swarm 1.145.0 → 1.147.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 (221) hide show
  1. package/README.md +2 -1
  2. package/dist/{acp-adapter-580t41c0.js → acp-adapter-9n319wqc.js} +61 -6
  3. package/dist/{actions-m662tjng.js → actions-2vxqvpr9.js} +9 -8
  4. package/dist/{app-zb3rndm8.js → app-w066xfy0.js} +6 -5
  5. package/dist/assistant-kt31dj2h.js +236 -0
  6. package/dist/{boot-reembed-072x00vn.js → boot-reembed-az5rassp.js} +7 -6
  7. package/dist/{boot-reembed-65zz2smt.js → boot-reembed-j3mm3rfz.js} +8 -7
  8. package/dist/{boot-scrub-logs-yz0ams04.js → boot-scrub-logs-b6h54817.js} +4 -4
  9. package/dist/{claude-adapter-paj270y2.js → claude-adapter-w1z04ann.js} +8 -6
  10. package/dist/{claude-managed-setup-w619jncn.js → claude-managed-setup-f5pmsyrp.js} +24 -3
  11. package/dist/{claude-sdk-session-swt80j9z.js → claude-sdk-session-f0cc1kec.js} +8 -6
  12. package/dist/{cli-dt553ktb.js → cli-0b6y7kxm.js} +1 -1
  13. package/dist/{cli-bxdhw615.js → cli-12fz972k.js} +4 -4
  14. package/dist/{cli-s2f20r5w.js → cli-1th1728g.js} +2 -2
  15. package/dist/{cli-rs6ec3b9.js → cli-3tgwnf43.js} +3 -3
  16. package/dist/{cli-gxnzc6wh.js → cli-4nr988h6.js} +1 -1
  17. package/dist/{cli-axs8e6d4.js → cli-53s590z8.js} +1 -1
  18. package/dist/{cli-ygbjp3z8.js → cli-54wwve05.js} +5 -4
  19. package/dist/{cli-v0asr5yv.js → cli-719k9j2c.js} +2 -2
  20. package/dist/{cli-anktweqr.js → cli-89xfd7f9.js} +21 -47
  21. package/dist/{cli-q34a0e10.js → cli-8v0g1yc8.js} +1 -1
  22. package/dist/cli-9d8ntetj.js +42 -0
  23. package/dist/{cli-z9fprcrf.js → cli-atcve9y8.js} +881 -738
  24. package/dist/{cli-jmb7g47h.js → cli-b5bq9crk.js} +14 -31
  25. package/dist/cli-d8brzxjb.js +190 -0
  26. package/dist/{cli-gzky0gkr.js → cli-de14znh8.js} +2 -2
  27. package/dist/{cli-xytwff7f.js → cli-dttph1bw.js} +2 -2
  28. package/dist/cli-ep19k3mb.js +19 -0
  29. package/dist/{cli-m1xa8be7.js → cli-f146mn5s.js} +7 -50
  30. package/dist/{cli-1fj0chdk.js → cli-f7dsy61y.js} +5 -3
  31. package/dist/{cli-f567d5c1.js → cli-ftht3zzj.js} +2782 -2589
  32. package/dist/{cli-a0g1gbt5.js → cli-hbv0kq6w.js} +2 -2
  33. package/dist/{cli-fhsz3twr.js → cli-jsp14b6c.js} +171 -1
  34. package/dist/{cli-7fnkj1hj.js → cli-kpv03zhj.js} +21 -17
  35. package/dist/{cli-m4j6trn4.js → cli-m8yrsg97.js} +2 -2
  36. package/dist/cli-m9sxbkhm.js +779 -0
  37. package/dist/cli-mmemxxdc.js +823 -0
  38. package/dist/{cli-tqtvj5pt.js → cli-n8508nre.js} +1 -1
  39. package/dist/{cli-z96t0thj.js → cli-p1d5b073.js} +37 -2
  40. package/dist/{cli-gh8y8f36.js → cli-rw51bq3j.js} +13 -10
  41. package/dist/{cli-59ej1c07.js → cli-smby5m17.js} +7 -6
  42. package/dist/{cli-65vqzz3v.js → cli-tzvk9haz.js} +94 -372
  43. package/dist/{cli-dc9k2ba9.js → cli-w9kb3bk1.js} +1 -1
  44. package/dist/{cli-kpev657q.js → cli-y6b6fb76.js} +25 -3
  45. package/dist/{cli-123q2get.js → cli-yz3djrm0.js} +9 -9
  46. package/dist/{cli-wvp6bvy4.js → cli-zhjmqxzh.js} +1 -1
  47. package/dist/cli.js +13 -13
  48. package/dist/{codex-adapter-k98vaw2r.js → codex-adapter-zw4dwsx7.js} +2 -2
  49. package/dist/{codex-hook-9pf19cks.js → codex-hook-bw8p5nh4.js} +2 -2
  50. package/dist/{codex-session-runner-tngtphx7.js → codex-session-runner-k981s65v.js} +2 -2
  51. package/dist/{commands-y8nbr3fv.js → commands-sykrxx7e.js} +4 -4
  52. package/dist/{db-30p4h2t1.js → db-sfbsc8wt.js} +6 -4
  53. package/dist/handlers-0aspcejm.js +603 -0
  54. package/dist/{hook-dcw4jfbj.js → hook-4nvqj2sb.js} +7 -5
  55. package/dist/{hook-21jexvfx.js → hook-hxccs7m5.js} +24 -49
  56. package/dist/{http-r4kq5g8d.js → http-y06z517d.js} +230 -94
  57. package/dist/{index-q3qja6ta.js → index-hssww7kj.js} +15 -14
  58. package/dist/{index-gay66b9w.js → index-k8znfx0z.js} +13 -12
  59. package/dist/{index-53zyht4z.js → index-tce1rss4.js} +371 -110
  60. package/dist/{index-nqvf26za.js → index-x5j894zf.js} +14 -13
  61. package/dist/{keepalive-xs8fa55y.js → keepalive-w733ax66.js} +6 -6
  62. package/dist/{lead-d9m2t4s0.js → lead-75bjqa9d.js} +29 -27
  63. package/dist/{maintenance-3zh82g6z.js → maintenance-w4f1zjck.js} +8 -7
  64. package/dist/{oauth-refresh-sweep-03snsye5.js → oauth-refresh-sweep-wdetky26.js} +5 -5
  65. package/dist/{onboard-0cg1925x.js → onboard-bb5net8s.js} +2 -2
  66. package/dist/{otel-impl-0ab95z27.js → otel-impl-0w7c14ap.js} +1 -1
  67. package/dist/{pi-mono-adapter-dtkz4e7j.js → pi-mono-adapter-2has4gnp.js} +12 -2
  68. package/dist/{pricing-refresh-e5jhzz4j.js → pricing-refresh-9nzsrgcq.js} +6 -6
  69. package/dist/{rbac-roles-p1mf40z3.js → rbac-roles-r3hqjp09.js} +5 -5
  70. package/dist/{rbac-roles-2w3cmrkc.js → rbac-roles-z1jrffbt.js} +4 -4
  71. package/dist/{registry-9kdck5ye.js → registry-v5wytg0s.js} +1 -2
  72. package/dist/{render-v2-xhawry3k.js → render-v2-vbr89fnb.js} +6 -5
  73. package/dist/{seed-pricing-t223sw91.js → seed-pricing-g62hy1pk.js} +7 -5
  74. package/dist/{setup-zcs0rce7.js → setup-gnfqnptp.js} +2 -2
  75. package/dist/{worker-qnp2ka62.js → worker-kznxp4qm.js} +29 -27
  76. package/openapi.json +263 -12
  77. package/package.json +1 -1
  78. package/plugin/commands/start-worker.md +4 -9
  79. package/plugin/pi-skills/start-worker/SKILL.md +4 -9
  80. package/src/be/db/agents.ts +556 -0
  81. package/src/be/db/context-versions.ts +117 -0
  82. package/src/be/db/runtime.ts +406 -0
  83. package/src/be/db/tasks/read.ts +1167 -0
  84. package/src/be/db/tasks/write.ts +953 -0
  85. package/src/be/db.ts +584 -3429
  86. package/src/be/migrations/147_scheduled_tasks_parent_task.sql +7 -0
  87. package/src/be/migrations/148_pages_svg.sql +123 -0
  88. package/src/be/migrations/149_session_tokens.sql +24 -0
  89. package/src/be/scripts/typecheck.ts +1 -0
  90. package/src/be/seed-pricing.ts +13 -8
  91. package/src/be/seed-scripts/catalog/ops-catalog-audit.inline.ts +1 -1
  92. package/src/be/seed-scripts/catalog/ops-catalog-audit.ts +1 -1
  93. package/src/be/seed-scripts/catalog/star-history-refresh.ts +101 -0
  94. package/src/be/seed-scripts/catalog/star-history-renderer.ts +119 -0
  95. package/src/be/seed-scripts/index.ts +17 -0
  96. package/src/be/seed-skills/index.ts +14 -0
  97. package/src/be/swarm-config-guard.ts +5 -2
  98. package/src/be/task-attachment-store.ts +70 -0
  99. package/src/be/users.ts +77 -0
  100. package/src/commands/attachments-section.ts +37 -0
  101. package/src/commands/claude-managed-setup.ts +45 -3
  102. package/src/commands/claude-md-session.ts +408 -0
  103. package/src/commands/context-preamble.ts +15 -3
  104. package/src/commands/profile-sync.ts +205 -5
  105. package/src/commands/provider-credentials.ts +60 -27
  106. package/src/commands/runner.ts +63 -58
  107. package/src/commands/templates.ts +25 -2
  108. package/src/hooks/hook.ts +26 -67
  109. package/src/http/agents.ts +40 -1
  110. package/src/http/auth.ts +13 -1
  111. package/src/http/fs.ts +21 -51
  112. package/src/http/index.ts +2 -0
  113. package/src/http/mcp.ts +32 -0
  114. package/src/http/pages-public.ts +20 -4
  115. package/src/http/pages.ts +11 -3
  116. package/src/http/poll.ts +4 -0
  117. package/src/http/repos.ts +3 -3
  118. package/src/http/sessions.ts +82 -0
  119. package/src/http/tasks.ts +10 -2
  120. package/src/http/utils.ts +79 -1
  121. package/src/prompts/session-templates.ts +12 -8
  122. package/src/providers/acp-adapter.ts +62 -8
  123. package/src/providers/pi-mono-adapter.ts +23 -6
  124. package/src/providers/pricing-sources.md +11 -0
  125. package/src/scheduler/scheduler.test.ts +49 -2
  126. package/src/scheduler/scheduler.ts +4 -0
  127. package/src/scripts-runtime/sdk-allowlist.ts +1 -0
  128. package/src/scripts-runtime/types/stdlib.d.ts +8 -0
  129. package/src/scripts-runtime/types/swarm-sdk.d.ts +8 -0
  130. package/src/server.ts +2 -0
  131. package/src/slack/assistant.ts +87 -46
  132. package/src/slack/handlers.test.ts +1 -3
  133. package/src/slack/handlers.ts +102 -87
  134. package/src/slack/inbound-files.ts +466 -0
  135. package/src/slack/templates.ts +14 -0
  136. package/src/tasks/task-terminal-effects.ts +158 -0
  137. package/src/tests/acp-adapter.test.ts +369 -117
  138. package/src/tests/budget-claim-gate.test.ts +1 -0
  139. package/src/tests/build-pi-skills.test.ts +5 -7
  140. package/src/tests/claude-managed-setup.test.ts +52 -4
  141. package/src/tests/claude-md-session.test.ts +603 -0
  142. package/src/tests/codex-skill-resolver.test.ts +19 -0
  143. package/src/tests/context-versioning.test.ts +107 -0
  144. package/src/tests/cors-allowlist.test.ts +309 -0
  145. package/src/tests/credential-check.test.ts +157 -14
  146. package/src/tests/db-agents.test.ts +91 -0
  147. package/src/tests/db-context-versions.test.ts +37 -0
  148. package/src/tests/db-extraction-contract.test.ts +45 -0
  149. package/src/tests/db-runtime.test.ts +70 -0
  150. package/src/tests/db-task-read-contract.test.ts +624 -0
  151. package/src/tests/db-task-write-contract.test.ts +653 -0
  152. package/src/tests/db-tasks-read.test.ts +183 -0
  153. package/src/tests/db-tasks-write.test.ts +230 -0
  154. package/src/tests/defer-task.test.ts +389 -0
  155. package/src/tests/docs-index-footprint.test.js +2 -2
  156. package/src/tests/e2e-visuals-stage.test.ts +151 -0
  157. package/src/tests/file-lock.test.ts +144 -0
  158. package/src/tests/fixtures/file-lock-holder.ts +13 -0
  159. package/src/tests/fixtures/hold-file-lock.ts +25 -0
  160. package/src/tests/heartbeat-expiry.test.ts +188 -0
  161. package/src/tests/http-api-integration.test.ts +78 -1
  162. package/src/tests/pages-public-html.test.ts +43 -0
  163. package/src/tests/pages-svg-migration.test.ts +42 -0
  164. package/src/tests/preload.ts +4 -1
  165. package/src/tests/profile-sync-warning.test.ts +16 -0
  166. package/src/tests/profile-sync.test.ts +126 -0
  167. package/src/tests/prompt-template-session.test.ts +20 -4
  168. package/src/tests/provider-registration.test.ts +93 -0
  169. package/src/tests/rbac-e2e-helpers.test.ts +53 -0
  170. package/src/tests/rbac-e2e-helpers.ts +19 -2
  171. package/src/tests/reload-config.test.ts +26 -0
  172. package/src/tests/routing-affinity-inventory.test.ts +1 -1
  173. package/src/tests/runner-attachments-section.test.ts +22 -2
  174. package/src/tests/runner-context-preamble.test.ts +30 -0
  175. package/src/tests/runner-deployment-resume.test.ts +107 -0
  176. package/src/tests/runner-repo-autostash.test.ts +12 -0
  177. package/src/tests/script-connections.test.ts +4 -0
  178. package/src/tests/seed-scripts.test.ts +2 -2
  179. package/src/tests/session-tokens.test.ts +279 -0
  180. package/src/tests/slack-file-tools-attachments.test.ts +408 -0
  181. package/src/tests/slack-inbound-files.test.ts +847 -0
  182. package/src/tests/star-history-refresh.test.ts +102 -0
  183. package/src/tests/swarm-config-reserved-keys.test.ts +21 -0
  184. package/src/tests/swarm-repos.test.ts +20 -0
  185. package/src/tests/tool-annotations.test.ts +4 -3
  186. package/src/tests/ui-logs-parser.test.ts +176 -0
  187. package/src/tools/create-page.ts +4 -2
  188. package/src/tools/defer-task.ts +239 -0
  189. package/src/tools/repos/update-repo.ts +2 -2
  190. package/src/tools/schedules/create-schedule.ts +1 -0
  191. package/src/tools/schedules/list-schedules.ts +1 -0
  192. package/src/tools/schedules/patch-schedule.ts +1 -0
  193. package/src/tools/schedules/update-schedule.ts +1 -0
  194. package/src/tools/slack-download-file.ts +60 -29
  195. package/src/tools/slack-read.ts +73 -32
  196. package/src/tools/store-progress.ts +16 -146
  197. package/src/tools/tool-config.ts +1 -0
  198. package/src/tools/utils.ts +21 -0
  199. package/src/types.ts +37 -4
  200. package/src/utils/acp-session-token.ts +56 -0
  201. package/src/utils/file-lock.ts +111 -0
  202. package/src/utils/heartbeat-expiry.ts +78 -0
  203. package/src/utils/request-auth-context.ts +2 -0
  204. package/src/utils/task-attachment-links.ts +22 -0
  205. package/templates/skills/code-quality/content.md +8 -5
  206. package/templates/skills/download-task-attachment/content.md +2 -1
  207. package/{plugin/pi-skills → templates/skills}/review-offered-task/SKILL.md +5 -5
  208. package/templates/skills/review-offered-task/config.json +14 -0
  209. package/{plugin/commands/review-offered-task.md → templates/skills/review-offered-task/content.md} +3 -8
  210. package/templates/skills/scheduling/content.md +4 -0
  211. package/templates/skills/slack-interaction/content.md +2 -2
  212. package/templates/skills/work-on-task/SKILL.md +16 -0
  213. package/templates/skills/work-on-task/config.json +14 -0
  214. package/templates/skills/work-on-task/content.md +11 -0
  215. package/dist/assistant-0qt42nzg.js +0 -212
  216. package/dist/cli-fs1kkqgr.js +0 -317
  217. package/dist/cli-jnf5zghe.js +0 -269
  218. package/dist/cli-n1q8q14d.js +0 -177
  219. package/dist/handlers-yfp1qn81.js +0 -592
  220. package/plugin/commands/work-on-task.md +0 -39
  221. package/plugin/pi-skills/work-on-task/SKILL.md +0 -39
@@ -0,0 +1,603 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { chmod, lstat, mkdir, mkdtemp, readdir, rm, symlink } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import {
6
+ type ClaudeMdSessionPaths,
7
+ DEFAULT_CLAUDE_MD_SESSION_PATHS,
8
+ materializeClaudeMd,
9
+ planClaudeMdSync,
10
+ stopClaudeMd,
11
+ unsyncedCopyPath,
12
+ } from "../commands/claude-md-session";
13
+ import {
14
+ claudeMdPendingRecord,
15
+ contentSha256,
16
+ type ProfilePayload,
17
+ } from "../commands/profile-sync";
18
+ import { setFlockForTests } from "../utils/file-lock";
19
+ import { holdFileLock } from "./fixtures/hold-file-lock";
20
+
21
+ const V1 = "# CLAUDE.md\n\nversion one";
22
+ const V2 = "# CLAUDE.md\n\nversion two — the Lead added a rule";
23
+ const h = contentSha256;
24
+
25
+ describe("planClaudeMdSync", () => {
26
+ test("skips content a hook wrote: it is not an edit", () => {
27
+ expect(planClaudeMdSync({ content: V1, record: { written: h(V1), base: h(V1) } })).toBeNull();
28
+ });
29
+
30
+ test("sends an edit with the base of the content it was made on", () => {
31
+ expect(
32
+ planClaudeMdSync({ content: "edited", record: { written: h(V2), base: h(V2) } }),
33
+ ).toEqual({
34
+ claudeMd: "edited",
35
+ changeSource: "session_sync",
36
+ expectedHashes: { claudeMd: h(V2) },
37
+ });
38
+ });
39
+
40
+ test("an edit made on a restored stale copy carries the stale base", () => {
41
+ // The file was a restored v1 (written by a hook, based on v1): an edit on
42
+ // it must not be applied over a DB at v2 — the token says v1.
43
+ expect(
44
+ planClaudeMdSync({ content: "edited", record: { written: h(V1), base: h(V1) } }),
45
+ ).toEqual({
46
+ claudeMd: "edited",
47
+ changeSource: "session_sync",
48
+ expectedHashes: { claudeMd: h(V1) },
49
+ });
50
+ });
51
+
52
+ test("without any record keeps the previous unconditional sync", () => {
53
+ expect(planClaudeMdSync({ content: "edited", record: null })).toEqual({
54
+ claudeMd: "edited",
55
+ changeSource: "session_sync",
56
+ });
57
+ expect(planClaudeMdSync({ content: " ", record: null })).toBeNull();
58
+ });
59
+ });
60
+
61
+ describe("concurrent sessions sharing ~/.claude/CLAUDE.md", () => {
62
+ let root: string;
63
+ let paths: ClaudeMdSessionPaths;
64
+
65
+ beforeEach(async () => {
66
+ root = await mkdtemp(join(tmpdir(), "claude-md-session-"));
67
+ paths = {
68
+ file: join(root, "home/.claude/CLAUDE.md"),
69
+ backup: join(root, "home/.claude/CLAUDE.md.bak"),
70
+ record: join(root, "lineage.json"),
71
+ lock: join(root, "home/.claude/CLAUDE.md.lock"),
72
+ };
73
+ });
74
+
75
+ afterEach(async () => {
76
+ setFlockForTests(undefined);
77
+ await rm(root, { recursive: true, force: true });
78
+ });
79
+
80
+ /** A Stop: the payload it would sync (null if none); restores the `.bak`. */
81
+ const stop = async () => {
82
+ let sent: ProfilePayload["body"] | null = null;
83
+ await stopClaudeMd(async (body) => {
84
+ sent = body;
85
+ }, paths);
86
+ return sent;
87
+ };
88
+ const edit = (content: string) => Bun.write(paths.file, content);
89
+
90
+ const holdLock = () => holdFileLock(paths.lock);
91
+
92
+ test("a sibling's .bak restore is not pushed as an edit", async () => {
93
+ await materializeClaudeMd(V1, paths); // A starts on v1
94
+ await materializeClaudeMd(V2, paths); // the DB moved to v2; B starts, .bak = v1
95
+
96
+ expect(await stop()).toBeNull(); // A: disk v2 is B's materialization
97
+ expect(await Bun.file(paths.file).text()).toBe(V1); // A restored the .bak
98
+ expect(await stop()).toBeNull(); // B: disk v1 was written by a hook
99
+ });
100
+
101
+ test("a second Stop in the same session does not push the restored .bak", async () => {
102
+ await edit(V1); // what was on disk before the session (no record)
103
+ await materializeClaudeMd(V2, paths);
104
+ await edit("edited in session");
105
+
106
+ expect(await stop()).toEqual({
107
+ claudeMd: "edited in session",
108
+ changeSource: "session_sync",
109
+ expectedHashes: { claudeMd: h(V2) },
110
+ });
111
+ expect(await stop()).toBeNull(); // disk is the restored pre-session copy
112
+ });
113
+
114
+ test("an unsynced edit backed up by a sibling's SessionStart still reaches the DB", async () => {
115
+ await materializeClaudeMd(V2, paths); // A starts on v2
116
+ await edit("A's edit"); // A's agent edits, not synced yet
117
+ await materializeClaudeMd(V2, paths); // B starts: .bak = A's edit, disk = v2
118
+
119
+ expect(await stop()).toBeNull(); // A: disk v2 is B's materialization
120
+ expect(await Bun.file(paths.file).text()).toBe("A's edit"); // A restored its own edit
121
+ // B finds A's edit on disk: an edit, sent against the v2 it was made on.
122
+ expect(await stop()).toEqual({
123
+ claudeMd: "A's edit",
124
+ changeSource: "session_sync",
125
+ expectedHashes: { claudeMd: h(V2) },
126
+ });
127
+ });
128
+
129
+ test("a deliberate revert to an earlier version is sent, based on the current value", async () => {
130
+ await materializeClaudeMd(V2, paths);
131
+ await edit(V1); // the agent restores v1 on purpose
132
+
133
+ expect(await stop()).toEqual({
134
+ claudeMd: V1,
135
+ changeSource: "session_sync",
136
+ expectedHashes: { claudeMd: h(V2) },
137
+ });
138
+ });
139
+
140
+ test("restoring without a .bak removes the file and the record", async () => {
141
+ await materializeClaudeMd(V1, paths); // nothing on disk before: no .bak
142
+ expect(await stop()).toBeNull();
143
+
144
+ expect(await Bun.file(paths.file).exists()).toBe(false);
145
+ expect(await Bun.file(paths.record).exists()).toBe(false);
146
+ });
147
+
148
+ test("a stale sidecar describing another backup is ignored", async () => {
149
+ await materializeClaudeMd(V2, paths);
150
+ await edit("A's edit");
151
+ await materializeClaudeMd(V2, paths); // .bak = A's edit, sidecar says "edit on v2"
152
+ await Bun.write(paths.backup, V1); // the .bak changed under a sidecar that no longer matches
153
+
154
+ expect(await stop()).toBeNull(); // disk v2 is the second materialization
155
+ expect(await stop()).toBeNull(); // restored v1: unknown lineage → hook-written
156
+ });
157
+
158
+ test("an unreadable or malformed record never turns into a push", async () => {
159
+ const upper = h(V1).toUpperCase();
160
+ for (const bad of [
161
+ '{"written": "abc',
162
+ "{}",
163
+ "[]",
164
+ "42",
165
+ '{"written": 5, "base": null}',
166
+ '{"written": "not-a-sha256", "base": null}',
167
+ `{"written": null, "base": "not-a-sha256"}`,
168
+ `{"written": "${upper}", "base": null}`,
169
+ ]) {
170
+ await materializeClaudeMd(V2, paths);
171
+ await edit("edited");
172
+ await Bun.write(paths.record, bad);
173
+
174
+ expect(await stop()).toBeNull();
175
+ }
176
+ });
177
+
178
+ test("record and sidecar writes are atomic: no temp files are left behind", async () => {
179
+ await edit(V1);
180
+ await materializeClaudeMd(V2, paths); // writes .bak, sidecar and record
181
+ await stop(); // restores them
182
+
183
+ const leftovers = [...(await readdir(root)), ...(await readdir(join(root, "home/.claude")))];
184
+ expect(leftovers.filter((name) => name.endsWith(".tmp"))).toEqual([]);
185
+ });
186
+
187
+ test("a symlink planted at the record path is replaced, not written through", async () => {
188
+ const victim = join(root, "victim.txt");
189
+ await Bun.write(victim, "do not touch");
190
+ await symlink(victim, paths.record);
191
+
192
+ await materializeClaudeMd(V1, paths);
193
+
194
+ expect(await Bun.file(victim).text()).toBe("do not touch");
195
+ expect((await lstat(paths.record)).isSymbolicLink()).toBe(false);
196
+ });
197
+
198
+ test("a sidecar with a non-sha256 value is treated as unknown lineage", async () => {
199
+ await materializeClaudeMd(V2, paths);
200
+ await edit("A's edit");
201
+ await materializeClaudeMd(V2, paths); // .bak = A's edit
202
+ await Bun.write(
203
+ `${paths.backup}.lineage`,
204
+ JSON.stringify({ written: null, base: "not-a-sha256", of: h("A's edit") }),
205
+ );
206
+
207
+ expect(await stop()).toBeNull(); // disk v2 is the second materialization
208
+ expect(await stop()).toBeNull(); // restored edit with an invalid base: never pushed
209
+ });
210
+
211
+ test("a .bak without lineage (from before this module) is treated as hook-written", async () => {
212
+ await Bun.write(paths.backup, V1); // legacy backup, no sidecar
213
+ await Bun.write(paths.file, V2);
214
+ await Bun.write(paths.record, JSON.stringify({ written: h(V2), base: h(V2) }));
215
+
216
+ expect(await stop()).toBeNull(); // disk v2 was written by a hook
217
+ expect(await stop()).toBeNull(); // restored legacy v1 is never pushed
218
+ });
219
+
220
+ test("a Stop cannot land between SessionStart's file and record writes", async () => {
221
+ // The reviewed race: B writes file v2 while its record still says v1; a Stop
222
+ // in that window restores the old .bak, B then commits record v2, and the
223
+ // next Stop sends v1 against v2 — a revert the compare-and-set accepts.
224
+ await materializeClaudeMd(V1, paths); // A starts on v1
225
+ let reachedPause!: () => void;
226
+ const paused = new Promise<void>((resolve) => {
227
+ reachedPause = resolve;
228
+ });
229
+ let release!: () => void;
230
+ const gate = new Promise<void>((resolve) => {
231
+ release = resolve;
232
+ });
233
+ const sessionStartB = materializeClaudeMd(V2, paths, {
234
+ testPauses: {
235
+ afterFileWrite: async () => {
236
+ reachedPause();
237
+ await gate;
238
+ },
239
+ },
240
+ });
241
+ await paused; // B wrote v2, record still v1
242
+
243
+ let stopADone = false;
244
+ const stopA = stop().then((body) => {
245
+ stopADone = true;
246
+ return body;
247
+ });
248
+ await Bun.sleep(100);
249
+ expect(stopADone).toBe(false); // A waits for B's transition to finish
250
+
251
+ release();
252
+ await sessionStartB;
253
+ expect(await stopA).toBeNull(); // A sees B's consistent write, restores v1
254
+ expect(await stop()).toBeNull(); // B: v1 was restored by a hook — no revert
255
+ });
256
+
257
+ test("a Stop that cannot get the lock pushes and restores nothing", async () => {
258
+ await materializeClaudeMd(V2, paths);
259
+ await edit("edited");
260
+ const release = await holdLock();
261
+ let synced = false;
262
+
263
+ const outcome = await stopClaudeMd(
264
+ async () => {
265
+ synced = true;
266
+ },
267
+ paths,
268
+ { lockWaitMs: 100 },
269
+ );
270
+ await release();
271
+
272
+ expect(outcome).toBe("busy");
273
+ expect(synced).toBe(false);
274
+ expect(await Bun.file(paths.file).text()).toBe("edited");
275
+ });
276
+
277
+ test("a SessionStart that cannot get the lock leaves the file as it is", async () => {
278
+ await materializeClaudeMd(V1, paths);
279
+ const release = await holdLock();
280
+
281
+ const outcome = await materializeClaudeMd(V2, paths, { lockWaitMs: 100 });
282
+ await release();
283
+
284
+ expect(outcome).toBe("busy"); // never falls back to an unlocked write
285
+ expect(await Bun.file(paths.file).text()).toBe(V1);
286
+ expect(await stop()).toBeNull(); // and nothing it did not write gets pushed
287
+ });
288
+
289
+ test("without flock: SessionStart still materializes, no Stop ever pushes, an edit is saved", async () => {
290
+ setFlockForTests(null);
291
+ expect(await materializeClaudeMd(V2, paths)).toBe("unsupported");
292
+ expect(await Bun.file(paths.file).text()).toBe(V2);
293
+
294
+ await edit("edited");
295
+ let synced = false;
296
+ const outcome = await stopClaudeMd(async () => {
297
+ synced = true;
298
+ }, paths);
299
+
300
+ expect(outcome).toBe("unsupported");
301
+ expect(synced).toBe(false); // the DB is never written without the lock
302
+ expect(await Bun.file(paths.file).exists()).toBe(false); // restored as before (no .bak)
303
+ // …but the edit it could not push is saved, not discarded with the file.
304
+ expect(await Bun.file(unsyncedCopyPath(paths, "edited")).text()).toBe("edited");
305
+ });
306
+
307
+ test("a live SessionStart paused mid-transition is never robbed by a Stop", async () => {
308
+ // The stale-lock variant of the race: however long the holder pauses between
309
+ // its file and record writes, a Stop cannot take the lock from it.
310
+ await materializeClaudeMd(V1, paths);
311
+ let reachedPause!: () => void;
312
+ const paused = new Promise<void>((resolve) => {
313
+ reachedPause = resolve;
314
+ });
315
+ let release!: () => void;
316
+ const gate = new Promise<void>((resolve) => {
317
+ release = resolve;
318
+ });
319
+ const sessionStartB = materializeClaudeMd(V2, paths, {
320
+ testPauses: {
321
+ afterFileWrite: async () => {
322
+ reachedPause();
323
+ await gate;
324
+ },
325
+ },
326
+ });
327
+ await paused;
328
+
329
+ const outcome = await stopClaudeMd(async () => {}, paths, { lockWaitMs: 300 });
330
+ expect(outcome).toBe("busy");
331
+ expect(await Bun.file(paths.file).text()).toBe(V2); // untouched mid-transition
332
+
333
+ release();
334
+ expect(await sessionStartB).toBe("materialized");
335
+ expect(await stop()).toBeNull(); // v2 with its own record: hook-written
336
+ });
337
+ });
338
+
339
+ describe("a lineage write that fails leaves nothing pushable", () => {
340
+ let root: string;
341
+ let paths: ClaudeMdSessionPaths;
342
+
343
+ beforeEach(async () => {
344
+ root = await mkdtemp(join(tmpdir(), "claude-md-write-failure-"));
345
+ paths = {
346
+ file: join(root, "home/.claude/CLAUDE.md"),
347
+ backup: join(root, "home/.claude/CLAUDE.md.bak"),
348
+ record: join(root, "lineage.json"),
349
+ lock: join(root, "home/.claude/CLAUDE.md.lock"),
350
+ };
351
+ });
352
+
353
+ afterEach(async () => {
354
+ setFlockForTests(undefined);
355
+ await rm(root, { recursive: true, force: true });
356
+ });
357
+
358
+ const stop = async (options: Parameters<typeof stopClaudeMd>[2] = {}) => {
359
+ let sent: ProfilePayload["body"] | null = null;
360
+ await stopClaudeMd(
361
+ async (body) => {
362
+ sent = body;
363
+ },
364
+ paths,
365
+ options,
366
+ );
367
+ return sent;
368
+ };
369
+ const throwInjectedFailure = async () => {
370
+ throw new Error("injected write failure");
371
+ };
372
+ const failLineageCommit = { testPauses: { beforeRecordCommit: throwInjectedFailure } };
373
+ /** The copy a hook saves of content it can neither push nor keep. */
374
+ const unsynced = (content: string) => Bun.file(unsyncedCopyPath(paths, content));
375
+ /** A non-empty directory where the copy goes: saving it fails, for root too. */
376
+ const occupy = async (path: string) => {
377
+ await mkdir(path);
378
+ await Bun.write(join(path, "occupied"), "");
379
+ };
380
+ /**
381
+ * A record whose temp file name exceeds NAME_MAX: every atomic replacement of
382
+ * it — the only way the hook writes it — fails for real (ENAMETOOLONG, for
383
+ * root too), while a test can still write it directly and the hook read it.
384
+ */
385
+ const useUnreplaceableRecord = () => {
386
+ paths = { ...paths, record: join(root, "r".repeat(230)) };
387
+ };
388
+
389
+ test("a SessionStart that cannot mark the record writes nothing", async () => {
390
+ // The reviewed case: the record write failed silently, the Stop found no
391
+ // record and sent the materialization unconditionally over a newer DB value.
392
+ useUnreplaceableRecord();
393
+
394
+ await expect(materializeClaudeMd(V2, paths)).rejects.toThrow();
395
+ expect(await Bun.file(paths.file).exists()).toBe(false);
396
+ expect(await stop()).toBeNull();
397
+ });
398
+
399
+ test("a SessionStart whose final record write fails leaves the file unpushable", async () => {
400
+ const outcome = await materializeClaudeMd(V2, paths, failLineageCommit);
401
+
402
+ expect(outcome).toBe("materialized"); // the session still gets the DB value
403
+ expect(await Bun.file(paths.file).text()).toBe(V2);
404
+ expect(await Bun.file(paths.record).text()).toBe(claudeMdPendingRecord(h(V2)));
405
+ await Bun.write(paths.file, "edited"); // an edit on top: its base is unknown
406
+ expect(await stop()).toBeNull(); // so it is not pushed…
407
+ expect(await Bun.file(paths.file).exists()).toBe(false); // …the Stop removes the file…
408
+ expect(await unsynced("edited").text()).toBe("edited"); // …but saves the edit first
409
+ });
410
+
411
+ test("a Stop that cannot mark the record restores nothing", async () => {
412
+ // The reviewed case: the restore's record write failed silently, the record
413
+ // kept describing v2, and a second Stop sent the restored v1 against v2 — a
414
+ // compare-and-set the DB, still at v2, accepts.
415
+ useUnreplaceableRecord();
416
+ await Bun.write(paths.backup, V1);
417
+ await Bun.write(paths.file, V2);
418
+ await Bun.write(paths.record, JSON.stringify({ written: h(V2), base: h(V2) }));
419
+
420
+ expect(await stop()).toBeNull(); // v2 is the hook's own write
421
+ expect(await Bun.file(paths.file).text()).toBe(V2); // not restored: file and record still agree
422
+ expect(await Bun.file(paths.backup).text()).toBe(V1); // the .bak waits for a later Stop
423
+ expect(await stop()).toBeNull();
424
+ });
425
+
426
+ test("a Stop whose final record write fails leaves the restored copy unpushable", async () => {
427
+ await Bun.write(paths.file, V1); // before the session
428
+ await materializeClaudeMd(V2, paths);
429
+
430
+ expect(await stop(failLineageCommit)).toBeNull();
431
+ expect(await Bun.file(paths.file).text()).toBe(V1); // restored
432
+ expect(await Bun.file(paths.record).text()).toBe(claudeMdPendingRecord(h(V1)));
433
+ expect(await stop()).toBeNull(); // the double Stop does not send v1 against v2
434
+ });
435
+
436
+ test("an edit the restore would overwrite is saved first", async () => {
437
+ // The reviewed case: under a pending record an edit cannot be pushed, and
438
+ // the Stop's restore would overwrite it with the `.bak`, silently.
439
+ await Bun.write(paths.file, V1); // before the session
440
+ await materializeClaudeMd(V2, paths, failLineageCommit);
441
+ await Bun.write(paths.file, "real edit");
442
+
443
+ expect(await stop()).toBeNull();
444
+ expect(await Bun.file(paths.file).text()).toBe(V1); // restored
445
+ expect(await unsynced("real edit").text()).toBe("real edit");
446
+ });
447
+
448
+ test("a SessionStart over an unsynced edit saves it first", async () => {
449
+ await materializeClaudeMd(V2, paths, failLineageCommit);
450
+ await Bun.write(paths.file, "real edit");
451
+
452
+ expect(await materializeClaudeMd(V2, paths)).toBe("materialized");
453
+ expect(await unsynced("real edit").text()).toBe("real edit");
454
+ });
455
+
456
+ test("the hook's own write under a pending record is not saved, and the record heals", async () => {
457
+ await materializeClaudeMd(V2, paths, failLineageCommit);
458
+ expect(await stop()).toBeNull(); // v2 is what the hook was writing: nothing to save
459
+ const leftovers = await readdir(dirname(paths.file));
460
+ expect(leftovers.filter((name) => name.includes(".unsynced-"))).toEqual([]);
461
+
462
+ await materializeClaudeMd(V2, paths); // storage is back: a real lineage again
463
+ await Bun.write(paths.file, "edited");
464
+ expect(await stop()).toEqual({
465
+ claudeMd: "edited",
466
+ changeSource: "session_sync",
467
+ expectedHashes: { claudeMd: h(V2) },
468
+ });
469
+ });
470
+
471
+ test("the reviewed lifecycle: failure, edit, Stop, then a healthy session", async () => {
472
+ await materializeClaudeMd(V2, paths, failLineageCommit); // the lineage never lands
473
+ await Bun.write(paths.file, "real edit"); // made on top: its base is unknown
474
+ expect(await stop()).toBeNull(); // not pushed…
475
+ expect(await unsynced("real edit").text()).toBe("real edit"); // …but saved
476
+
477
+ await materializeClaudeMd(V2, paths); // storage is back: the record heals
478
+ expect(await Bun.file(paths.record).text()).toBe(
479
+ JSON.stringify({ written: h(V2), base: h(V2) }),
480
+ );
481
+ await Bun.write(paths.file, "next edit");
482
+ expect(await stop()).toEqual({
483
+ claudeMd: "next edit",
484
+ changeSource: "session_sync",
485
+ expectedHashes: { claudeMd: h(V2) },
486
+ });
487
+ expect(await unsynced("real edit").text()).toBe("real edit"); // the copy outlives it
488
+ });
489
+
490
+ test("a SessionStart that cannot save an unsynced edit leaves it in place", async () => {
491
+ await materializeClaudeMd(V2, paths, failLineageCommit);
492
+ await Bun.write(paths.file, "real edit");
493
+ await occupy(unsyncedCopyPath(paths, "real edit"));
494
+
495
+ await expect(materializeClaudeMd(V2, paths)).rejects.toThrow();
496
+ expect(await Bun.file(paths.file).text()).toBe("real edit");
497
+ expect(await Bun.file(paths.backup).exists()).toBe(false);
498
+ });
499
+
500
+ test("without flock, an edit under a pending record is saved too", async () => {
501
+ setFlockForTests(null);
502
+ expect(await materializeClaudeMd(V2, paths, failLineageCommit)).toBe("unsupported");
503
+ await Bun.write(paths.file, "real edit");
504
+
505
+ expect(await stop()).toBeNull();
506
+ expect(await unsynced("real edit").text()).toBe("real edit");
507
+ });
508
+
509
+ test("an emptied file under a pending record is saved before the Stop deletes it", async () => {
510
+ // Clearing CLAUDE.md is an edit too, and blank content is never pushed at
511
+ // all (`planClaudeMdSync` skips it), so the copy is the only record of it.
512
+ await materializeClaudeMd(V2, paths, failLineageCommit);
513
+ await Bun.write(paths.file, ""); // the agent cleared the file
514
+
515
+ expect(await stop()).toBeNull();
516
+ expect(await Bun.file(paths.file).exists()).toBe(false); // no `.bak`: removed
517
+ expect(await unsynced("").text()).toBe("");
518
+ });
519
+
520
+ test("a whitespace-only edit under a pending record survives the restore", async () => {
521
+ await Bun.write(paths.file, V1); // before the session
522
+ await materializeClaudeMd(V2, paths, failLineageCommit);
523
+ await Bun.write(paths.file, " \n\t\n");
524
+
525
+ expect(await stop()).toBeNull();
526
+ expect(await Bun.file(paths.file).text()).toBe(V1); // restored over the edit
527
+ expect(await unsynced(" \n\t\n").text()).toBe(" \n\t\n");
528
+ });
529
+
530
+ test("a Stop that cannot save an unsynced edit leaves it in place", async () => {
531
+ await materializeClaudeMd(V2, paths, failLineageCommit);
532
+ await Bun.write(paths.file, "real edit");
533
+ await occupy(unsyncedCopyPath(paths, "real edit"));
534
+
535
+ expect(await stop()).toBeNull();
536
+ expect(await Bun.file(paths.file).text()).toBe("real edit"); // neither deleted nor overwritten
537
+ expect(await Bun.file(paths.record).text()).toBe(claudeMdPendingRecord(h(V2)));
538
+ });
539
+
540
+ test("a SessionStart whose backup fails leaves an unsynced edit pushable", async () => {
541
+ // The marker goes on right before the file, not before the `.bak`: a backup
542
+ // that fails must not turn the edit still on disk into hook-written content.
543
+ await materializeClaudeMd(V2, paths);
544
+ await Bun.write(paths.file, "edited"); // not synced yet
545
+ await Bun.write(join(root, "blocker"), "a file where a directory should be");
546
+ paths = { ...paths, backup: join(root, "blocker/CLAUDE.md.bak") }; // ENOTDIR, for root too
547
+
548
+ await expect(materializeClaudeMd(V2, paths)).rejects.toThrow();
549
+ expect(await Bun.file(paths.file).text()).toBe("edited");
550
+ expect(await stop()).toEqual({
551
+ claudeMd: "edited",
552
+ changeSource: "session_sync",
553
+ expectedHashes: { claudeMd: h(V2) },
554
+ });
555
+ });
556
+
557
+ test.skipIf(process.getuid?.() === 0)(
558
+ "a Stop that cannot delete the .bak still records the restored edit's lineage",
559
+ async () => {
560
+ await materializeClaudeMd(V2, paths); // A starts on v2
561
+ await Bun.write(paths.file, "A's edit"); // not synced yet
562
+ await materializeClaudeMd(V2, paths); // B starts: .bak = A's edit
563
+ const dir = dirname(paths.file);
564
+ await chmod(dir, 0o555); // the file can be rewritten, the .bak not deleted
565
+ try {
566
+ expect(await stop()).toBeNull(); // A: disk v2 is B's materialization
567
+ expect(await Bun.file(paths.file).text()).toBe("A's edit"); // restored
568
+ } finally {
569
+ await chmod(dir, 0o755);
570
+ }
571
+ expect(await stop()).toEqual({
572
+ claudeMd: "A's edit",
573
+ changeSource: "session_sync",
574
+ expectedHashes: { claudeMd: h(V2) },
575
+ });
576
+ },
577
+ );
578
+
579
+ // A read-only directory is the real failure here, and it does not stop root.
580
+ test.skipIf(process.getuid?.() === 0)(
581
+ "a Stop that cannot remove the file keeps its record",
582
+ async () => {
583
+ await materializeClaudeMd(V2, paths); // nothing on disk before: no .bak
584
+ const dir = dirname(paths.file);
585
+ await chmod(dir, 0o555); // the file can no longer be removed
586
+ try {
587
+ expect(await stop()).toBeNull();
588
+ expect(await Bun.file(paths.file).text()).toBe(V2);
589
+ } finally {
590
+ await chmod(dir, 0o755);
591
+ }
592
+ expect(await stop()).toBeNull(); // v2 still has its record: hook-written
593
+ },
594
+ );
595
+ });
596
+
597
+ describe("default paths", () => {
598
+ test("the lineage record lives next to the file it describes, not in /tmp", () => {
599
+ const { file, backup, record } = DEFAULT_CLAUDE_MD_SESSION_PATHS;
600
+ expect(record).toBe(`${file}.lineage.json`);
601
+ expect(backup).toBe(`${file}.bak`);
602
+ });
603
+ });
@@ -11,6 +11,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
11
11
  import { mkdirSync, rmSync } from "node:fs";
12
12
  import { tmpdir } from "node:os";
13
13
  import { join } from "node:path";
14
+ import { prependContextPreamble } from "../commands/context-preamble";
14
15
  import { resolveCodexPrompt } from "../providers/codex-skill-resolver";
15
16
  import type { ProviderEvent } from "../providers/types";
16
17
 
@@ -41,6 +42,24 @@ async function writeSkill(name: string, content: string): Promise<void> {
41
42
  }
42
43
 
43
44
  describe("resolveCodexPrompt", () => {
45
+ test("expands a follow-up skill while preserving parent context and current task", async () => {
46
+ const skillBody = await Bun.file(
47
+ new URL("../../templates/skills/work-on-task/SKILL.md", import.meta.url),
48
+ ).text();
49
+ await writeSkill("work-on-task", skillBody);
50
+ const prompt = "/work-on-task child-id\n\nTask: continue work\n";
51
+ const preamble = "\n---\n## Prior Conversation Context\nParent: parent-id\n\n";
52
+
53
+ // The old ordering makes the resolver pass through an unexpanded slash line.
54
+ expect(await resolveCodexPrompt(preamble + prompt, skillsDir)).toBe(preamble + prompt);
55
+ const resolved = await resolveCodexPrompt(prependContextPreamble(prompt, preamble), skillsDir);
56
+ expect(resolved.startsWith(skillBody)).toBe(true);
57
+ expect(resolved).toContain("User request: child-id");
58
+ expect(resolved).toContain(preamble);
59
+ expect(resolved).toContain("Task: continue work");
60
+ expect(resolved).not.toContain("/work-on-task child-id");
61
+ });
62
+
44
63
  test("inlines known skill and preserves trailing args + body", async () => {
45
64
  const skillBody = "You are a task worker. Follow these steps carefully.";
46
65
  await writeSkill("work-on-task", skillBody);