@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.
- package/README.md +2 -1
- package/dist/{acp-adapter-580t41c0.js → acp-adapter-9n319wqc.js} +61 -6
- package/dist/{actions-m662tjng.js → actions-2vxqvpr9.js} +9 -8
- package/dist/{app-zb3rndm8.js → app-w066xfy0.js} +6 -5
- package/dist/assistant-kt31dj2h.js +236 -0
- package/dist/{boot-reembed-072x00vn.js → boot-reembed-az5rassp.js} +7 -6
- package/dist/{boot-reembed-65zz2smt.js → boot-reembed-j3mm3rfz.js} +8 -7
- package/dist/{boot-scrub-logs-yz0ams04.js → boot-scrub-logs-b6h54817.js} +4 -4
- package/dist/{claude-adapter-paj270y2.js → claude-adapter-w1z04ann.js} +8 -6
- package/dist/{claude-managed-setup-w619jncn.js → claude-managed-setup-f5pmsyrp.js} +24 -3
- package/dist/{claude-sdk-session-swt80j9z.js → claude-sdk-session-f0cc1kec.js} +8 -6
- package/dist/{cli-dt553ktb.js → cli-0b6y7kxm.js} +1 -1
- package/dist/{cli-bxdhw615.js → cli-12fz972k.js} +4 -4
- package/dist/{cli-s2f20r5w.js → cli-1th1728g.js} +2 -2
- package/dist/{cli-rs6ec3b9.js → cli-3tgwnf43.js} +3 -3
- package/dist/{cli-gxnzc6wh.js → cli-4nr988h6.js} +1 -1
- package/dist/{cli-axs8e6d4.js → cli-53s590z8.js} +1 -1
- package/dist/{cli-ygbjp3z8.js → cli-54wwve05.js} +5 -4
- package/dist/{cli-v0asr5yv.js → cli-719k9j2c.js} +2 -2
- package/dist/{cli-anktweqr.js → cli-89xfd7f9.js} +21 -47
- package/dist/{cli-q34a0e10.js → cli-8v0g1yc8.js} +1 -1
- package/dist/cli-9d8ntetj.js +42 -0
- package/dist/{cli-z9fprcrf.js → cli-atcve9y8.js} +881 -738
- package/dist/{cli-jmb7g47h.js → cli-b5bq9crk.js} +14 -31
- package/dist/cli-d8brzxjb.js +190 -0
- package/dist/{cli-gzky0gkr.js → cli-de14znh8.js} +2 -2
- package/dist/{cli-xytwff7f.js → cli-dttph1bw.js} +2 -2
- package/dist/cli-ep19k3mb.js +19 -0
- package/dist/{cli-m1xa8be7.js → cli-f146mn5s.js} +7 -50
- package/dist/{cli-1fj0chdk.js → cli-f7dsy61y.js} +5 -3
- package/dist/{cli-f567d5c1.js → cli-ftht3zzj.js} +2782 -2589
- package/dist/{cli-a0g1gbt5.js → cli-hbv0kq6w.js} +2 -2
- package/dist/{cli-fhsz3twr.js → cli-jsp14b6c.js} +171 -1
- package/dist/{cli-7fnkj1hj.js → cli-kpv03zhj.js} +21 -17
- package/dist/{cli-m4j6trn4.js → cli-m8yrsg97.js} +2 -2
- package/dist/cli-m9sxbkhm.js +779 -0
- package/dist/cli-mmemxxdc.js +823 -0
- package/dist/{cli-tqtvj5pt.js → cli-n8508nre.js} +1 -1
- package/dist/{cli-z96t0thj.js → cli-p1d5b073.js} +37 -2
- package/dist/{cli-gh8y8f36.js → cli-rw51bq3j.js} +13 -10
- package/dist/{cli-59ej1c07.js → cli-smby5m17.js} +7 -6
- package/dist/{cli-65vqzz3v.js → cli-tzvk9haz.js} +94 -372
- package/dist/{cli-dc9k2ba9.js → cli-w9kb3bk1.js} +1 -1
- package/dist/{cli-kpev657q.js → cli-y6b6fb76.js} +25 -3
- package/dist/{cli-123q2get.js → cli-yz3djrm0.js} +9 -9
- package/dist/{cli-wvp6bvy4.js → cli-zhjmqxzh.js} +1 -1
- package/dist/cli.js +13 -13
- package/dist/{codex-adapter-k98vaw2r.js → codex-adapter-zw4dwsx7.js} +2 -2
- package/dist/{codex-hook-9pf19cks.js → codex-hook-bw8p5nh4.js} +2 -2
- package/dist/{codex-session-runner-tngtphx7.js → codex-session-runner-k981s65v.js} +2 -2
- package/dist/{commands-y8nbr3fv.js → commands-sykrxx7e.js} +4 -4
- package/dist/{db-30p4h2t1.js → db-sfbsc8wt.js} +6 -4
- package/dist/handlers-0aspcejm.js +603 -0
- package/dist/{hook-dcw4jfbj.js → hook-4nvqj2sb.js} +7 -5
- package/dist/{hook-21jexvfx.js → hook-hxccs7m5.js} +24 -49
- package/dist/{http-r4kq5g8d.js → http-y06z517d.js} +230 -94
- package/dist/{index-q3qja6ta.js → index-hssww7kj.js} +15 -14
- package/dist/{index-gay66b9w.js → index-k8znfx0z.js} +13 -12
- package/dist/{index-53zyht4z.js → index-tce1rss4.js} +371 -110
- package/dist/{index-nqvf26za.js → index-x5j894zf.js} +14 -13
- package/dist/{keepalive-xs8fa55y.js → keepalive-w733ax66.js} +6 -6
- package/dist/{lead-d9m2t4s0.js → lead-75bjqa9d.js} +29 -27
- package/dist/{maintenance-3zh82g6z.js → maintenance-w4f1zjck.js} +8 -7
- package/dist/{oauth-refresh-sweep-03snsye5.js → oauth-refresh-sweep-wdetky26.js} +5 -5
- package/dist/{onboard-0cg1925x.js → onboard-bb5net8s.js} +2 -2
- package/dist/{otel-impl-0ab95z27.js → otel-impl-0w7c14ap.js} +1 -1
- package/dist/{pi-mono-adapter-dtkz4e7j.js → pi-mono-adapter-2has4gnp.js} +12 -2
- package/dist/{pricing-refresh-e5jhzz4j.js → pricing-refresh-9nzsrgcq.js} +6 -6
- package/dist/{rbac-roles-p1mf40z3.js → rbac-roles-r3hqjp09.js} +5 -5
- package/dist/{rbac-roles-2w3cmrkc.js → rbac-roles-z1jrffbt.js} +4 -4
- package/dist/{registry-9kdck5ye.js → registry-v5wytg0s.js} +1 -2
- package/dist/{render-v2-xhawry3k.js → render-v2-vbr89fnb.js} +6 -5
- package/dist/{seed-pricing-t223sw91.js → seed-pricing-g62hy1pk.js} +7 -5
- package/dist/{setup-zcs0rce7.js → setup-gnfqnptp.js} +2 -2
- package/dist/{worker-qnp2ka62.js → worker-kznxp4qm.js} +29 -27
- package/openapi.json +263 -12
- package/package.json +1 -1
- package/plugin/commands/start-worker.md +4 -9
- package/plugin/pi-skills/start-worker/SKILL.md +4 -9
- package/src/be/db/agents.ts +556 -0
- package/src/be/db/context-versions.ts +117 -0
- package/src/be/db/runtime.ts +406 -0
- package/src/be/db/tasks/read.ts +1167 -0
- package/src/be/db/tasks/write.ts +953 -0
- package/src/be/db.ts +584 -3429
- package/src/be/migrations/147_scheduled_tasks_parent_task.sql +7 -0
- package/src/be/migrations/148_pages_svg.sql +123 -0
- package/src/be/migrations/149_session_tokens.sql +24 -0
- package/src/be/scripts/typecheck.ts +1 -0
- package/src/be/seed-pricing.ts +13 -8
- package/src/be/seed-scripts/catalog/ops-catalog-audit.inline.ts +1 -1
- package/src/be/seed-scripts/catalog/ops-catalog-audit.ts +1 -1
- package/src/be/seed-scripts/catalog/star-history-refresh.ts +101 -0
- package/src/be/seed-scripts/catalog/star-history-renderer.ts +119 -0
- package/src/be/seed-scripts/index.ts +17 -0
- package/src/be/seed-skills/index.ts +14 -0
- package/src/be/swarm-config-guard.ts +5 -2
- package/src/be/task-attachment-store.ts +70 -0
- package/src/be/users.ts +77 -0
- package/src/commands/attachments-section.ts +37 -0
- package/src/commands/claude-managed-setup.ts +45 -3
- package/src/commands/claude-md-session.ts +408 -0
- package/src/commands/context-preamble.ts +15 -3
- package/src/commands/profile-sync.ts +205 -5
- package/src/commands/provider-credentials.ts +60 -27
- package/src/commands/runner.ts +63 -58
- package/src/commands/templates.ts +25 -2
- package/src/hooks/hook.ts +26 -67
- package/src/http/agents.ts +40 -1
- package/src/http/auth.ts +13 -1
- package/src/http/fs.ts +21 -51
- package/src/http/index.ts +2 -0
- package/src/http/mcp.ts +32 -0
- package/src/http/pages-public.ts +20 -4
- package/src/http/pages.ts +11 -3
- package/src/http/poll.ts +4 -0
- package/src/http/repos.ts +3 -3
- package/src/http/sessions.ts +82 -0
- package/src/http/tasks.ts +10 -2
- package/src/http/utils.ts +79 -1
- package/src/prompts/session-templates.ts +12 -8
- package/src/providers/acp-adapter.ts +62 -8
- package/src/providers/pi-mono-adapter.ts +23 -6
- package/src/providers/pricing-sources.md +11 -0
- package/src/scheduler/scheduler.test.ts +49 -2
- package/src/scheduler/scheduler.ts +4 -0
- package/src/scripts-runtime/sdk-allowlist.ts +1 -0
- package/src/scripts-runtime/types/stdlib.d.ts +8 -0
- package/src/scripts-runtime/types/swarm-sdk.d.ts +8 -0
- package/src/server.ts +2 -0
- package/src/slack/assistant.ts +87 -46
- package/src/slack/handlers.test.ts +1 -3
- package/src/slack/handlers.ts +102 -87
- package/src/slack/inbound-files.ts +466 -0
- package/src/slack/templates.ts +14 -0
- package/src/tasks/task-terminal-effects.ts +158 -0
- package/src/tests/acp-adapter.test.ts +369 -117
- package/src/tests/budget-claim-gate.test.ts +1 -0
- package/src/tests/build-pi-skills.test.ts +5 -7
- package/src/tests/claude-managed-setup.test.ts +52 -4
- package/src/tests/claude-md-session.test.ts +603 -0
- package/src/tests/codex-skill-resolver.test.ts +19 -0
- package/src/tests/context-versioning.test.ts +107 -0
- package/src/tests/cors-allowlist.test.ts +309 -0
- package/src/tests/credential-check.test.ts +157 -14
- package/src/tests/db-agents.test.ts +91 -0
- package/src/tests/db-context-versions.test.ts +37 -0
- package/src/tests/db-extraction-contract.test.ts +45 -0
- package/src/tests/db-runtime.test.ts +70 -0
- package/src/tests/db-task-read-contract.test.ts +624 -0
- package/src/tests/db-task-write-contract.test.ts +653 -0
- package/src/tests/db-tasks-read.test.ts +183 -0
- package/src/tests/db-tasks-write.test.ts +230 -0
- package/src/tests/defer-task.test.ts +389 -0
- package/src/tests/docs-index-footprint.test.js +2 -2
- package/src/tests/e2e-visuals-stage.test.ts +151 -0
- package/src/tests/file-lock.test.ts +144 -0
- package/src/tests/fixtures/file-lock-holder.ts +13 -0
- package/src/tests/fixtures/hold-file-lock.ts +25 -0
- package/src/tests/heartbeat-expiry.test.ts +188 -0
- package/src/tests/http-api-integration.test.ts +78 -1
- package/src/tests/pages-public-html.test.ts +43 -0
- package/src/tests/pages-svg-migration.test.ts +42 -0
- package/src/tests/preload.ts +4 -1
- package/src/tests/profile-sync-warning.test.ts +16 -0
- package/src/tests/profile-sync.test.ts +126 -0
- package/src/tests/prompt-template-session.test.ts +20 -4
- package/src/tests/provider-registration.test.ts +93 -0
- package/src/tests/rbac-e2e-helpers.test.ts +53 -0
- package/src/tests/rbac-e2e-helpers.ts +19 -2
- package/src/tests/reload-config.test.ts +26 -0
- package/src/tests/routing-affinity-inventory.test.ts +1 -1
- package/src/tests/runner-attachments-section.test.ts +22 -2
- package/src/tests/runner-context-preamble.test.ts +30 -0
- package/src/tests/runner-deployment-resume.test.ts +107 -0
- package/src/tests/runner-repo-autostash.test.ts +12 -0
- package/src/tests/script-connections.test.ts +4 -0
- package/src/tests/seed-scripts.test.ts +2 -2
- package/src/tests/session-tokens.test.ts +279 -0
- package/src/tests/slack-file-tools-attachments.test.ts +408 -0
- package/src/tests/slack-inbound-files.test.ts +847 -0
- package/src/tests/star-history-refresh.test.ts +102 -0
- package/src/tests/swarm-config-reserved-keys.test.ts +21 -0
- package/src/tests/swarm-repos.test.ts +20 -0
- package/src/tests/tool-annotations.test.ts +4 -3
- package/src/tests/ui-logs-parser.test.ts +176 -0
- package/src/tools/create-page.ts +4 -2
- package/src/tools/defer-task.ts +239 -0
- package/src/tools/repos/update-repo.ts +2 -2
- package/src/tools/schedules/create-schedule.ts +1 -0
- package/src/tools/schedules/list-schedules.ts +1 -0
- package/src/tools/schedules/patch-schedule.ts +1 -0
- package/src/tools/schedules/update-schedule.ts +1 -0
- package/src/tools/slack-download-file.ts +60 -29
- package/src/tools/slack-read.ts +73 -32
- package/src/tools/store-progress.ts +16 -146
- package/src/tools/tool-config.ts +1 -0
- package/src/tools/utils.ts +21 -0
- package/src/types.ts +37 -4
- package/src/utils/acp-session-token.ts +56 -0
- package/src/utils/file-lock.ts +111 -0
- package/src/utils/heartbeat-expiry.ts +78 -0
- package/src/utils/request-auth-context.ts +2 -0
- package/src/utils/task-attachment-links.ts +22 -0
- package/templates/skills/code-quality/content.md +8 -5
- package/templates/skills/download-task-attachment/content.md +2 -1
- package/{plugin/pi-skills → templates/skills}/review-offered-task/SKILL.md +5 -5
- package/templates/skills/review-offered-task/config.json +14 -0
- package/{plugin/commands/review-offered-task.md → templates/skills/review-offered-task/content.md} +3 -8
- package/templates/skills/scheduling/content.md +4 -0
- package/templates/skills/slack-interaction/content.md +2 -2
- package/templates/skills/work-on-task/SKILL.md +16 -0
- package/templates/skills/work-on-task/config.json +14 -0
- package/templates/skills/work-on-task/content.md +11 -0
- package/dist/assistant-0qt42nzg.js +0 -212
- package/dist/cli-fs1kkqgr.js +0 -317
- package/dist/cli-jnf5zghe.js +0 -269
- package/dist/cli-n1q8q14d.js +0 -177
- package/dist/handlers-yfp1qn81.js +0 -592
- package/plugin/commands/work-on-task.md +0 -39
- 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
|
|
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 =
|
|
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
|
|
6
|
-
* uniform across ALL harness providers — not just those
|
|
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
|
);
|