@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
@@ -133,7 +133,7 @@ registerTemplate({
133
133
 
134
134
  **RESUMED TASK** - This task was interrupted during a deployment and is being resumed.
135
135
 
136
- Task: "{{task_description}}"
136
+ Task: "{{task_description}}"{{attachments_section}}
137
137
 
138
138
  Previous Progress:
139
139
  {{progress}}
@@ -143,6 +143,7 @@ Continue from where you left off. Review the progress above and complete the rem
143
143
  { name: "work_on_task_cmd", description: "Formatted /work-on-task command" },
144
144
  { name: "task_id", description: "Task ID" },
145
145
  { name: "task_description", description: "Original task description" },
146
+ { name: "attachments_section", description: "Task attachment fetch recipes" },
146
147
  { name: "progress", description: "Previous progress text" },
147
148
  {
148
149
  name: "completion_instructions",
@@ -159,13 +160,14 @@ registerTemplate({
159
160
 
160
161
  **RESUMED TASK** - This task was interrupted during a deployment and is being resumed.
161
162
 
162
- Task: "{{task_description}}"
163
+ Task: "{{task_description}}"{{attachments_section}}
163
164
 
164
165
  No progress was saved before the interruption. Start the task fresh but be aware files may have been partially modified.{{completion_instructions}}`,
165
166
  variables: [
166
167
  { name: "work_on_task_cmd", description: "Formatted /work-on-task command" },
167
168
  { name: "task_id", description: "Task ID" },
168
169
  { name: "task_description", description: "Original task description" },
170
+ { name: "attachments_section", description: "Task attachment fetch recipes" },
169
171
  {
170
172
  name: "completion_instructions",
171
173
  description: "Completion instructions (empty for providers without MCP)",
@@ -173,3 +175,24 @@ No progress was saved before the interruption. Start the task fresh but be aware
173
175
  ],
174
176
  category: "task_lifecycle",
175
177
  });
178
+
179
+ registerTemplate({
180
+ eventType: "task.output.schema",
181
+ header: "",
182
+ defaultBody: `\n\n**Required Output Format**: When completing this task, you MUST call store-progress with output that is valid JSON conforming to this schema:
183
+ \`\`\`json
184
+ {{schema}}
185
+ \`\`\`
186
+ Call store-progress with status "completed" and your JSON output. If your output doesn't match the schema, the tool call will fail and you should fix and retry.`,
187
+ variables: [{ name: "schema", description: "Required output JSON schema" }],
188
+ category: "task_lifecycle",
189
+ });
190
+
191
+ registerTemplate({
192
+ eventType: "task.output.generic",
193
+ header: "",
194
+ defaultBody:
195
+ '\n\nWhen done, use `store-progress` with status: "completed" and include your output.',
196
+ variables: [],
197
+ category: "task_lifecycle",
198
+ });
package/src/hooks/hook.ts CHANGED
@@ -9,9 +9,12 @@ import {
9
9
  postRatings,
10
10
  type RetrievalRow,
11
11
  } from "../be/memory/raters/llm";
12
+ import { materializeClaudeMd, stopClaudeMd } from "../commands/claude-md-session";
12
13
  import {
13
14
  buildIndependentIdentityPayloads,
14
15
  contentSha256,
16
+ fetchWithSyncTimeout,
17
+ type ProfilePayload,
15
18
  readIdentityBaselines,
16
19
  warnProfileFileTooLarge,
17
20
  } from "../commands/profile-sync";
@@ -24,10 +27,6 @@ import { checkToolLoop, clearToolHistory } from "./tool-loop-detection";
24
27
 
25
28
  const SERVER_NAME = pkg.config?.name ?? "agent-swarm";
26
29
 
27
- // CLAUDE.md file paths
28
- const CLAUDE_MD_PATH = `${process.env.HOME}/.claude/CLAUDE.md`;
29
- const CLAUDE_MD_BACKUP_PATH = `${process.env.HOME}/.claude/CLAUDE.md.bak`;
30
-
31
30
  // Identity and workspace file paths
32
31
  const SOUL_MD_PATH = "/workspace/SOUL.md";
33
32
  const IDENTITY_MD_PATH = "/workspace/IDENTITY.md";
@@ -120,7 +119,7 @@ export async function postHookProfileUpdate({
120
119
  }: {
121
120
  url: string;
122
121
  headers: Record<string, string>;
123
- body: Record<string, string>;
122
+ body: ProfilePayload["body"];
124
123
  label: string;
125
124
  fetchImpl?: typeof fetch;
126
125
  }): Promise<void> {
@@ -204,47 +203,6 @@ async function fetchTaskDetails(
204
203
  }
205
204
  }
206
205
 
207
- /**
208
- * Backup existing CLAUDE.md file if it exists
209
- */
210
- async function backupExistingClaudeMd(): Promise<void> {
211
- const file = Bun.file(CLAUDE_MD_PATH);
212
- if (await file.exists()) {
213
- const content = await file.text();
214
- await Bun.write(CLAUDE_MD_BACKUP_PATH, content);
215
- }
216
- }
217
-
218
- /**
219
- * Write agent's CLAUDE.md content to ~/.claude/CLAUDE.md
220
- */
221
- async function writeAgentClaudeMd(content: string): Promise<void> {
222
- // Ensure ~/.claude directory exists
223
- const dir = `${process.env.HOME}/.claude`;
224
- try {
225
- await Bun.$`mkdir -p ${dir}`.quiet();
226
- } catch {
227
- // Directory may already exist
228
- }
229
- await Bun.write(CLAUDE_MD_PATH, content);
230
- }
231
-
232
- /**
233
- * Restore CLAUDE.md from backup or remove it if no backup exists
234
- */
235
- async function restoreClaudeMdBackup(): Promise<void> {
236
- const backupFile = Bun.file(CLAUDE_MD_BACKUP_PATH);
237
- if (await backupFile.exists()) {
238
- const content = await backupFile.text();
239
- await Bun.write(CLAUDE_MD_PATH, content);
240
- // Remove backup file
241
- await Bun.$`rm -f ${CLAUDE_MD_BACKUP_PATH}`.quiet();
242
- } else {
243
- // No backup existed, remove the agent's CLAUDE.md
244
- await Bun.$`rm -f ${CLAUDE_MD_PATH}`.quiet();
245
- }
246
- }
247
-
248
206
  /**
249
207
  * Resolve task context for the Stop-hook session-summary / memory-rater
250
208
  * piggyback. Prefers the AGENT_SWARM_TASK_ID env var (set by the harness in
@@ -663,22 +621,21 @@ export async function handleHook(): Promise<void> {
663
621
  };
664
622
 
665
623
  /**
666
- * Sync CLAUDE.md content back to the server
624
+ * Stop's CLAUDE.md step: sync an agent's edit (never content a hook wrote, and
625
+ * as a compare-and-set against its base), then restore the `.bak` — all under
626
+ * the CLAUDE.md lock (see `claude-md-session.ts`). The POST is bounded
627
+ * (CLAUDE_MD_SYNC_TIMEOUT_MS) so the lock is never held for long.
667
628
  */
668
- const syncClaudeMdToServer = async (agentId: string): Promise<void> => {
669
- if (!mcpConfig) return;
670
-
671
- const file = Bun.file(CLAUDE_MD_PATH);
672
- if (!(await file.exists())) return;
673
-
674
- const content = await file.text();
675
-
676
- if (!content.trim()) return;
677
- await postHookProfileUpdate({
678
- url: `${getBaseUrl()}/api/agents/${agentId}/profile`,
679
- headers: mcpConfig.headers,
680
- body: { claudeMd: content, changeSource: "session_sync" },
681
- label: "claudeMd",
629
+ const syncAndRestoreClaudeMd = async (agentId: string): Promise<void> => {
630
+ await stopClaudeMd(async (body) => {
631
+ if (!mcpConfig) return;
632
+ await postHookProfileUpdate({
633
+ url: `${getBaseUrl()}/api/agents/${agentId}/profile`,
634
+ headers: mcpConfig.headers,
635
+ body,
636
+ label: "claudeMd",
637
+ fetchImpl: fetchWithSyncTimeout(),
638
+ });
682
639
  });
683
640
  };
684
641
 
@@ -1055,9 +1012,12 @@ export async function handleHook(): Promise<void> {
1055
1012
  // Write agent's CLAUDE.md if available
1056
1013
  if (agentInfo.claudeMd) {
1057
1014
  try {
1058
- await backupExistingClaudeMd();
1059
- await writeAgentClaudeMd(agentInfo.claudeMd);
1060
- console.log("Loaded your personal CLAUDE.md configuration.");
1015
+ const outcome = await materializeClaudeMd(agentInfo.claudeMd);
1016
+ console.log(
1017
+ outcome === "busy" || outcome === "error"
1018
+ ? "CLAUDE.md is being updated by another session; keeping the current copy."
1019
+ : "Loaded your personal CLAUDE.md configuration.",
1020
+ );
1061
1021
  } catch (error) {
1062
1022
  console.log(`Warning: Could not load CLAUDE.md: ${(error as Error).message}`);
1063
1023
  }
@@ -1334,13 +1294,12 @@ export async function handleHook(): Promise<void> {
1334
1294
  // PM2 not available or no processes - silently ignore
1335
1295
  }
1336
1296
 
1337
- // Sync CLAUDE.md, identity files, and setup script back to database, then restore backup
1297
+ // Sync CLAUDE.md (then restore its backup), identity files, and setup script back to database
1338
1298
  if (agentInfo?.id) {
1339
1299
  try {
1340
- await syncClaudeMdToServer(agentInfo.id);
1300
+ await syncAndRestoreClaudeMd(agentInfo.id);
1341
1301
  await syncIdentityFilesToServer(agentInfo.id);
1342
1302
  await syncSetupScriptToServer(agentInfo.id);
1343
- await restoreClaudeMdBackup();
1344
1303
  } catch {
1345
1304
  // Silently fail - don't block shutdown
1346
1305
  }
@@ -48,10 +48,12 @@ import {
48
48
  AgentSchema,
49
49
  AgentStatusSchema,
50
50
  AgentWithTasksSchema,
51
+ type ProfileSyncConflict,
51
52
  type ProviderName,
52
53
  ProviderNameSchema,
53
54
  ReasoningEffortSchema,
54
55
  RuntimeInstanceSchema,
56
+ VersionableFieldSchema,
55
57
  } from "../types";
56
58
  import {
57
59
  type ClaudeTransport,
@@ -59,6 +61,7 @@ import {
59
61
  resolveClaudeTransport,
60
62
  } from "../utils/claude-transport";
61
63
  import { MAX_PROFILE_FILE_LENGTH } from "../utils/constants";
64
+ import { HeartbeatExpiryError } from "../utils/heartbeat-expiry";
62
65
  import {
63
66
  type BudgetedIdentityField,
64
67
  IDENTITY_FIELD_BUDGETS,
@@ -411,6 +414,8 @@ const listAgentRuntimeInstances = route({
411
414
  },
412
415
  });
413
416
 
417
+ const ContentHashSchema = z.string().regex(/^[0-9a-f]{64}$/, "sha256 hex");
418
+
414
419
  const ProfileSyncRejectionSchema = z.object({
415
420
  field: z.enum(["soulMd", "identityMd", "claudeMd", "toolsMd"]),
416
421
  diskSize: z.number().int(),
@@ -442,6 +447,12 @@ const updateAgentProfileRoute = route({
442
447
  changeSource: z.string().optional(),
443
448
  changedByAgentId: z.string().optional(),
444
449
  changeReason: z.string().optional(),
450
+ /**
451
+ * Compare-and-set token per field: the sha256 (hex) of the content the edit
452
+ * was based on. A field whose current value hashes differently is dropped
453
+ * (the DB moved since), without failing the rest of the update.
454
+ */
455
+ expectedHashes: z.partialRecord(VersionableFieldSchema, ContentHashSchema).optional(),
445
456
  }),
446
457
  responses: {
447
458
  200: { description: "Profile updated", schema: AgentWithCapacitySchema },
@@ -840,6 +851,7 @@ export async function handleAgentsRest(
840
851
  }
841
852
  : undefined;
842
853
 
854
+ const conflicts: ProfileSyncConflict[] = [];
843
855
  let agent: Agent | null;
844
856
  try {
845
857
  agent = await updateAgentProfile(
@@ -859,8 +871,16 @@ export async function handleAgentsRest(
859
871
  ...(body.avatar !== undefined ? { avatar: body.avatar } : {}),
860
872
  },
861
873
  versionMeta,
874
+ {
875
+ expectedHashes: body.expectedHashes,
876
+ onConflict: (conflict) => conflicts.push(conflict),
877
+ },
862
878
  );
863
879
  } catch (error) {
880
+ if (error instanceof HeartbeatExpiryError) {
881
+ jsonError(res, error.message, 400);
882
+ return true;
883
+ }
864
884
  if (error instanceof IdentityFieldBudgetError) {
865
885
  if (
866
886
  versionMeta?.changeSource === "self_edit" ||
@@ -901,10 +921,29 @@ export async function handleAgentsRest(
901
921
  return true;
902
922
  }
903
923
 
924
+ // A dropped field still answers 200 (the rest of the update landed), so the
925
+ // drop must be visible somewhere: one event per field, queryable later.
926
+ for (const conflict of conflicts) {
927
+ try {
928
+ await createEvent({
929
+ category: "system",
930
+ event: "system.profile_sync_conflict",
931
+ status: "skipped",
932
+ source: "api",
933
+ agentId: parsed.params.id,
934
+ data: { ...conflict, changeSource: versionMeta?.changeSource ?? null },
935
+ });
936
+ } catch (eventError) {
937
+ const message = eventError instanceof Error ? eventError.message : String(eventError);
938
+ console.error(scrubSecrets(`[profile-sync] Failed to persist conflict event: ${message}`));
939
+ }
940
+ }
941
+
904
942
  if (versionMeta?.changeSource === "self_edit" || versionMeta?.changeSource === "session_sync") {
905
943
  try {
906
944
  for (const field of Object.keys(IDENTITY_FIELD_BUDGETS) as BudgetedIdentityField[]) {
907
- if (body[field] === undefined) continue;
945
+ // A field dropped by the compare-and-set was not written: nothing reconciled.
946
+ if (body[field] === undefined || conflicts.some((c) => c.field === field)) continue;
908
947
  await createEvent({
909
948
  category: "system",
910
949
  event: "system.profile_sync_reconciled",
package/src/http/auth.ts CHANGED
@@ -1,6 +1,11 @@
1
1
  import type { IncomingMessage } from "node:http";
2
2
  import { getPage } from "../be/db";
3
- import { findUserById, fingerprintApiKey, resolveUserByToken } from "../be/users";
3
+ import {
4
+ findUserById,
5
+ fingerprintApiKey,
6
+ resolveBySessionToken,
7
+ resolveUserByToken,
8
+ } from "../be/users";
4
9
  import type { User } from "../types";
5
10
  import { verifyPageSession } from "../utils/page-session";
6
11
  import type { HttpRequestAuth } from "../utils/request-auth-context";
@@ -52,6 +57,13 @@ export async function resolveHttpRequestAuth(
52
57
  }
53
58
  }
54
59
 
60
+ if (bearer.startsWith("aseph_")) {
61
+ const principal = await resolveBySessionToken(bearer);
62
+ if (principal) {
63
+ return { kind: "agent", agentId: principal.agentId, taskId: principal.taskId };
64
+ }
65
+ }
66
+
55
67
  return null;
56
68
  }
57
69
 
package/src/http/fs.ts CHANGED
@@ -1,17 +1,11 @@
1
- import { createHash } from "node:crypto";
2
1
  import type { IncomingMessage, ServerResponse } from "node:http";
3
2
  import { z } from "zod";
4
- import {
5
- deleteTaskAttachment,
6
- getAgentById,
7
- getTaskAttachments,
8
- getTaskById,
9
- insertTaskAttachment,
10
- } from "../be/db";
3
+ import { deleteTaskAttachment, getAgentById, getTaskAttachments, getTaskById } from "../be/db";
11
4
  import {
12
5
  ensureAgentFsCredentialsForAgent,
13
6
  inviteEmailToSharedOrg,
14
7
  } from "../be/seed/agent-fs-provision";
8
+ import { MAX_TASK_ATTACHMENT_BYTES, recordTaskAttachmentUpload } from "../be/task-attachment-store";
15
9
  import { type FileObject, type FileScope, FilesError, normalizeFilesError } from "../fs/provider";
16
10
  import { getFileStorageProvider } from "../fs/registry";
17
11
  import { can, type RbacPrincipal, type RbacResource } from "../rbac";
@@ -22,8 +16,6 @@ import { scrubSecrets } from "../utils/secret-scrubber";
22
16
  import { route } from "./route-def";
23
17
  import { BODY_TOO_LARGE, enforceContentLengthCap, jsonError } from "./utils";
24
18
 
25
- const MAX_UPLOAD_BYTES = 50 * 1024 * 1024;
26
-
27
19
  // Upload wall-clock past which the provider round-trip is worth a log line.
28
20
  // Attachment stalls were invisible before: this path had no timing at all.
29
21
  const SLOW_UPLOAD_LOG_MS = 3_000;
@@ -306,7 +298,8 @@ export async function handleFs(
306
298
  }
307
299
 
308
300
  if (uploadTaskFileRoute.match(req.method, pathSegments)) {
309
- if (enforceContentLengthCap(req, res, MAX_UPLOAD_BYTES) === BODY_TOO_LARGE) return true;
301
+ if (enforceContentLengthCap(req, res, MAX_TASK_ATTACHMENT_BYTES) === BODY_TOO_LARGE)
302
+ return true;
310
303
  const parsed = await uploadTaskFileRoute.parse(req, res, pathSegments, queryParams);
311
304
  if (!parsed) return true;
312
305
  const task = await getTaskById(parsed.params.taskId);
@@ -344,9 +337,9 @@ async function sendUpload(
344
337
  query: z.infer<typeof uploadQuery>,
345
338
  agentId: string | null,
346
339
  ): Promise<boolean> {
347
- const body = await readRawBody(req, MAX_UPLOAD_BYTES);
340
+ const body = await readRawBody(req, MAX_TASK_ATTACHMENT_BYTES);
348
341
  if (body === BODY_TOO_LARGE) {
349
- jsonError(res, `Payload too large (max ${MAX_UPLOAD_BYTES} bytes)`, 413);
342
+ jsonError(res, `Payload too large (max ${MAX_TASK_ATTACHMENT_BYTES} bytes)`, 413);
350
343
  return true;
351
344
  }
352
345
 
@@ -380,44 +373,21 @@ async function sendUpload(
380
373
  return sendProviderError(res, error);
381
374
  }
382
375
 
383
- try {
384
- const auth = getCurrentRequestAuth();
385
- const attachment = await insertTaskAttachment({
386
- taskId,
387
- agentId,
388
- name: query.name,
389
- kind: provider.id === "agent-fs" ? "agent-fs" : "shared-fs",
390
- path: uploaded.key,
391
- providerId: provider.id,
392
- providerKey: uploaded.key,
393
- capabilities: {
394
- ...provider.capabilities,
395
- version: uploaded.version,
396
- etag: uploaded.etag,
397
- },
398
- mimeType: uploaded.contentType ?? contentType,
399
- sizeBytes: uploaded.sizeBytes ?? body.byteLength,
400
- sha256: uploaded.sha256 ?? createHash("sha256").update(body).digest("hex"),
401
- intent: query.intent,
402
- description: query.description,
403
- isPrimary: query.isPrimary === "true",
404
- createdBy: auth?.kind === "user" ? auth.userId : undefined,
405
- });
406
- uploadTaskFileRoute.respond(res, 201, attachment);
407
- } catch (error) {
408
- try {
409
- await provider.delete(scope);
410
- } catch (cleanupError) {
411
- console.warn(
412
- scrubSecrets(
413
- `[fs] upload metadata insert failed and blob cleanup failed: ${
414
- cleanupError instanceof Error ? cleanupError.message : String(cleanupError)
415
- }`,
416
- ),
417
- );
418
- }
419
- throw error;
420
- }
376
+ const auth = getCurrentRequestAuth();
377
+ const attachment = await recordTaskAttachmentUpload({
378
+ provider,
379
+ scope,
380
+ uploaded,
381
+ sizeBytes: body.byteLength,
382
+ sha256: uploaded.sha256 ?? new Bun.CryptoHasher("sha256").update(body).digest("hex"),
383
+ contentType,
384
+ agentId,
385
+ intent: query.intent,
386
+ description: query.description,
387
+ isPrimary: query.isPrimary === "true",
388
+ createdBy: auth?.kind === "user" ? auth.userId : undefined,
389
+ });
390
+ uploadTaskFileRoute.respond(res, 201, attachment);
421
391
  return true;
422
392
  }
423
393
 
package/src/http/index.ts CHANGED
@@ -118,6 +118,7 @@ import {
118
118
  parseQueryParams,
119
119
  safeRequestUrlForLog,
120
120
  setCorsHeaders,
121
+ warnIfCorsAllowsAnyOrigin,
121
122
  wireHttpSpanLifecycle,
122
123
  } from "./utils";
123
124
  import { handleWebhooks } from "./webhooks";
@@ -556,6 +557,7 @@ try {
556
557
  process.exitCode = 1;
557
558
  throw err;
558
559
  }
560
+ warnIfCorsAllowsAnyOrigin();
559
561
 
560
562
  // Upgrade seed: explicit CAPABILITIES env values that predate capability
561
563
  // gating get the previously always-registered groups backfilled into a
package/src/http/mcp.ts CHANGED
@@ -4,6 +4,7 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/
4
4
  import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
5
5
  import { getAgentById, getResolvedConfig } from "@/be/db";
6
6
  import { createServer } from "@/server";
7
+ import { getRequestAuth } from "@/utils/request-auth-context";
7
8
  import { resolveScriptsOnlyMode } from "@/utils/scripts-only-mode";
8
9
 
9
10
  export type McpTransportActivity = Record<string, number>;
@@ -64,6 +65,34 @@ function unauthorized(res: ServerResponse, message = "Unauthorized"): true {
64
65
  return true;
65
66
  }
66
67
 
68
+ function forbidden(res: ServerResponse, message: string): true {
69
+ res.writeHead(403, { "Content-Type": "application/json" });
70
+ res.end(JSON.stringify({ error: message }));
71
+ return true;
72
+ }
73
+
74
+ /**
75
+ * When a request is authenticated via an aseph_ session token (kind: "agent"),
76
+ * the X-Agent-ID and X-Source-Task-Id headers MUST match the token's bound
77
+ * agentId and taskId. A valid token cannot be used on behalf of a different
78
+ * agent or task.
79
+ */
80
+ function validateAgentTokenIdentity(req: IncomingMessage, res: ServerResponse): true | undefined {
81
+ const auth = getRequestAuth(req);
82
+ if (!auth || auth.kind !== "agent") return undefined;
83
+
84
+ const agentId = headerValue(req.headers["x-agent-id"]);
85
+ const taskId = headerValue(req.headers["x-source-task-id"]);
86
+
87
+ if (!agentId || agentId !== auth.agentId) {
88
+ return forbidden(res, "X-Agent-ID does not match session token");
89
+ }
90
+ if (!taskId || taskId !== auth.taskId) {
91
+ return forbidden(res, "X-Source-Task-Id does not match session token");
92
+ }
93
+ return undefined;
94
+ }
95
+
67
96
  function headerValue(value: string | string[] | undefined): string | undefined {
68
97
  return Array.isArray(value) ? value[0] : value;
69
98
  }
@@ -111,6 +140,9 @@ export async function handleMcp(
111
140
  return false;
112
141
  }
113
142
 
143
+ const tokenIdentityMismatch = validateAgentTokenIdentity(req, res);
144
+ if (tokenIdentityMismatch) return true;
145
+
114
146
  const agentMismatch = validateBoundAgent(req, res, sessionId, sessionAgents);
115
147
  if (agentMismatch) return true;
116
148
 
@@ -8,7 +8,8 @@
8
8
  * Scope of THIS module (step-3):
9
9
  * - `auth_mode === 'public'`: ungated. HTML responses inline-inject the
10
10
  * `BROWSER_SDK_JS` constant from `src/artifact-sdk/browser-sdk.ts` (reused
11
- * verbatim — no token-injection hook on the client). JSON responses
11
+ * verbatim — no token-injection hook on the client). SVG responses serve
12
+ * raw bytes with a restrictive CSP and no injected scripts. JSON responses
12
13
  * 302-redirect to the SPA `/pages/:id` route (the JSON renderer lives
13
14
  * in the SPA, not the API — step-6/7).
14
15
  * - `auth_mode === 'authed'`: returns 401. step-4 narrows this to also
@@ -52,14 +53,14 @@ const publicPageRoute = route({
52
53
  method: "get",
53
54
  path: "/p/{id}",
54
55
  pattern: ["p", null],
55
- summary: "Render a page (HTML inline; JSON redirects to SPA)",
56
+ summary: "Render a page (HTML/SVG inline; JSON redirects to SPA)",
56
57
  tags: ["Pages"],
57
58
  params: z.object({ id: z.string() }),
58
59
  responses: {
59
60
  200: {
60
- description: "Rendered HTML page",
61
+ description: "Rendered HTML page or raw SVG image",
61
62
  unstructured:
62
- "Serves the page's raw agent-authored HTML body (SDK-injected) or, for ?print=1 on a JSON-content page, a standalone printable HTML document — not a JSON shape",
63
+ "Serves raw image/svg+xml bytes, the page's agent-authored HTML body (SDK-injected) or, for ?print=1 on a JSON-content page, a standalone printable HTML document — not a JSON shape",
63
64
  },
64
65
  302: { description: "Redirect to SPA for JSON content" },
65
66
  401: { description: "Page requires an authenticated session" },
@@ -593,6 +594,21 @@ export async function handlePagesPublic(
593
594
  return true;
594
595
  }
595
596
 
597
+ // SVG stays byte-for-byte intact, including when ?print=1 is supplied.
598
+ if (page.contentType === "image/svg+xml") {
599
+ const headers: Record<string, string> = {
600
+ "Content-Type": "image/svg+xml",
601
+ "Cache-Control": page.authMode === "public" ? "public, max-age=1800" : "private, no-store",
602
+ "X-Content-Type-Options": "nosniff",
603
+ "Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; sandbox",
604
+ };
605
+ if (inlineSetCookie) headers["Set-Cookie"] = inlineSetCookie;
606
+ res.writeHead(200, headers);
607
+ res.end(page.body);
608
+ await bumpViewCount(page.id);
609
+ return true;
610
+ }
611
+
596
612
  // `?print=1` — the SPA's "Export PDF" button opens the page in a new tab
597
613
  // with this flag so the page self-prints in the user's own browser (no
598
614
  // server-side headless browser). HTML pages get the auto-print snippet
package/src/http/pages.ts CHANGED
@@ -33,7 +33,12 @@ import { issuePageSessionCookie } from "../utils/page-session";
33
33
  import { getRequestAuth } from "../utils/request-auth-context";
34
34
  import { resolveHttpFavoriteOwner } from "./favorite-owner";
35
35
  import { route } from "./route-def";
36
- import { BODY_TOO_LARGE, enforceContentLengthCap, jsonError } from "./utils";
36
+ import {
37
+ BODY_TOO_LARGE,
38
+ enforceContentLengthCap,
39
+ isOriginAllowedForCredentials,
40
+ jsonError,
41
+ } from "./utils";
37
42
 
38
43
  /**
39
44
  * Per-page body-size cap. Page bodies are stored as a TEXT column with no
@@ -370,9 +375,12 @@ const getPageVersionRoute = route({
370
375
  function applyLaunchCors(req: IncomingMessage, res: ServerResponse): void {
371
376
  const origin = (req.headers.origin as string | undefined) ?? "";
372
377
  if (origin) {
373
- res.setHeader("Access-Control-Allow-Origin", origin);
374
378
  res.setHeader("Vary", "Origin");
375
- res.setHeader("Access-Control-Allow-Credentials", "true");
379
+ // Cookie issuance always requires the hosted/dev or configured allowlist.
380
+ if (isOriginAllowedForCredentials(origin)) {
381
+ res.setHeader("Access-Control-Allow-Origin", origin);
382
+ res.setHeader("Access-Control-Allow-Credentials", "true");
383
+ }
376
384
  }
377
385
  res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
378
386
  res.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type");
package/src/http/poll.ts CHANGED
@@ -94,11 +94,14 @@ const PollTriggerAttachmentSchema = TaskAttachmentSchema.pick({
94
94
  // user's identity fields, or (when no `requestedByUserId` is recorded) just a
95
95
  // rendered `name` for the UNKNOWN-identity sentinel.
96
96
  const PollRequestedBySchema = UserSchema.pick({
97
+ id: true,
97
98
  name: true,
98
99
  email: true,
99
100
  role: true,
100
101
  notes: true,
101
102
  }).extend({
103
+ // Absent for the UNKNOWN-identity sentinel (Slack-only requester, no users row).
104
+ id: z.string().optional(),
102
105
  // Structured communication preferences from `users.metadata.comms`.
103
106
  comms: UserCommsPrefsSchema.optional(),
104
107
  });
@@ -195,6 +198,7 @@ async function buildTriggerRequestedBy(task: {
195
198
  const user = task.requestedByUserId ? await getUserById(task.requestedByUserId) : undefined;
196
199
  if (user) {
197
200
  return {
201
+ id: user.id,
198
202
  name: user.name,
199
203
  email: user.email,
200
204
  role: user.role,
package/src/http/repos.ts CHANGED
@@ -8,7 +8,7 @@ import {
8
8
  updateSwarmRepo,
9
9
  } from "../be/db";
10
10
  import { emitIntegrationConnected } from "../telemetry";
11
- import { RepoGuidelinesSchema, RepoHooksSchema, SwarmRepoSchema } from "../types";
11
+ import { RepoGuidelinesInputSchema, RepoHooksSchema, SwarmRepoSchema } from "../types";
12
12
  import { route } from "./route-def";
13
13
  import { json, jsonError } from "./utils";
14
14
 
@@ -58,7 +58,7 @@ const createRepo = route({
58
58
  defaultBranch: z.string().optional(),
59
59
  autoClone: z.boolean().optional(),
60
60
  hooks: RepoHooksSchema.optional(),
61
- guidelines: RepoGuidelinesSchema.nullable().optional(),
61
+ guidelines: RepoGuidelinesInputSchema.nullable().optional(),
62
62
  }),
63
63
  responses: {
64
64
  201: { description: "Repo created", schema: SwarmRepoSchema },
@@ -81,7 +81,7 @@ const updateRepo = route({
81
81
  defaultBranch: z.string().optional(),
82
82
  autoClone: z.boolean().optional(),
83
83
  hooks: RepoHooksSchema.nullable().optional(),
84
- guidelines: RepoGuidelinesSchema.nullable().optional(),
84
+ guidelines: RepoGuidelinesInputSchema.nullable().optional(),
85
85
  }),
86
86
  responses: {
87
87
  200: { description: "Repo updated", schema: SwarmRepoSchema },