@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
package/src/be/users.ts CHANGED
@@ -529,6 +529,83 @@ export async function resolveUserByToken(plaintext: string): Promise<User | null
529
529
  return findUserById(row.userId);
530
530
  }
531
531
 
532
+ // ---------------------------------------------------------------------------
533
+ // Session tokens (ephemeral, ACP provider)
534
+ // ---------------------------------------------------------------------------
535
+
536
+ const SESSION_TOKEN_PREFIX = "aseph_"; // agent-swarm-ephemeral
537
+
538
+ /**
539
+ * Mint a short-lived `aseph_<base62-24>` token scoped to an ACP session.
540
+ * The token is handed to the ACP target process instead of the full operator
541
+ * key; it expires at `now + ttlMs` and is actively revoked when the session
542
+ * ends.
543
+ */
544
+ export async function mintSessionToken(
545
+ agentId: string,
546
+ taskId: string,
547
+ ttlMs: number,
548
+ ): Promise<{ tokenId: string; plaintext: string }> {
549
+ const plaintext = `${SESSION_TOKEN_PREFIX}${base62(randomBytes(24))}`;
550
+ const tokenId = randomUUID().replace(/-/g, "");
551
+ const hash = sha256Hex(plaintext);
552
+ const preview = plaintext.slice(-4);
553
+ const now = new Date();
554
+ const expiresAt = new Date(now.getTime() + ttlMs).toISOString();
555
+
556
+ await getDbClient().run(
557
+ `INSERT INTO session_tokens (id, tokenHash, tokenPreview, agentId, taskId, expiresAt, createdAt)
558
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
559
+ [tokenId, hash, preview, agentId, taskId, expiresAt, now.toISOString()],
560
+ );
561
+
562
+ return { tokenId, plaintext };
563
+ }
564
+
565
+ /** Revoke a session token by id. No-op if already revoked. */
566
+ export async function revokeSessionToken(tokenId: string): Promise<void> {
567
+ await getDbClient().run(
568
+ "UPDATE session_tokens SET revokedAt = ? WHERE id = ? AND revokedAt IS NULL",
569
+ [new Date().toISOString(), tokenId],
570
+ );
571
+ }
572
+
573
+ /**
574
+ * Resolve a plaintext `aseph_` token to its principal. Returns null when the
575
+ * token is unknown, revoked, or past its expiry. On a hit, touches `lastUsedAt`
576
+ * best-effort.
577
+ */
578
+ export async function resolveBySessionToken(
579
+ plaintext: string,
580
+ ): Promise<{ agentId: string; taskId: string } | null> {
581
+ if (!plaintext.startsWith(SESSION_TOKEN_PREFIX)) return null;
582
+ const hash = sha256Hex(plaintext);
583
+ const client = getDbClient();
584
+
585
+ const row = await client.get<{
586
+ id: string;
587
+ agentId: string;
588
+ taskId: string;
589
+ expiresAt: string;
590
+ revokedAt: string | null;
591
+ }>("SELECT id, agentId, taskId, expiresAt, revokedAt FROM session_tokens WHERE tokenHash = ?", [
592
+ hash,
593
+ ]);
594
+ if (!row || row.revokedAt !== null) return null;
595
+ if (new Date(row.expiresAt) <= new Date()) return null;
596
+
597
+ try {
598
+ await client.run("UPDATE session_tokens SET lastUsedAt = ? WHERE id = ?", [
599
+ new Date().toISOString(),
600
+ row.id,
601
+ ]);
602
+ } catch {
603
+ // best-effort
604
+ }
605
+
606
+ return { agentId: row.agentId, taskId: row.taskId };
607
+ }
608
+
532
609
  // ---------------------------------------------------------------------------
533
610
  // API-key fingerprint (operator audit)
534
611
  // ---------------------------------------------------------------------------
@@ -0,0 +1,37 @@
1
+ import { taskAttachmentFetchCommand } from "../utils/task-attachment-links.ts";
2
+
3
+ /**
4
+ * Build a ready-to-run fetch recipe for each task attachment, so the agent
5
+ * can download the bytes in one call via the provider-agnostic
6
+ * `/api/fs/tasks/{taskId}/files/{attachmentId}/raw` route — instead of having
7
+ * to discover the file's storage provider/org/drive itself (e.g. guessing at
8
+ * the `agent-fs` CLI with no org context). MCP_BASE_URL/API_KEY/AGENT_ID are
9
+ * already present in every worker container's env.
10
+ */
11
+ export function buildAttachmentsSection(
12
+ taskId: string | undefined,
13
+ attachmentsRaw: unknown,
14
+ ): string {
15
+ if (!taskId || !Array.isArray(attachmentsRaw) || attachmentsRaw.length === 0) return "";
16
+
17
+ const lines = attachmentsRaw
18
+ .filter((a): a is Record<string, unknown> => !!a && typeof a === "object")
19
+ .map((a) => {
20
+ const id = typeof a.id === "string" ? a.id : undefined;
21
+ const name = typeof a.name === "string" ? a.name : id;
22
+ if (!id || !name) return null;
23
+ const details = [
24
+ typeof a.mimeType === "string" ? a.mimeType : null,
25
+ typeof a.sizeBytes === "number" ? `${a.sizeBytes} bytes` : null,
26
+ ]
27
+ .filter(Boolean)
28
+ .join(", ");
29
+ const cmd = taskAttachmentFetchCommand(taskId, id, name);
30
+ return `- ${name}${details ? ` (${details})` : ""}: \`${cmd}\``;
31
+ })
32
+ .filter((line): line is string => line !== null);
33
+
34
+ if (lines.length === 0) return "";
35
+
36
+ return `\n\n📎 Attachment(s) — fetch directly, no need to discover the storage path yourself:\n${lines.join("\n")}`;
37
+ }
@@ -33,6 +33,7 @@ import type { BetaEnvironment } from "@anthropic-ai/sdk/resources/beta/environme
33
33
  import type { SkillCreateResponse } from "@anthropic-ai/sdk/resources/beta/skills";
34
34
  import { toFile } from "@anthropic-ai/sdk/uploads";
35
35
 
36
+ import { buildSkillContent, type SkillTemplateConfig } from "../be/seed-skills/render";
36
37
  import { getApiKey } from "../utils/api-key";
37
38
  import { promptHiddenInput } from "./codex-login.js";
38
39
 
@@ -66,8 +67,20 @@ export type ClaudeManagedSetupResult = {
66
67
 
67
68
  const DEFAULT_AGENT_MODEL = "claude-sonnet-5";
68
69
  const SKILLS_DIR_RELATIVE = "plugin/commands";
70
+ const SEEDED_SKILLS_DIR_RELATIVE = "templates/skills";
69
71
  const SKILLS_BETA_HEADER = "skills-2025-10-02";
70
72
 
73
+ /**
74
+ * Seeded skills that must also ship in the managed-agent definition.
75
+ *
76
+ * The runner opens every assigned task with `/work-on-task` and every offered
77
+ * task with `/review-offered-task`. Both live in `templates/skills/` and reach
78
+ * a normal worker through the DB → filesystem sync. Managed agents run in
79
+ * Anthropic's sandbox with no entrypoint sync, so they only ever see skills
80
+ * uploaded here.
81
+ */
82
+ const SEEDED_SKILLS_TO_UPLOAD = ["work-on-task", "review-offered-task"];
83
+
71
84
  // Config keys persisted to `swarm_config`. The docker-entrypoint hydrates env
72
85
  // vars from these on worker boot.
73
86
  const CONFIG_KEY_AGENT_ID = "managed_agent_id";
@@ -116,7 +129,8 @@ Options:
116
129
 
117
130
  This command:
118
131
  1. Creates an Anthropic-side environment (cloud, unrestricted networking).
119
- 2. Uploads each plugin/commands/*.md as a managed-agents skill (skips on 409).
132
+ 2. Uploads each plugin/commands/*.md plus the turn-prompt skills under
133
+ templates/skills/ as managed-agents skills (skips on 409).
120
134
  3. Creates a managed-agents agent referencing the uploaded skills + the
121
135
  swarm MCP server (MCP_BASE_URL/mcp).
122
136
  4. Persists the resulting IDs to swarm_config (managed_agent_id,
@@ -211,6 +225,26 @@ async function loadSkillFiles(
211
225
  return out;
212
226
  }
213
227
 
228
+ /**
229
+ * Load `SEEDED_SKILLS_TO_UPLOAD` from `templates/skills/<name>/` and render
230
+ * each as a SKILL.md, using the same frontmatter renderer the seeder uses.
231
+ */
232
+ async function loadSeededSkillFiles(
233
+ templatesDir: string,
234
+ names: string[] = SEEDED_SKILLS_TO_UPLOAD,
235
+ ): Promise<Array<{ slug: string; absPath: string; content: string }>> {
236
+ const out: Array<{ slug: string; absPath: string; content: string }> = [];
237
+ for (const name of names) {
238
+ const dir = path.join(templatesDir, name);
239
+ const config = JSON.parse(
240
+ await readFile(path.join(dir, "config.json"), "utf8"),
241
+ ) as SkillTemplateConfig;
242
+ const body = await readFile(path.join(dir, "content.md"), "utf8");
243
+ out.push({ slug: name, absPath: dir, content: buildSkillContent(config, body) });
244
+ }
245
+ return out;
246
+ }
247
+
214
248
  async function uploadSkill(
215
249
  client: Anthropic,
216
250
  slug: string,
@@ -438,8 +472,10 @@ export type RunClaudeManagedSetupDeps = {
438
472
  fetchConfig?: typeof fetchConfigByKey;
439
473
  upsert?: typeof upsertConfig;
440
474
  loadSkills?: typeof loadSkillFiles;
475
+ loadSeededSkills?: typeof loadSeededSkillFiles;
441
476
  uploadOne?: typeof uploadSkill;
442
477
  skillsDir?: string;
478
+ seededSkillsDir?: string;
443
479
  log?: (msg: string) => void;
444
480
  };
445
481
 
@@ -459,8 +495,11 @@ export async function runClaudeManagedSetupFlow(
459
495
  const fetchCfg = deps.fetchConfig ?? fetchConfigByKey;
460
496
  const upsert = deps.upsert ?? upsertConfig;
461
497
  const loadSkills = deps.loadSkills ?? loadSkillFiles;
498
+ const loadSeededSkills = deps.loadSeededSkills ?? loadSeededSkillFiles;
462
499
  const uploadOne = deps.uploadOne ?? uploadSkill;
463
500
  const skillsDir = deps.skillsDir ?? path.resolve(process.cwd(), SKILLS_DIR_RELATIVE);
501
+ const seededSkillsDir =
502
+ deps.seededSkillsDir ?? path.resolve(process.cwd(), SEEDED_SKILLS_DIR_RELATIVE);
464
503
 
465
504
  // Idempotency check: if an agent ID is already persisted, short-circuit
466
505
  // unless --force was passed.
@@ -495,8 +534,11 @@ export async function runClaudeManagedSetupFlow(
495
534
  log(` + environment id=${env.id}`);
496
535
 
497
536
  // 2. Upload skills.
498
- log(`Uploading skills from ${skillsDir} ...`);
499
- const skillFiles = await loadSkills(skillsDir);
537
+ log(`Uploading skills from ${skillsDir} and ${seededSkillsDir} ...`);
538
+ const skillFiles = [
539
+ ...(await loadSkills(skillsDir)),
540
+ ...(await loadSeededSkills(seededSkillsDir)),
541
+ ];
500
542
  log(` found ${skillFiles.length} skill markdown file(s)`);
501
543
  // Pre-fetch existing custom skills so we can reuse their IDs on rerun. The
502
544
  // API returns 400 (not 409) on display_title collision and skill IDs aren't
@@ -0,0 +1,408 @@
1
+ /**
2
+ * The Claude hook's handling of the SHARED personal CLAUDE.md
3
+ * (`~/.claude/CLAUDE.md`) across concurrent sessions of one agent.
4
+ *
5
+ * Every SessionStart backs the current file up to `.bak` and materializes the
6
+ * DB value into it; every Stop syncs the file back and restores the `.bak`.
7
+ * Sessions of one agent overlap, so what a Stop finds on disk can be an agent's
8
+ * edit, or content a hook wrote — this session's or a sibling's materialization,
9
+ * or a `.bak` restore (Stop can fire more than once). Syncing hook-written
10
+ * content can revert a value the DB already moved past — measured: `claudeMd`
11
+ * edits reverted 8–27 s later, byte-for-byte to the previous version.
12
+ *
13
+ * So the hook keeps a LINEAGE record for the file, rewritten on every hook write:
14
+ * - `written`: hash of the content the hook itself put there, or null when it
15
+ * restored an agent's edit. A file that still hashes to it is not an edit
16
+ * and is never synced.
17
+ * - `base`: hash of the DB value that content derives from. An edit is sent
18
+ * with it as the compare-and-set token (`expectedHashes.claudeMd`): the
19
+ * server applies it if the DB is still at that value — including a
20
+ * deliberate revert to an earlier version — and drops it otherwise.
21
+ * The `.bak` carries the same two facts in a sidecar, so restoring an agent's
22
+ * unsynced edit that a sibling's SessionStart had backed up brings back an edit
23
+ * (still synced, against its own base), not something a hook wrote.
24
+ *
25
+ * Transitions of the pair (file + record) run under one kernel-held
26
+ * cross-process lock (`src/utils/file-lock.ts`, flock): SessionStart's
27
+ * materialization, the Stop's whole read → sync → restore protocol, and the
28
+ * runner backstop's read + post. Without it a Stop landing between the file
29
+ * write and the record write reads a mismatched pair and can re-create the
30
+ * revert this module exists to prevent. The lock can never be taken from a live
31
+ * holder, so no transition ever runs without it:
32
+ * - lock busy (or failing) → the transition is skipped, never run unlocked.
33
+ * A skipped SessionStart leaves the file as it is (whatever is there carries
34
+ * its own lineage); a skipped Stop pushes and restores nothing.
35
+ * - no flock on this platform → SessionStart still materializes (unprotected)
36
+ * but no Stop ever pushes: the DB is never at risk. An edit a Stop would
37
+ * have sent is saved instead (see below).
38
+ * The record and the sidecar are also written atomically (temp file + rename),
39
+ * and an unreadable record counts as hook-written, never as a reason to push.
40
+ * A write that fails fails closed, the same way: every hook write marks the
41
+ * record pending BEFORE touching the file, naming the content it writes, and
42
+ * the real lineage replaces the marker after. A pending record makes whatever
43
+ * is on disk hook-written, for the Stop and the runner backstop alike, so:
44
+ * - the marker cannot be written → the transition is skipped (SessionStart
45
+ * throws, a Stop restores nothing), as with a busy lock;
46
+ * - the real lineage cannot replace it → the marker stays: an edit made on
47
+ * top of that file is never pushed, since its base is unknown.
48
+ * Such an edit is never discarded silently either. Before a hook overwrites or
49
+ * deletes content it did not push and whose lineage is unknown (a pending
50
+ * marker naming other content, an unreadable record, or an edit where there is
51
+ * no flock), it saves a copy as `CLAUDE.md.unsynced-<hash>` and warns; if the
52
+ * copy fails, it leaves the file alone. Bringing the copy into the DB is a
53
+ * manual step. The next hook write whose lineage lands heals the record.
54
+ * A Stop with no `.bak` removes the record only once the file is gone.
55
+ *
56
+ * Known limit: with one `.bak` slot, a third overlapping session overwrites the
57
+ * backup of the first, and a Stop can restore a backup a sibling took (as before
58
+ * this module); both are disk-level only, since every copy carries its lineage.
59
+ */
60
+
61
+ // `rename` has no Bun equivalent; it is what makes the record writes atomic.
62
+ import { rename } from "node:fs/promises";
63
+ import { withFileLock } from "../utils/file-lock.ts";
64
+ import { scrubSecrets } from "../utils/secret-scrubber.ts";
65
+ import {
66
+ CLAUDE_MD_LINEAGE_PATH,
67
+ CLAUDE_MD_LOCK_PATH,
68
+ CLAUDE_MD_PATH,
69
+ type ClaudeMdLineage,
70
+ claudeMdLineageOf,
71
+ claudeMdPendingRecord,
72
+ contentSha256,
73
+ effectiveClaudeMdLineage,
74
+ type FileReader,
75
+ hookWrittenLineage,
76
+ type ProfilePayload,
77
+ parseClaudeMdLineage,
78
+ parseClaudeMdPendingRecord,
79
+ planClaudeMdSync,
80
+ } from "./profile-sync.ts";
81
+
82
+ export { planClaudeMdSync };
83
+
84
+ export interface ClaudeMdSessionPaths {
85
+ /** The shared personal CLAUDE.md. */
86
+ file: string;
87
+ /** Where SessionStart backs up the previous content; Stop restores it. */
88
+ backup: string;
89
+ /** Lineage record of the content the hook last wrote to `file`. */
90
+ record: string;
91
+ /** Cross-process lock for every transition of `file` + `record`. */
92
+ lock: string;
93
+ }
94
+
95
+ const readText: FileReader = async (path) => {
96
+ const file = Bun.file(path);
97
+ return (await file.exists()) ? await file.text() : undefined;
98
+ };
99
+
100
+ export const DEFAULT_CLAUDE_MD_SESSION_PATHS: ClaudeMdSessionPaths = {
101
+ file: CLAUDE_MD_PATH,
102
+ backup: `${CLAUDE_MD_PATH}.bak`,
103
+ record: CLAUDE_MD_LINEAGE_PATH,
104
+ lock: CLAUDE_MD_LOCK_PATH,
105
+ };
106
+
107
+ /**
108
+ * How long each side waits for the lock, both below the Claude Code hook timeout
109
+ * (60 s). SessionStart waits longer: giving up means the session starts with
110
+ * whatever CLAUDE.md is already on disk.
111
+ */
112
+ const SESSION_START_LOCK_WAIT_MS = 40_000;
113
+ const STOP_LOCK_WAIT_MS = 15_000;
114
+
115
+ /**
116
+ * Test seam: pause points inside a transition, to force interleavings — or, by
117
+ * throwing, a failure of the step that follows.
118
+ */
119
+ export interface ClaudeMdTestPauses {
120
+ afterFileWrite?: () => Promise<void>;
121
+ /** Right before the real lineage replaces the pending marker. */
122
+ beforeRecordCommit?: () => Promise<void>;
123
+ }
124
+
125
+ /**
126
+ * Write via a temp file in the same directory plus `rename`: readers see the old
127
+ * content or the new one, never a partial write. `rename` also replaces a
128
+ * symlink at `path` instead of writing through it.
129
+ */
130
+ async function writeAtomic(path: string, data: string): Promise<void> {
131
+ const tmp = `${path}.${process.pid}.${crypto.randomUUID()}.tmp`;
132
+ await Bun.write(tmp, data);
133
+ try {
134
+ await rename(tmp, path);
135
+ } catch (error) {
136
+ await Bun.file(tmp)
137
+ .delete()
138
+ .catch(() => {});
139
+ throw error;
140
+ }
141
+ }
142
+
143
+ /**
144
+ * The one way a hook write reaches the file: mark the record pending (naming
145
+ * the content), write the content, then replace the marker with its lineage.
146
+ * The marker failing throws before the file is touched; the lineage failing is
147
+ * logged, not thrown: the file is already in place and the marker keeps it
148
+ * unpushable.
149
+ */
150
+ async function writeUnderPendingRecord(
151
+ paths: ClaudeMdSessionPaths,
152
+ pauses: ClaudeMdTestPauses,
153
+ content: string,
154
+ lineage: ClaudeMdLineage,
155
+ ): Promise<void> {
156
+ await writeAtomic(paths.record, claudeMdPendingRecord(contentSha256(content)));
157
+ await Bun.write(paths.file, content); // creates ~/.claude if missing
158
+ await pauses.afterFileWrite?.();
159
+ try {
160
+ await pauses.beforeRecordCommit?.();
161
+ await writeAtomic(paths.record, JSON.stringify(lineage));
162
+ } catch (error) {
163
+ console.warn(
164
+ scrubSecrets(`[claude-md] lineage not recorded, the file stays unpushable: ${String(error)}`),
165
+ );
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Whether `content` may be an agent's edit whose base is unknown: the record is
171
+ * there but is no lineage — a pending marker naming other content, or an
172
+ * unreadable record. Such content is never pushed, so it must not be discarded.
173
+ * Blank content counts: clearing the file is an edit too, and one that is never
174
+ * pushed at all (`planClaudeMdSync` skips it), so the copy is the only record of
175
+ * it. No record at all is excluded: that content is pushed, not saved.
176
+ */
177
+ function hasUnknownLineage(content: string, rawRecord: string | undefined): boolean {
178
+ if (rawRecord === undefined) return false;
179
+ if (parseClaudeMdLineage(rawRecord)) return false;
180
+ return parseClaudeMdPendingRecord(rawRecord) !== contentSha256(content);
181
+ }
182
+
183
+ /** Where a hook saves content it can neither push nor keep: one file per content. */
184
+ export function unsyncedCopyPath(paths: ClaudeMdSessionPaths, content: string): string {
185
+ return `${paths.file}.unsynced-${contentSha256(content).slice(0, 12)}`;
186
+ }
187
+
188
+ /**
189
+ * Save content that cannot be pushed before a hook discards it; throws if it
190
+ * cannot. The copy may hold an agent's edit, or not (a partial write, content a
191
+ * failed write left behind): its base is unknown either way, so it is never
192
+ * something to re-apply blindly.
193
+ */
194
+ async function saveUnsynced(paths: ClaudeMdSessionPaths, content: string): Promise<void> {
195
+ const copy = unsyncedCopyPath(paths, content);
196
+ await writeAtomic(copy, content);
197
+ console.warn(
198
+ scrubSecrets(
199
+ `[claude-md] CLAUDE.md content of unknown lineage was not synced; saved to ${copy} ` +
200
+ "instead of discarding it. It may hold an agent's edit: compare it with the current " +
201
+ "value before re-applying anything.",
202
+ ),
203
+ );
204
+ }
205
+
206
+ /** Delete `path`: already gone is fine, any other failure throws. */
207
+ async function deleteIfPresent(path: string): Promise<void> {
208
+ try {
209
+ await Bun.file(path).delete();
210
+ } catch (error) {
211
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
212
+ }
213
+ }
214
+
215
+ /** Why a transition did not run under the lock; see `FileLockResult`. */
216
+ type LockMiss = "busy" | "error" | "unsupported";
217
+
218
+ function warnLockMiss(step: string, miss: { reason: LockMiss; error?: unknown }): void {
219
+ const detail = miss.error === undefined ? "" : `: ${String(miss.error)}`;
220
+ console.warn(scrubSecrets(`[claude-md] no lock at ${step} (${miss.reason})${detail}`));
221
+ }
222
+
223
+ /** `unsupported`: materialized anyway, without protection (see the module header). */
224
+ export type ClaudeMdMaterializeOutcome = "materialized" | LockMiss;
225
+
226
+ /**
227
+ * SessionStart: under the lock, back up whatever is on disk together with its
228
+ * lineage, then materialize the DB value and record it. Lock busy or failing →
229
+ * nothing is written (the file is left as it is). No flock on this platform →
230
+ * materialized without protection; `stopClaudeMd` then never pushes. Throws,
231
+ * leaving the file and its record as they were, if the record cannot be marked
232
+ * pending or an unsynced edit on disk cannot be saved.
233
+ */
234
+ export async function materializeClaudeMd(
235
+ content: string,
236
+ paths: ClaudeMdSessionPaths = DEFAULT_CLAUDE_MD_SESSION_PATHS,
237
+ options: { testPauses?: ClaudeMdTestPauses; lockWaitMs?: number } = {},
238
+ ): Promise<ClaudeMdMaterializeOutcome> {
239
+ const pauses = options.testPauses ?? {};
240
+ const locked = await withFileLock(paths.lock, () => materializeUnlocked(content, paths, pauses), {
241
+ waitMs: options.lockWaitMs ?? SESSION_START_LOCK_WAIT_MS,
242
+ });
243
+ if (locked.acquired) return "materialized";
244
+ warnLockMiss("SessionStart", locked);
245
+ if (locked.reason === "unsupported") await materializeUnlocked(content, paths, pauses);
246
+ return locked.reason;
247
+ }
248
+
249
+ async function materializeUnlocked(
250
+ content: string,
251
+ paths: ClaudeMdSessionPaths,
252
+ pauses: ClaudeMdTestPauses,
253
+ ): Promise<void> {
254
+ const state = await readClaudeMdSyncState(paths);
255
+ if (state) {
256
+ const { content: existing, record } = state;
257
+ if (state.lineageUnknown) await saveUnsynced(paths, existing);
258
+ // With a record, anything but the hook's own write is an agent's unsynced
259
+ // edit. Without one (the file predates the hook, e.g. the user's own) its
260
+ // origin is unknown: treat it as hook-written, never to be pushed.
261
+ const lineage = record ? claudeMdLineageOf(existing, record) : hookWrittenLineage(existing);
262
+ await Bun.write(paths.backup, existing);
263
+ // `of` pins the sidecar to this exact backup: a stale sidecar left by an
264
+ // earlier overlap must not describe a different `.bak`.
265
+ const sidecar = { ...lineage, of: contentSha256(existing) };
266
+ await writeAtomic(`${paths.backup}.lineage`, JSON.stringify(sidecar)).catch(() => {});
267
+ }
268
+ const hash = contentSha256(content);
269
+ await writeUnderPendingRecord(paths, pauses, content, { written: hash, base: hash });
270
+ }
271
+
272
+ /**
273
+ * Restore the `.bak` with its lineage (or remove the file if there was none).
274
+ * Throws, having restored nothing, if the record cannot be marked pending.
275
+ */
276
+ async function restoreUnlocked(
277
+ paths: ClaudeMdSessionPaths,
278
+ pauses: ClaudeMdTestPauses,
279
+ ): Promise<void> {
280
+ const backup = Bun.file(paths.backup);
281
+ const sidecar = Bun.file(`${paths.backup}.lineage`);
282
+ if (await backup.exists()) {
283
+ const content = await backup.text();
284
+ const raw = (await sidecar.exists()) ? await sidecar.text() : undefined;
285
+ let lineage: ClaudeMdLineage | null = null;
286
+ try {
287
+ const pinnedTo = raw ? (JSON.parse(raw) as { of?: unknown }).of : undefined;
288
+ if (pinnedTo === contentSha256(content)) lineage = parseClaudeMdLineage(raw);
289
+ } catch {
290
+ lineage = null; // corrupt sidecar: unknown lineage
291
+ }
292
+ // Without a matching sidecar (a legacy `.bak`, a failed or stale sidecar)
293
+ // the lineage is unknown: record it as hook-written, the conservative choice
294
+ // (never pushed as an edit).
295
+ await writeUnderPendingRecord(paths, pauses, content, lineage ?? hookWrittenLineage(content));
296
+ // Only after the lineage: a `.bak` that cannot be deleted must not cost a
297
+ // restored edit its lineage. (A leftover `.bak` is restored again, harmlessly.)
298
+ await backup.delete();
299
+ await sidecar.delete().catch(() => {});
300
+ } else {
301
+ // The record goes only once the file is gone: a file left without its
302
+ // record would be sent unconditionally by the next Stop.
303
+ await deleteIfPresent(paths.file);
304
+ await deleteIfPresent(paths.record).catch(() => {});
305
+ }
306
+ }
307
+
308
+ /**
309
+ * What a Stop must save before its restore discards the file: content of
310
+ * unknown lineage (never pushed), or, when the Stop could not push at all (no
311
+ * flock), an edit it would have sent.
312
+ */
313
+ function unpushedContent(state: ClaudeMdSyncState | null, couldPush: boolean): string | null {
314
+ if (!state) return null;
315
+ if (state.lineageUnknown) return state.content;
316
+ return !couldPush && planClaudeMdSync(state) ? state.content : null;
317
+ }
318
+
319
+ /**
320
+ * Save `unpushed` (see `unpushedContent`), then restore; if the save fails the
321
+ * restore is skipped and the file stays. A Stop never fails over its restore:
322
+ * it is logged, and the rest of Stop runs.
323
+ */
324
+ async function restoreSavingUnpushed(
325
+ paths: ClaudeMdSessionPaths,
326
+ pauses: ClaudeMdTestPauses,
327
+ unpushed: string | null,
328
+ ): Promise<void> {
329
+ try {
330
+ if (unpushed !== null) await saveUnsynced(paths, unpushed);
331
+ await restoreUnlocked(paths, pauses);
332
+ } catch (error) {
333
+ console.warn(scrubSecrets(`[claude-md] .bak not restored: ${String(error)}`));
334
+ }
335
+ }
336
+
337
+ export interface ClaudeMdSyncState {
338
+ /** Current content of the shared file. */
339
+ content: string;
340
+ /** Lineage record of the last hook write, or null if there is none. */
341
+ record: ClaudeMdLineage | null;
342
+ /** The record is there but is no lineage for this content: never pushed, never discarded. */
343
+ lineageUnknown: boolean;
344
+ }
345
+
346
+ /** Read what the Stop sync needs to decide; null when the file does not exist. */
347
+ export async function readClaudeMdSyncState(
348
+ paths: ClaudeMdSessionPaths = DEFAULT_CLAUDE_MD_SESSION_PATHS,
349
+ readFile: FileReader = readText,
350
+ ): Promise<ClaudeMdSyncState | null> {
351
+ const content = await readFile(paths.file);
352
+ if (content === undefined) return null;
353
+ const raw = await readFile(paths.record);
354
+ return {
355
+ content,
356
+ record: effectiveClaudeMdLineage(raw, content),
357
+ lineageUnknown: hasUnknownLineage(content, raw),
358
+ };
359
+ }
360
+
361
+ /** `unsupported`: the `.bak` was restored, but nothing is ever pushed there. */
362
+ export type ClaudeMdStopOutcome = "synced" | "not-an-edit" | LockMiss;
363
+
364
+ /**
365
+ * Stop: under the lock, decide the sync from what is on disk, run it, then
366
+ * restore the `.bak` — one transition, so no SessionStart can land in between.
367
+ * Lock busy or failing → nothing is pushed or restored (safe: the next
368
+ * SessionStart backs up whatever is there, with its lineage). No flock on this
369
+ * platform → the `.bak` is restored as before, but nothing is ever pushed (an
370
+ * edit it would have sent is saved first). A restore that fails is logged,
371
+ * never thrown.
372
+ */
373
+ export async function stopClaudeMd(
374
+ sync: (body: ProfilePayload["body"]) => Promise<void>,
375
+ paths: ClaudeMdSessionPaths = DEFAULT_CLAUDE_MD_SESSION_PATHS,
376
+ options: { testPauses?: ClaudeMdTestPauses; lockWaitMs?: number } = {},
377
+ ): Promise<ClaudeMdStopOutcome> {
378
+ const pauses = options.testPauses ?? {};
379
+ const locked = await withFileLock(
380
+ paths.lock,
381
+ async (): Promise<ClaudeMdStopOutcome> => {
382
+ const state = await readClaudeMdSyncState(paths);
383
+ const body = state ? planClaudeMdSync(state) : null;
384
+ if (body) {
385
+ await sync(body).catch((error: unknown) => {
386
+ console.warn(scrubSecrets(`[claude-md] sync failed: ${String(error)}`));
387
+ });
388
+ }
389
+ await restoreSavingUnpushed(paths, pauses, unpushedContent(state, true));
390
+ return body ? "synced" : "not-an-edit";
391
+ },
392
+ { waitMs: options.lockWaitMs ?? STOP_LOCK_WAIT_MS },
393
+ );
394
+ if (locked.acquired) return locked.value;
395
+ warnLockMiss("Stop", locked);
396
+ if (locked.reason === "unsupported") {
397
+ // Nothing is pushed without the lock: an edit this Stop would have sent is
398
+ // saved instead of being discarded by the restore. Unreadable → no restore.
399
+ const state = await readClaudeMdSyncState(paths).catch((error: unknown) => {
400
+ console.warn(scrubSecrets(`[claude-md] .bak not restored: ${String(error)}`));
401
+ return undefined;
402
+ });
403
+ if (state !== undefined) {
404
+ await restoreSavingUnpushed(paths, pauses, unpushedContent(state, false));
405
+ }
406
+ }
407
+ return locked.reason;
408
+ }
@@ -2,9 +2,9 @@
2
2
  * Universal context preamble for follow-up task continuity.
3
3
  *
4
4
  * Builds a bounded text summary of prior task context (parent → ancestor chain)
5
- * and prepends it to the child task's prompt. This makes follow-up continuity
6
- * uniform across ALL harness providers — not just those that support native
7
- * session resume (claude/codex).
5
+ * and inserts it after the child task's leading skill invocation. This makes
6
+ * follow-up continuity uniform across ALL harness providers — not just those
7
+ * that support native session resume (claude/codex).
8
8
  *
9
9
  * Token budget (CONTEXT_PREAMBLE_MAX_TOKENS, default 2000) prevents the
10
10
  * SIGTERM-143 context-saturation failure mode seen with unbounded session
@@ -16,6 +16,18 @@ import { scrubSecrets } from "../utils/secret-scrubber";
16
16
  import { isSteeringEnabled } from "../utils/steering-enabled";
17
17
  import { taskAttachmentDisplayUrl } from "../utils/task-attachment-links";
18
18
 
19
+ /** Keep slash skills on line one so native and adapter resolvers can expand them. */
20
+ export function prependContextPreamble(prompt: string, preamble: string): string {
21
+ if (!preamble) return prompt;
22
+ const newline = prompt.indexOf("\n");
23
+ const firstLine = newline === -1 ? prompt : prompt.slice(0, newline);
24
+ if (!/^\/[a-z0-9:_-]+(?:[ \t].*)?$/.test(firstLine.trim())) {
25
+ return preamble + prompt;
26
+ }
27
+ const rest = newline === -1 ? "" : prompt.slice(newline + 1);
28
+ return `${firstLine}\n${preamble}${rest}`;
29
+ }
30
+
19
31
  export const CONTEXT_PREAMBLE_MAX_TOKENS = Number(
20
32
  process.env.CONTEXT_PREAMBLE_MAX_TOKENS || "2000",
21
33
  );