Swarm Swarm

Open questions

Status: living
  • OQ-1 Name / npm package. ✅ RESOLVED (2026-08-20): the project is swarm. A swarm of agents is the product's mental model and fits the dashboard's Fleet view. Published scoped as @ra3orblade/swarm (the bare swarm npm name is taken by Docker/Ethereum); CLI command swarm, daemon swarmd, hook swarm-hook, MCP swarm-mcp. Bare npx swarm would need the unscoped name — revisit only if we want it.
  • OQ-2 Hook install scope default. User-level (every session everywhere, matches "pick any folder") vs. project-level opt-in (less surprising for people trying it). Leaning user-level with swarm install --project as the alternative.
  • OQ-3 Fail-open vs fail-closed when the daemon is down. Proposal: fail open except for rules tagged critical; critical rules are evaluated locally in the shim from a cached rules file so they work without the daemon.

    Decision (2026-08-23, M8.1c): fail open by default; fail closed only for rules the org policy locks. The daemon keeps ~/.swarm/policy.cache.json (locked modes + live-session/held-worktree snapshot, sha256 integrity hash) while any rule is locked; the hook shim evaluates it with the same guardBash/guardWrite when the daemon is unreachable. Unlocked rules still fail open. Cryptographic signing of the cache waits for the team daemon's key distribution (M8.3).

  • OQ-4 Worktree location. .worktrees/ inside the repo (needs gitignore + test-runner exclusions) vs ~/.swarm/worktrees/<project>/<task> (zero footprint, matches repo-agnostic). Leaning outside the repo by default, .swarm.toml can override.

    Decision (2026-08-20): default ~/.swarm/worktrees/<project>/<task>; .swarm.toml worktrees = overrides. Zero footprint in the monitored repo and no vitest/gitignore exclusions needed.

  • OQ-5 Task source for M1.6. Markdown tables only, or also a tasks.json? Keep markdown-table only until a second format is actually needed.

    Decision (2026-08-22): markdown tables only. [tasks] source in .swarm.toml names one markdown file; every ID | Task | … | Status table in it is parsed (✅ done / 🟡 active / ⚪ todo, Depends may name a task or a milestone prefix). A second format waits for a real request.

  • OQ-6 Web stack. Vite+React+shadcn (familiar, batteries-included) vs plain HTML/htmx over SSE (no build, smaller). Dashboard will get interactive (stdin, run control); React.

    Decision (2026-08-27, M11): React + TypeScript, bundled by Bun.build. Still no Vite. The vanilla dashboard reached 3,551 lines in one app.js with 40 render functions, and its update model was one HTML string per view assigned to #main.innerHTML. Measured on Fleet, a single idle repaint destroyed and rebuilt 1,059 elements — 99 <svg> and 27 <img> — to change 11 text nodes. That is the visible flicker, and it forced three workarounds into render(): hold the frame while a menu is open, save/restore the caret in filter inputs, skip the project list mid-drag. refresh() had grown to 21 hand-tracked xChanged booleans compared by JSON.stringify. None of that is a bug to fix; it is the cost of hand-rolling reconciliation.

    React rather than Solid because the menus island is already React 19 and the toolchain already compiles TSX — this widens an existing seam instead of adding a second framework. No Vite because Bun.build already produces the bundle, and a dev server buys nothing over the daemon's static serving. Views import their domain types straight from @swarm/core, so the API layer is typed by the same code that produces the responses.

    Style: Google's TypeScript style guide, enforced by Biome where a rule exists (named exports only, no any, no non-null assertions, const by default) and by review where it does not.

  • OQ-7 Schema drift. How to pin tested Claude Code versions and detect new hook/stream event types — doctor warning plus raw passthrough is the proposal.
  • OQ-8 Non-Claude agents. Out of scope for v1; keep the adapter seam but do not design for it.
  • OQ-9 Memory search. ✅ RESOLVED (2026-08-22): SQLite FTS5 (BM25) first, no embedding model. bun:sqlite ships FTS5, so memory search needs no native extension, no model download and no network — it works on a fresh install, offline, and stays in the one swarm.db. Indexed: handoffs, incidents (the stopped command + reason), gate runs (rubric + evidence) and what each session last said; never the monitored codebase. Queries are sanitised into MATCH expressions that cannot error (core/memory.ts). sqlite-vec + local embeddings stay on the table as a second ranker if keyword recall proves insufficient on real use — revisit with evidence, not upfront. Original note: Vector search over Swarm's own data only (handoffs, incidents, gate evidence, notes, denied commands), local embeddings via sqlite-vec, never over the monitored codebase (Claude Code greps better than RAG). Scheduled under M4; decide embedding model then.

Decision: FTS5/BM25 over Swarm's own data; revisit vectors only with evidence of missed recall.

  • OQ-10 First non-Claude agent. ✅ RESOLVED (2026-08-20): Codex CLI first (real ~/.codex rollout logs, closest shape to Claude Code), then Gemini CLI, Aider, opencode/Cline, and Grok — all behind the same AgentAdapter interface. Original note: Which agent gets the first adapter (M5.3)? Candidates with a machine-readable session/telemetry surface a local daemon can read: OpenAI Codex CLI (JSONL session/rollout logs), Gemini CLI, Aider (.aider.*.history), opencode, Cline. GUI tools (Cursor) are harder (no hooks; would need their local DB/logs). Decides M5.3–M5.5.

  • OQ-11 Desktop packaging & signing. (Partly resolved 2026-08-21: reuse the existing Apple Developer account creds — same cert + notarization as the sibling Electron project — mapped to Tauri var names in docs/11-desktop-signing.md; the Tauri updater keypair is new. Remaining: set the CI secrets and cut a real signed release; Windows signing later.) Tauri (M6): tray+window vs window-only; sidecar daemon (bun build --compile) vs embedded runtime; and signing — macOS notarization needs an Apple Developer account, Windows needs a code-signing cert. Update channel: single stable vs beta/stable. These gate M6.3–M6.4.

  • OQ-12 Message delivery channel (M7.6). How does a message reach a running Claude Code session? Options: (a) additionalContext on the next hook response — zero new surface, but only arrives when the agent next calls a tool and PreToolUse context is advisory; (b) the agent polls swarm_inbox — reliable but only if the agent is told to poll (SessionStart context can say so); (c) stdin for spawned runs only (M3.1 already streams user messages). Leaning (a)+(b) together with (c) for spawned runs; interactive sessions cannot be interrupted, only informed. Decides M7.6–M7.7.

    Decision (2026-08-24, M7.6): (a)+(b)+(c) together. Messages are kind = 'message' rows in the questions table; delivery is additionalContext on the recipient's next hook (UserPromptSubmit / PreToolUse / PostToolUse), immediate stdin for a live spawned run on the target task, and pull via swarm_inbox (which now returns answers and messages). Addressing: a session id (or unique prefix), a task (resolved to the session inside its held worktree, queued until one appears), or "lead" (the project's latest live interactive session). Interactive sessions are informed, never interrupted.

  • OQ-13 Where do workflow steps and executed gates run (M7.4, M7.8)? Executed gates could run inside the daemon (simple, but the daemon is the only DB writer and should not block on a 10-minute test suite) or as a detached process in the registry (M1.4 proc, pid + start-time liveness, logs in ~/.swarm/logs) with the result posted back. Leaning the process registry: gates and workflow steps are just registered processes with an on_exit that records the gate run / advances the workflow; the daemon never blocks. Also: is a workflow's state a new table or derived from gate runs + runs per task? Leaning a small workflow_runs table (task, name, step index, status) — derivation gets ambiguous on retries.

    Decision (2026-08-23): executed gates (and later dispatch/workflow steps) run as detached processes in the registry (kind: gate, pid + start-time liveness, log in ~/.swarm/logs/<project>/gate-<task>-<gate>.log); the daemon records the gate run when the process exits and never blocks. Workflow state (0.8) gets its own workflow_runs table.

  • OQ-14 Dispatch safety (M7.5). What stops a dispatched run from flipping a task ✅ on its own? Proposal: the run never edits the task source; "done" is derived (required gates pass + PR opened, M7.3) and shown on the Board, and only a human (or a workflow's final built-in step) writes status back. Also the concurrency cap: per project only, or global (machine-wide token spend)? Leaning per project with a global default in ~/.swarm/config.toml.

    Decision (2026-08-23): a dispatched run never edits the task source. "Done" on the Board is derived — required gates pass (executed or recorded) and a PR is open — and only a human flips status. Concurrency cap per project: .swarm.toml [dispatch] max_parallel (default 2), global default in ~/.swarm/config.toml.

  • OQ-15 Commercial model / open-core boundary (M8). Swarm can become open-core without betraying local-first: everything that runs on one machine stays free and Apache-2.0 (daemon, dashboard, claims/worktrees/gates, rules, spawned runs, transcripts/cost, all agent adapters, .swarm.toml policies); everything that needs a second person is the paid tier (team daemon, SSO/OIDC, RBAC, org-level non-overridable policy, audit export / SIEM sinks, chargeback, fleet install, support). Constraints that must hold: (a) never relicense the existing packages — the team daemon lives in a separate package/repo under a source-available or closed license; (b) the free tier keeps "no telemetry, no account" — the account lives in the team daemon only; (c) self-hosted first — the buyers who chose Swarm because code never leaves the machine want the team daemon on their own infra (one binary, SQLite or Postgres); a hosted offering can wrap the same binary later if demand appears; (d) the free tier is never crippled to sell the paid one — M8.1/M8.2 below are free and land in core.

    Decision (2026-08-23): open-core on the single-machine / multi-person line; self-hosted team daemon first, hosted later; no relicensing of existing packages; free tier stays telemetry- and account-free. Pricing, license text and package split are open until M8.3 starts. Decision (2026-08-24, M8.3 start): the team daemon lives in this monorepo as packages/team (@ra3orblade/swarm-team, bin swarm-teamd) under FSL-1.1-ALv2 (the current SPDX id of the FSL "Apache-2.0 future" variant) — source-available, competing use restricted, each release converting to Apache-2.0 after two years; a per-package LICENSE.md overrides the repo Apache-2.0 for that directory only, and tools/build-pkg.ts excludes it from the free bundle. Design in 14-teams. Pricing remains open.

  • OQ-16 Daemon auth default (M8.2). daemon.auth is loopback-optional in 0.x so existing installs, the dashboard and the Tauri app keep working without a token. Should required become the default at 1.0, and how does the desktop app obtain the token (read daemon.json from the shell, or a one-shot /v1/session-token on loopback)? See 14-teams.

  • OQ-17 Local human identity before OIDC (M8.2). actor.kind = "human" needs an id on a machine with no login: OS username (spoofable on shared boxes), git config user.email (what the forge already attributes commits to), or just "laptop owner". Proposal: OS username now, replaced by the OIDC subject in M8.3; record both when available. See 14-teams.

  • OQ-18 Team daemon storage: when does Postgres land (M8.3)? v1 is SQLite (~/.swarm/team.db, WAL, the versioned migrate() pattern) behind the TeamStore class boundary — one binary, zero external services, which is what "self-hosted first" buyers asked for. Postgres becomes worth it when a deployment outgrows one node (HA, >~50 machines, SIEM-scale retention); the protocol (/t1/*) must not change when it does. Revisit with a real deployment that hurts, not upfront.

  • OQ-19 Cluster project identity + shared machines (M8.3). Two identities cross machine boundaries: projects — proposal: the normalized origin remote URL (the forge parsing in core/forge.ts already normalizes github/gitlab remotes) as the cluster key, path-hash fallback keeps unpushed repos local-only; and machines — a machine token is bound to the user who ran swarm login, so a shared build box re-binds on re-login and per-user attribution on shared machines relies on the local actor rows riding along in forwarded events. Good enough for v1? Decides M8.3b/M8.3d.

  • OQ-20 OTLP schema (M12.1). Mirror Claude Code's beta trace names (claude_code.interaction, llm_request, tool, tool.blocked_on_user) so Grafana's first-party dashboard works unchanged, or emit the OpenTelemetry GenAI semantic conventions (gen_ai.*, gen_ai.conversation.id = session) that W&B, Datadog and Arize read, with swarm.* attributes for claims, incidents and gates? Proposal: GenAI semconv for spans and metrics, plus Claude Code's span names as a [otel] compat = "claude-code" option, because Swarm exports six agents and only one of them is Claude Code.

    Decision (2026-09-18, M12.1): the proposal, with both vocabularies verified that day against their sources (open-telemetry/semantic-conventions-genai; code.claude.com monitoring-usage). GenAI by default: a trace per session keyed by gen_ai.conversation.id, invoke_agent {agent} root, execute_tool {tool} children, gen_ai.client.token.usage by gen_ai.token.type (input includes cached input), cost as swarm.cost.usage. compat = "claude-code" switches to claude_code.token.usage / claude_code.cost.usage / claude_code.tool / claude_code.tool.blocked_on_user with session.id; the root stays swarm.session, because Claude Code's own root is a per-prompt interaction Swarm does not reconstruct. Content (commands, paths) is opt-in, as in Claude Code. OTLP/HTTP JSON only — no SDK, no new dependency; validated against an OpenTelemetry Collector (0.161, debug exporter), not yet against Grafana Cloud.

  • OQ-21 Sandbox engine (M12.6). Wrap spawned runs in Anthropic's sandbox-runtime (srt, Apache-2.0, macOS Seatbelt / Linux bubblewrap, filtering proxy), pass Claude Code's own sandbox settings through for Claude runs only, or ship nothing and keep the guide's "hooks see the command, not the syscall" line? Proposal: srt as an optional dependency reported by doctor, because it is agent-neutral and the same engine Claude Code ships; Claude Code's settings remain the answer for interactive sessions.

    Decision (2026-10-01, M12.6): the proposal. srt is an optional dependency: swarm run --sandbox / [dispatch] sandbox wrap the spawned agent in it when it is installed, refuse with the install line when it is not, and doctor reports it. Interactive sessions keep Claude Code's own sandbox settings; Swarm does not write them.

  • OQ-22 Reviewer authority (M12.7). May a model decide an ask prompt? Codex (auto_review), Cursor and Claude Code (auto mode) all do, and all say the classifier can be wrong. Proposal: advise is the highest default anyone gets without opting in; decide is per repo, never for locked org rules, and every decision is an event with the reviewer's reason so rule effectiveness (M9.10) can score the reviewer like a rule.

    Decision (2026-10-01, M12.7): the proposal. [broker] reviewer defaults to off; advise is the most the user config can turn on for every repo; decide is only read from a repo's .swarm.toml, is never applied to a locked org rule, and every review is a permission.reviewed event with the reviewer's reason so rule effectiveness (M9.10) scores the reviewer like a rule. A person can always overrule it.

  • OQ-23 Quota source (M12.3). The subscription windows come from an OAuth usage endpoint the popular monitors poll with the user's Claude credentials, or from the statusline payload Claude Code hands to statusLine commands. Proposal: statusline only — Swarm has no account and never sends the user's token anywhere; if the payload lacks the windows on the installed version, the tile says so instead of estimating.

    Decision (2026-09-12, M12.3): statusline only. The windows are stored as samples when a session on a plan reports them and shown while it does; there is no tile at all when nothing reports (an API-key machine has no windows to show, and a stale number would be worse than none). Burn rate is computed inside the newest reset period only, so a reset never reads as a negative pace. Warnings are once per window per reset period, at [budget] window_warn_at and at 100%, attributed to the project whose session last reported.

  • OQ-24 How many times may Swarm refuse a stop (M13.1). A Stop hook that answers block keeps the agent working; an agent that cannot make the gate pass would loop forever, burning the budget the gate was meant to protect. Proposal: max_blocks = 3 per session, then a gate_failed incident and the stop goes through; never on SubagentStop (a subagent's parent decides), never without a held claim (no task, no required gates), and on_stop = "record" stays the default so nothing changes until a repo opts in. The count is per session, not per task, so a resumed session starts fresh.

    Decision (2026-09-12, M13.1): as proposed. max_blocks = 3 per session (0 disables), counted from the session's gate.blocked events so a daemon restart cannot reset it; one gate_failed incident per session when the refusals are used up; on_stop = "record" by default; SubagentStop is not routed through the decision at all; a Stop waits at most stop_timeout (300 s) for the gates and is let through when they outrun it (the runs still record). stop_hook_active is not used as the bound — Claude Code sets it after one refusal, and one is too few for a fix-and-rerun.

  • OQ-25 The card and the terminal prompt race (M13.2). PermissionRequest lets a hook answer allow / deny, and the shim can wait on a person for up to the hook timeout. If Claude Code draws the terminal prompt only after the hook returns, a long wait reads as a stall to someone sitting at the keyboard; if it draws both, the card is a second answerer and the wait costs nothing. Proposal: verify which it is on the installed version first; default interactive_wait = 30 if the prompt is drawn concurrently, 0 (card is informational, terminal decides) if it is not, and in both cases a systemMessage names the other place to answer.

    Decision (2026-09-12, M13.2): verified on 2.1.269: the dialog is not drawn while the hook runs, and a timeout or empty answer falls through to it unchanged. So the wait is real, and it is made safe two ways rather than by defaulting it off: the daemon waits only while a dashboard has polled or streamed in the last 45 s (nobody watching, nobody waits), and the card carries Answer in terminal plus a countdown. Default interactive_wait = 30, capped at 120, 0 = off. The pass-through is recorded as permission.resolved { by: "terminal" } at once, so the M9.4 waiting pairs measure the card, and the terminal's own wait keeps coming from Claude Code's Notification hook as before.

  • OQ-26 Waking an idle session (M13.4). asyncRewake hooks run in the background and wake Claude with their output when they exit 2, but the reference does not say whether a hook armed at SessionStart survives the turn boundary or must be re-armed at Stop, nor whether the reminder reaches a session sitting idle at the prompt. Proposal: arm at both events, verify on 2.1.269 with a message sent to an idle session, and keep next-hook delivery (the OQ-12 decision) as the fallback the Fleet marks queued rather than delivered.

    Decision (2026-09-12, M13.4): the reference settles the semantics: an asyncRewake command hook runs in the background, wakes the session from idle at the prompt when it exits 2 (stderr, else stdout, shown as a system reminder), its timeout is enforced (Claude Code cancels it), and nothing persists across turns — a fresh instance starts per event. So the waiter is armed on SessionStart and Stop (the two moments an idle period begins), gives up at 55 min under a 3600 s timeout, and the daemon keeps one waiter per session, replacing older ones and cancelling on activity or end. Next-hook delivery stays as the mid-turn path and the fallback. The in-terminal wake is exercised by contract and at the shim level; a live idle-session run remains to be watched on a real terminal.

  • OQ-27 Distributing the team daemon (M13.12). packages/team is FSL-1.1-ALv2 and excluded from the free npm bundle by design, which today means it runs only from a clone. Options: publish @ra3orblade/swarm-team to npm as its own package (npm carries any SPDX license; the free package never depends on it), a ghcr.io/ra3orblade/swarm-teamd image built by release.yml on the same v* tag, a per-platform swarm-teamd binary attached to the GitHub release that the app downloads on demand, or all three. Proposal (revised 2026-09-12, after "why not in the interface of the app?"): all three, same version as the free package. The app hosts a team by fetching the release binary into ~/.swarm/bin — so the free download stays Apache-only and the FSL binary arrives with its license on screen the moment someone chooses to host — and the image + npm package serve teams that start on a server. Bundling the FSL binary inside the free app is the one option ruled out: it would put source-available code in every Apache download whether or not a team is ever hosted.

    Status (2026-09-12, M13.12): the decision stands and is not executed yet. Hosting from the app shipped by resolving swarm-teamd as a command (a clone, or PATH), which keeps FSL code out of the free bundle exactly as intended, but none of the three distribution channels exists: publishing a second npm package needs its own trusted-publisher rule on npmjs.com, and the image and the release binary need release.yml changes. Tracked as M13.13. Status (2026-09-18, M13.13): executed in the repo. bun run build:team (tools/build-team.ts) builds npm-team/ — the @ra3orblade/swarm-team bundle and, with --binaries, five compiled swarm-teamd-<os>-<arch> binaries plus swarm-teamd-SHA256SUMS; the team page's assets are embedded because a binary has nothing beside it. packages/team/Dockerfile is the ghcr.io/ra3orblade/swarm-teamd image (state on /data). release.yml job team publishes npm + ghcr on each v* tag and team-release-assets attaches the binaries to the draft release. The app downloads the binary only after the license is shown and accepted (POST /v1/team/teamd {accept: true}), checksum-verified, into ~/.swarm/bin. Two one-time steps remain outside the repo (until the first is done, only the npm step of the team job fails — it runs last, so the image and binaries still ship): the first npm publish of the new package by hand (npm's trusted publishing can only be configured for a package that exists) followed by its trusted-publisher rule, and making the ghcr package public after its first push.

  • OQ-28 Claiming Claude Code's own worktrees (M13.7). The roadmap planned WorktreeCreate / WorktreeRemove hooks that register Claude Code's worktrees as claims. The hooks reference (verified 2026-09-18) says a configured WorktreeCreate hook replaces Claude Code's default git worktree creation: the hook must create the directory and print its path, any failure aborts the creation, and .worktreeinclude is no longer processed. Installed user-wide that would put Swarm in charge of every claude --worktree, every subagent with isolation: "worktree" and every background session — and a shim that must fail open cannot also be the thing that has to succeed for a worktree to exist. Whether WorktreeRemove fires at all for plain git worktrees without a create hook is not stated. Options: take over creation in the shim (no daemon needed), or observe.

    Decision (2026-09-18): observe. No worktree hook is installed. The first hook from a session whose cwd is inside <repo>/.claude/worktrees/<name> (a real git worktree) claims it as cc/<name>, owner session:<id>, claims.origin = "claude-code"; the usual auto-renew keeps it, SessionEnd or a lapsed lease closes it, and a lapsed adoption is taken over by the next session there. Release, reap and gc never remove an adopted worktree — Claude Code decides whether it stays. The refuse-a-dirty-removal half is dropped: Claude Code already keeps a subagent worktree that has changes and asks before removing a --worktree session's.

  • OQ-29 An ask rule on an agent that cannot ask (M12.4). Codex's PreToolUse parses permissionDecision: "ask" but does not support it yet, Gemini CLI's BeforeTool only allows or denies, and Cursor's preToolUse accepts ask without enforcing it (all verified 2026-09-18). A rule in ask mode — five of the defaults are — has to become either allow or deny there.

    Decision (2026-09-18): deny, with the rule's reason plus "Swarm would ask the user here; 's hooks cannot ask, so the call is refused — ask the user to run it, or to turn the rule off." Allowing would make the rule a no-op on exactly the agents M12.4 exists for; the reasons already tell the agent what to do instead (stage explicit paths, kill by pid), so it can usually recover without the person. A rewrite is refused the same way, with the fixed command offered. Revisit when an agent's hook grows a real ask — effectiveDecision in core/src/agenthooks.ts is the one place to change.

  • OQ-30 What a scheduled workflow may do on its own (M13.10). A [[schedules]] entry starts a workflow on a cron with nobody at the keyboard. Its review-prs step would post on GitHub PRs under the user's account, and its gates step was planned to run "on the main checkout's branch", where someone may be working. Projects are also discovered from any session on the machine, so a cloned repo's .swarm.toml could schedule work by itself. Options for posting: never post, post a plain comment when the schedule opts in, or post approve / request-changes verdicts. Options for gates: the main checkout when it is clean and idle, or a scratch worktree.

    Decision (2026-10-01, M13.10):

    • Posting is a plain COMMENT review, and only when the schedule sets post = true. It never approves or requests changes. It is posted once per PR head (a swarm-review:<sha> marker is checked first), and only on PRs from the repo itself: forks are never reviewed at all.
    • gates runs in a detached scratch worktree under ~/.swarm/scratch at the default branch's head (origin/HEAD). The result is recorded as <branch>@<sha7> and the worktree is removed afterwards. The main checkout is never touched.
    • Arming. A schedule from .swarm.toml stays inert until armed (swarm schedule arm). Arming pins a hash of the definition, so any edit (cron, workflow, task or post) disarms it. swarm schedule run starts one by hand without arming, because a person asked.

Source: docs/07-open-questions.md · v0.16.0.