Multi-agent coding architect with automatic self-improvement. 37 plainly named skills (modes fold overlapping ones: atlas-setup tour, atlas-dogfood smoke…

A self-configuring Claude Code plugin that turns any coding agent into a disciplined multi-agent architect. Run atlas-setup once to onboard a project, then drive work through the auto-triggering skills and the atlas:<role> subagent squad. A SessionStart hook loads the runtime automatically every session, and the self-improvement hooks (memory capture, chronicle facet capture, nudge, session ingest) make the agent better in a codebase the more it is used. No hook creates a skill or a slash command: durable knowledge goes to findings, docs, and memory.
Org deployment (11 departments, 156 department skills) lives in the separate armada plugin in this repo; install it alongside atlas only for org use.
One manual skill, 36 auto-trigger skills. Auto-trigger comes primarily from each skill's description (Claude Code loads on relevance); when_to_use is retained as atlas-local routing metadata. The manual skill sets disable-model-invocation: true.
Overlapping capabilities are modes of one skill rather than separate skills: atlas-setup (onboard, install, connectors, repair, tour), atlas-dogfood (full, smoke), atlas-frontend (build, component, polish), atlas-run-watch (jobs, PR CI), atlas-bakeoff (bakeoff, verdict), atlas-brainstorm (requirements, strategy), atlas-orchestrate (engine, feature flow), atlas-ship (full, commit-only), atlas-status (status, announce), atlas-audit (code, architecture, self, act), atlas-sweep (sweep, analyze). Each SKILL.md opens with its mode table. atlas keeps its own orchestrator, named agents, docs/.atlas SSOT, findings ledger and explicit-push-consent policy as the execution and safety authority throughout.
| Skill | Mode | What it does |
|---|---|---|
| atlas-audit | auto | Audits a codebase: CODE (quality, security, hotspots, dead code, docs drift), ARCHITECTURE (flowcharts, duplication), SELF (atlas run health), or ACT (open a remediation session for one audit finding by id; no id lists them). Use to audit a repo, map architecture or fix a finding. |
| atlas-autopilot | auto | Consent-gated autonomous pipeline: runs a ready plan or diagnosed fix via atlas-orchestrate, simplifies, reviews, fixes under a budget, stops at the local commit. Use to run a plan end to end. |
| atlas-bakeoff | auto | Decides a technical choice with evidence: compares 2-3 approaches against named criteria (bakeoff), or grades one option Adopt, Trial, Hold or Reject (verdict). Use when a costly, hard-to-reverse choice or a second opinion is needed; reversible picks go to atlas-plan. |
| atlas-brainstorm | auto | Grills a vague idea one hard question at a time, recommending an answer each turn, until shared understanding. Default mode writes a requirements-only docs/plans/ artifact (WHAT, not HOW); strategy mode writes docs/architecture/product-strategy.md. Writes no product code. Use before atlas-plan. |
| atlas-compound | auto | Captures one durable, verified, non-obvious lesson into docs/lessons/ after an eligibility gate and overlap check. Use after solving and verifying a problem a future engineer would repeat. |
| atlas-db-audit | auto | Audits a live database read-only via parallel subagents: schema inventory, schema-to-code reconciliation, privileges, naming. Use before a schema or permission change. |
| atlas-debug | auto | Diagnoses a reproducible bug, exception, stack trace or bad output and fixes the root cause with evidence. Use when the cause of a failure must be found and fixed. |
| atlas-doctor | auto | Atlas self-improvement loop: mines cross-session findings from atlas.db, applies accepted changes, records a baseline. Use when telemetry has accumulated or before changing a skill, hook or agent. |
| atlas-dogfood | auto | Drives the branch or PR diff in a live browser via atlas:ui-runtime-tester: full mode repairs genuine breaks in a bounded loop, smoke mode only reports Pass, Fail or Skip per route. Use for end-to-end flow checks or a quick render check. |
| atlas-explain | auto | Explains how or why existing code behaves, in chat, with file:line evidence from an atlas:explorer trace; read-only. Use when asked how something works or what code means. |
| atlas-frontend | auto | Builds, refactors or polishes frontend UI on one design system (shadcn/ui, Tailwind, Radix), verified live in the browser. Modes: build (screens, flows), component (one reusable async component), polish (live visual refinement). Use when creating or reworking UI. |
| atlas-gitignore | auto | Generates a zero-trust, deny-by-default .gitignore for a named stack, secrets re-excluded last. Use when starting a repo or hardening a .gitignore. |
| atlas-handoff | auto | Produces a dense session handoff so a fresh session resumes without re-discovery. Use at a checkpoint before context fills, before a break, or when handing work to another session. |
| atlas-harden | auto | Writes an idempotent endpoint remediation script (CHECK/SET/VERIFY) for RMM or MDM that proves whether it changed state. Use to remediate or enforce an endpoint setting at scale. |
| atlas-loop | auto | Matches a recurring or iterative task to a library loop: interval loops use /loop, fan-out loops run as a parallel verify workflow. Use to repeat, poll, iterate until a condition holds, or sweep. |
| atlas-optimize | auto | Single-experiment loop: baseline, one hypothesis, one change, identical re-measure, real delta reported. Use when a working system's metric should improve; not for bugs or restructuring. |
| atlas-orchestrate | auto | Orchestrates multi-step, multi-surface or whole-codebase work (build, fix, audit, refactor, investigate) via subagents with independent verification. Includes a feature flow that builds one feature across UI, API and data with the squad. Use when a task spans layers or the repo. |
| atlas-plan | auto | Writes an implementation-ready plan to docs/plans/ from a brainstorm artifact or feature description, with U-numbered units and a verification contract. Use when requirements are settled. |
| atlas-prompt | auto | Rewrites a vague coding request into a structured, environment-aware prompt, asking up to three questions if scope is ambiguous. Use when a request is underspecified. |
| atlas-proof | auto | Human-review workflow for project markdown: publishes a doc for review, records anchored comments in a sidecar file, folds feedback back. Use to share a plan, spec or draft for review; not proofreading. |
| atlas-prototype | auto | Builds a throwaway prototype in an isolated run directory to answer one visual, interaction or product-shape question, then promotes decisions into a plan or discards it. Use when showing beats arguing. |
| atlas-pulse | auto | Generates a time-windowed product pulse (usage, performance, errors) from wired telemetry, read-only, into docs/pulses/. Use for a product pulse or usage and error summary. |
| atlas-readme | auto | Generates an onboarding-grade README.md by inspecting the repo, tracing every claim to a file. Use when a repo has no README or a stale one. |
| atlas-refactor | auto | Refactors, renames or restructures code without changing behavior, with before/after evidence. Use when code works but is messy, hard to navigate or carries dead weight. |
| atlas-resolve-pr-feedback | auto | Resolves PR review feedback: triages each thread (fix, reply, needs-human), applies approved fixes, drafts replies, confirms before pushing or posting. Use when a PR has open review comments. |
| atlas-review | auto | Risk-selected multi-persona review of a diff, branch or PR; P0/P1 findings verified, report-only verdict (Ready, Ready-with-fixes, Not-ready). Use before merge. |
| atlas-run-watch | auto | Watches a long-running job (PID, log tail, CI URL, API, command loop) or one open GitHub PR's CI with tick/checkpoint/re-arm, spilling logs to a run ledger. PR targets repair genuine CI failures in a bounded loop, ask before pushing and never merge. Use when a job or PR needs watching. |
| atlas-setup | MANUAL | Manual atlas lifecycle: onboard (scaffold docs/ and .atlas/), install (claude-mem, context-mode, hooks), connectors (vendor MCP), repair, tour (first-session walkthrough), menu. Use to bring atlas online, fix it or learn it. |
| atlas-ship | auto | Ships finished work: fresh-context verifier checks a clean tree, local commit, stops for confirmation before any push or PR. Commit-only mode makes just the local conventional commit from a file list. Use when work is ready to commit or open a PR. |
| atlas-simplify | auto | Behavior-preserving simplification pass over a fresh diff (reuse, quality, efficiency reviewers), then re-runs the gate. Use after implementing, before review or commit; restructuring is atlas-refactor. |
| atlas-status | auto | Builds a stakeholder-readable status rollup for a plan or feature from docs/plans/, findings, the todo board and commits, claims labeled verified or assumed. Announce mode drafts plain-language release notes, Slack updates and changelog entries for shipped work. Use when asked how a feature is going or to announce it. |
| atlas-sweep | auto | Turns feedback into triaged findings. Sweep mode ingests new GitHub issues, triages them, verifies claimed fixes and updates a docs/features/ triage doc, never auto-closing or replying. Analyze mode turns a transcript, support thread or notes into findings tied to verbatim quotes. Use when feedback needs triage or analysis. |
| atlas-test-xcode | auto | Tests an iOS app on the simulator: boots it, builds and tests via xcodebuild, captures screenshots and logs. Use to build or test an iOS or Xcode project; macOS only. |
| atlas-ux-test | auto | UX test swarm for a web app: generates personas, drives real-browser walkthroughs and fuzzing, gates on client-surface correctness. Use for a full UI/UX pass or persona testing. |
| atlas-validate | auto | Verifies a finished Claude Code plugin: structure, manifest and content quality, file:line findings, pass or fail per check, no auto-fixing. Use when a plugin is believed complete. |
| atlas-wiki | auto | Generates and refreshes docs/wiki/ diagrams from docs/architecture/ using graphify. Use when architecture docs changed, before the completion gate, or when wiki diagrams are stale. |
| atlas-worktree | auto | Git-worktree primitive: creates or reuses linked worktrees, tears down with dirty-state protection. Use to isolate parallel implementer waves, spikes or bakeoffs, or close them at wave end. |
atlas/
|-- .claude-plugin/plugin.json # manifest (name: atlas, v12.4.5; 51 userConfig keys)
|-- package.json # omp.extensions entry ("./omp/index.ts") for marketplace installs
|-- contracts/ # shared rules read by the Python hooks AND the omp modules (native-tools, mandates, tool-names, hook-bridge)
|-- omp/ # omp extension package (index.ts, hook-bridge, mandates, style, advisor, workers, ...; omp/agents/ is GENERATED by gen-agents.ts)
|-- mcp/ # 12 bundled connector servers (11 Node server.mjs + vendored falcon); declared in .mcp.json
|-- hooks/ # 17 hook programs / 22 bindings (16 in hooks/ + atlas_doctor.py in scripts/, SessionStart; hooks.json wires them all)
| |-- session_boot.py # SessionStart: activate runtime, surface lessons
| |-- prompt_optimizer.py # UserPromptSubmit: optional rewrite + orchestration arm-early classifier
| |-- bash_advisor.py # PreToolUse(Bash): advisory warning on catastrophic commands only
| |-- recall_gate.py # PreToolUse(all tools): deny every non-claude-mem call until one real recall happens (TodoWrite exempt; armed when claude-mem is enabled)
| |-- fallow_gate.py # PreToolUse(Bash): fallow audit gate on git commit/push (fail-open if CLI absent)
| |-- todo_capture.py # PostToolUse(TodoWrite): mirror the plan into the durable board <project>/.atlas/.run/todos.json
| |-- format_after_edit.py # PostToolUse(Edit/Write): format after edits
| |-- edit_read_nudge.py # Pre/PostToolUse advisory: edit of a file read only via ctx_read
| |-- delegation_prewarn.py # PostToolUse(Edit/Write/MultiEdit/NotebookEdit): once-per-session pre-warn that an inline code edit will trip completion_gate (m)
| |-- docs_drift_watch.py # PostToolUse(Edit/Write/MultiEdit/NotebookEdit): inline docs-drift warning, debounced
| |-- dispatch_tripwire.py # PostToolUse advisory + PreToolUse deny: curb inline drift; flag a verifier that wrote no findings.json row
| |-- connector_credential_watch.py # PostToolUse(mcp__plugin_atlas_.* + known bare MCP prefixes): one warning on connector auth failure -- restart, do not sweep endpoints
| |-- stop_tail.py # Stop: one process runs ingest_session, chronicle_facet, memory_capture, nudge in order (replaces four Stop entries)
| |-- completion_gate.py # Stop: block premature "done" until the definition-of-done holds
| |-- ingest_session.py # Stop/SubagentStop/SessionEnd/PreCompact: mirror transcript to the observability DB
| |-- chronicle_facet.py # Stop: write one facets row + mirror signals into friction_events
| |-- memory_capture.py # Stop: persist lessons to ~/.atlas/memory/ (not bound to SubagentStop)
| |-- nudge.py # Stop only: self-improvement nudge (throttled)
| |-- worker_report_gate.py # SubagentStop: block an atlas:* subagent whose final message is not the fixed report container (once per agent_id; fail-open)
| |-- docs_drift.py # not a hook; shared find_root/docs_drift/git_changed_paths used by completion_gate.py and docs_drift_watch.py
| `-- validate-readonly-query.sh # not auto-loaded; DB-audit subagents wire it during read-only audits
|-- scripts/ # 25 non-test tools, each with a test_*.py beside it: atlas_doctor.py (repair + --mine miners; also wired via hooks.json --hook, SessionStart),
| # atlas_db.py (observability), atlas_todo.py (durable todo board + notes), atlas_finding.py (verifier's findings.json write path),
| # atlas_dashboard.py + atlas_control.py (local dashboard and its control plane), atlas_mux.py (colony workers: herdr panes, tmux fallback), atlas_herdr.py + atlas_launch.py + atlas_remote.py (herdr colony, pinned binary, tailnet access), atlas_wt.py (isolated-worker git worktrees: add/status/merge/prune, branch worker/<agent>, worktree .atlas/wt/<agent>), atlas_packs.py,
| # atlas_memory.py, atlas_curator.py, atlas_context_optimizer.py, atlas_hook_guard.py, session_ingest.py, sweep_state.py,
| # tool_routing.py, turn_scoring.py, typesafe_client.py, lint_docs_names.py (gate condition (l)), lint_skill_names.py,
| # asset_audit.py, discover_capabilities.py, build_hub.py, install_hooks.py
|-- references/ # plugin-wide references every skill and agent may load
| |-- operating-contract.md # the research -> document -> implement -> verify -> report loop
| |-- connector-config-flow.md # how a vendor connector gets configured
| `-- connector-tool-disclosure.md
|-- output-styles/
| `-- atlas-orchestrator.md # force-for-plugin: true - auto-applies whenever atlas is enabled
|-- agents/ # 13 core agents (atlas:<role>), auto-registered
| |-- explorer.md # read-only codebase mapping (never fork)
| |-- implementer.md # bounded, verified code edits
| |-- verifier.md # adversarial confirm/refute with runtime-parity requirement (never fork)
| |-- db-prober.md # read-only schema/RLS/index inspection
| |-- schema-inventory.md # PostgreSQL catalog inventory
| |-- rls-privilege-audit.md # read-only RLS/grants/privilege audit
| |-- naming-glossary-audit.md # table/column name audit against project glossary
| |-- ui-runtime-tester.md # live browser/runtime behavior
| |-- planner.md # multi-stage decomposition + stage maps (fork)
| |-- docs-curator.md # maintains the docs/ single source of truth (fork)
| |-- docs-auditor.md # audits docs/ for drift against code
| |-- completeness-critic.md # "what did we miss" gap pass before done (fork)
| `-- runner.md # mechanical executor (haiku/low) for <=7 exact numbered STEPS; fixed STEPS: report
`-- skills/ # the 37 skills, one directory each (SKILL.md; most add references/)
Install the plugin (place this directory under your plugins root, or install from the marketplace). On the next session the boot hook activates the runtime automatically. Then run atlas-setup once per project: it scaffolds docs/, installs claude-mem and context-mode if you approve, recommends the capabilities your stack needs, and tells you what to run next.
For omp, load the extension package by DIRECTORY -- agents are only discovered from a directory entry: omp --extension <abs>/plugins/atlas/omp, or add that directory to extensions: in ~/.omp/agent/config.yml. The extension adds the enforcement this CLI needs: native grep/glob route to lean-ctx, read/bash get one nudge per session, and session_stop blocks once for main-thread non-docs edit/write calls without a task dispatch. Native atlas agents (generated from agents/*.md with tuned thinkingLevel and model-role fallbacks -- see omp/README.md) make omp workers first-class colony members: the lead's omp todo plan mirrors into the durable board, and workers get CLAUDE_PLUGIN_ROOT set so ${CLAUDE_PLUGIN_ROOT}/scripts/atlas_todo.py works. The same ATLAS_TRIPWIRE_HARD=off and ATLAS_GATE=off kill switches apply.
On omp, ~/.atlas/settings.json env (Command Center settings) is applied at extension load (applyAtlasStoreEnv in omp/index.ts), and while task subagents run an atlas-channel widget (omp/channel-view.ts) shows the live channel notes and member status (ATLAS_CHANNELS=off disables it). Doctor checks install-content and omp-content (check_content_drift) warn when an installed copy differs from the source checkout.
The hooks auto-load from hooks/hooks.json when the plugin is installed - no manual step. All are stdlib-only. Most fail open on an internal error (exit 0 or an explicit fail-open decision), but two fail closed on purpose: dispatch_tripwire.py denies a Skill/Agent/Task dispatch outright when it cannot read the inline-op count from the observability DB (_deny, "DENY - tripwire could not verify... Failing closed") and unconditionally denies any nested subagent dispatch regardless of DB state; completion_gate.py denies "done" whenever its checked conditions fail, and only its own unhandled exceptions fail open. fallow_gate.py also denies on purpose (verdict: fail from the fallow CLI on git commit/push) but is fail-open by design when the CLI itself is missing (ATLAS_FALLOW=off).
completion_gate.py conditions (a)-(l) gate sessions flagged orchestrating in the observability DB. Condition (m), the delegation mandate, also blocks once in an unflagged docs/ project when the main thread shipped non-docs code with zero Task/Agent dispatches; sidechains are exempt and errors fail open. The flag is set by two independent writers - dispatch_tripwire.py, when a Skill dispatch names an orchestration skill (gated by ATLAS_TRIPWIRE), and prompt_optimizer.py's arm_orchestration, when a prompt reads as substantive engineering work (gated by ATLAS_ENGINE_ARM). Ambiguous prompts, a common verb plus a generic noun such as "table", or a longer prompt the regex does not arm, can be reclassified by a local System One model (hooks/prompt_decision.py, default http://127.0.0.1:11434, model nimble). A confident conversation label vetoes a regex arm. A confident code_change or investigation label can arm a regex miss. Timeout, low confidence, a bare defect label, and ATLAS_DECISION=off leave the regex answer in place. File and sqlite gates are not model calls. Either write arms conditions (a)-(l); disabling both writers prevents new flags but does not disable (m) or clear existing flags. ATLAS_GATE=off disables the whole completion gate.
| Hook | Event | Purpose |
|---|---|---|
session_boot.py | SessionStart | Activate the runtime, report dependency state, surface relevant lessons, carry the todo board over, and repair the durable docs/ tree (creates any missing scaffolder-owned subfolder; only when docs/ already exists, so a project that never asked for one is never scaffolded behind the user's back -- it gets a one-line notice instead; ATLAS_DOCS_REPAIR=off); when the claude-mem plugin is enabled, adds a "Recall first" line telling the session to run one claude-mem search before planning (ATLAS_MANDATES=off) |
atlas_doctor.py --hook | SessionStart | Rollback guard: warn loudly if the installed plugin was downgraded, the marketplace points at a fork, or hooks/assets are missing (warn-only, always exits 0) |
prompt_optimizer.py | UserPromptSubmit | Optional trigger-gated prompt rewrite; also arm-early classifier that flags substantive engineering prompts as orchestration runs (ATLAS_ENGINE_ARM=off). The ambiguous band may call a local System One model (ATLAS_DECISION=off keeps the regex) |
bash_advisor.py | PreToolUse (Bash) | Advisory only: warns on catastrophic patterns (rm -rf /, mkfs, dd to a disk, fork bomb), and once per session nudges a ponytail-review of the staged diff before git commit when the ponytail plugin is enabled (ATLAS_MANDATES=off). Never denies |
fallow_gate.py | PreToolUse (Bash) | Fallow agent gate: on git commit/git push, runs fallow audit --format json --quiet --explain --gate-marker agent and denies when verdict is fail. Fail-open if the fallow CLI is missing (ATLAS_FALLOW=off, FALLOW_GATE_MIN_VERSION). Docs: skills/atlas-orchestrate/references/fallow-tools.md |
recall_gate.py | PreToolUse (all tools) | claude-mem recall gate: the first main-thread tool call that is neither a claude-mem call nor TodoWrite is denied on every attempt until a real claude-mem call happens, naming the claude-mem search tool and an example argument (only a claude-mem call satisfies it). Armed only when the claude-mem plugin is enabled (hooks cannot see the callable tool set, so this is the proxy); skips subagent transcripts; fail-open (ATLAS_MANDATES=off). The omp twin is omp/mandates.ts; shared cases in contracts/mandates.json |
| dispatch_tripwire.py | PostToolUse + PreToolUse | In docs/ projects where lean-ctx is reachable (binary on PATH AND a lean-ctx MCP server in .mcp.json / Claude settings; a server supplied only by an installed plugin's .mcp.json is not detected and falls back to the nudge), deny native Grep/Glob toward ctx_search/ctx_glob (naming the ToolSearch load step), including subagents and unflagged sessions; otherwise allow with a one-time nudge. Allowed native calls still hit the armed inline-op threshold deny; nudge Read/Bash once per session toward ctx_read and ctx_shell/context-mode ctx_execute (markers: .atlas/.run/native_nudges/; ATLAS_TRIPWIRE_HARD=off). Flag orchestration sessions, count inline ops, advise at the threshold; deny tier blocks at 6 unsanctioned inline ops (the orchestrator's own docs//.atlas/ writes are excluded, since the completion gate requires them at closeout) or any non-docs edit in an orchestration run (ATLAS_TRIPWIRE=off, ATLAS_TRIPWIRE_HARD=off). Denies an atlas:* dispatch whose `mode
mod/register.ts 776 lines1// register.ts — the Atlas mod entrypoint (plan docs/plans/2026-10-09-atlas-mod.md §3).
2// Declared by hooks/hooks.json "modules": ["./mod/register.ts"]. Kill switch:
3// ATLAS_MOD=off turns every wired handler into a pass-through (register itself
4// has no `$`: the flag is read at the first event, session.start). Harness
5// constraints honored: static imports only, `$` flows only through top-level
6// `function` declarations and handler parameters, and no ui.* call runs unless
7// a person is at the prompt (hooks also run headless, surface null).
8import { atom, read, update } from 'claude-code';
9import type { EngineInterface, On, PluginOptions } from 'claude-code';
10import { SnapshotBuilder } from './snapshot';
11import type { AgentState, AtlasSnapshot, FsLike, PhaseId, SquadAgent } from './contract';
12import { PHASES, intentToArgv } from './intents';
13import { bandTree, railPhase, type BandHandlers } from './band';
14import { activeCount, liveWorkers } from './data/live';
15import { sidebarTree, type GateGroup, type SidebarHandlers } from './pane/sidebar';
16import { gatesError, headerGroup, parseGates } from './gates';
17import { registerRouting, registerCompletion } from './routing';
18import { registerDiscipline } from './discipline';
19import { PHASE_RE, registerRestyle } from './restyle';
20import { registerNotify } from './notify';
21import { ownRows, paneExit, paneTool, parseMuxStatus, teamsOf } from './data/mux';
22import type { TeamTool } from './orchestra-types';
23import { EMPTY_ORCH, onComplete, parseWt, pushCall, type SpawnMeta, type WtRow } from './data/orchestration';
24
25const REBUILD_MS = 1500;
26const WT_MS = 5000;
27const MUX_MS = 5000;
28const HERDR_MS = 5000;
29const GATES_MS = 30_000;
30const PANE_ID = 'atlas';
31// The auto open (first render with a viewport) is docked only and asks no keyboard; /atlas-cc opens a
32// 12-row pane on demand, takes focus, and Esc closes it.
33const PANE_DOCK = { id: PANE_ID, title: 'Atlas', columns: 56 } as const;
34const PANE_ON_DEMAND = { id: PANE_ID, title: 'Atlas', rows: 12, closeOnEscape: true, focus: true } as const;
35const TICK_MS = 250;
36const DOCK_MIN_COLUMNS = 144; // the engine places an unasked pane only from 144 columns (UiOpenResult); below that it waits undrawn
37
38/** The pane opens by itself only docked: a fullscreen terminal wide enough to leave the transcript its room. */
39export const shouldDock = (v: { isFullscreen?: boolean; columns: number }): boolean => v.isFullscreen === true && v.columns >= DOCK_MIN_COLUMNS;
40
41/** Toast why a pane waits undrawn, once per open call. */
42const toastUnplaced = ($: EngineInterface) => (r: { isPlaced: boolean; reason?: string }): void => {
43 if (!r.isPlaced && r.reason) $.ui.toast(`atlas: ${r.reason}`, { timeoutMs: 6000 });
44};
45
46// ---- session state ($.state: per-session, versioned, survives module reload) --
47// Contract in types/index.d.ts. Render hooks read these (a write redraws exactly
48// the readers); only handlers and ticks write, never a render hook. The scan
49// that validates `$.state` keys needs each atom declared in the file that reads
50// it, so routing.ts, notify.ts and restyle.tsx re-declare the few they read.
51// usage: null = 'not measured yet' (rendered `--`); 0 stays a legitimate value.
52
53const isModOn = atom({ plugin: 'atlas', key: 'isModOn' } as const, true); // ATLAS_MOD kill switch, set at session.start
54const isInteractive = atom({ plugin: 'atlas', key: 'isInteractive' } as const, false);
55const snapshotState = atom({ plugin: 'atlas', key: 'snapshot' } as const, null);
56const usageState = atom({ plugin: 'atlas', key: 'usage' } as const, { tokens: null, contextPct: null });
57const taskAgentsState = atom({ plugin: 'atlas', key: 'taskAgents' } as const, []);
58const orchState = atom({ plugin: 'atlas', key: 'orch' } as const, EMPTY_ORCH);
59const headerPhaseState = atom({ plugin: 'atlas', key: 'headerPhase' } as const, null);
60const paneOpenedState = atom({ plugin: 'atlas', key: 'paneOpened' } as const, false); // herdrTick stays idle until the pane has been opened
61// Sidebar UI state and gate data: handlers write, the Pane render hook reads.
62const recipientState = atom({ plugin: 'atlas', key: 'sidebarRecipient' } as const, null);
63const expandedState = atom({ plugin: 'atlas', key: 'sidebarExpanded' } as const, null);
64const draftState = atom({ plugin: 'atlas', key: 'sidebarDraft' } as const, '');
65const spinnerTickState = atom({ plugin: 'atlas', key: 'spinnerTick' } as const, 0); // written by restyle's ticker; renders only read it
66const lastHeaderState = atom({ plugin: 'atlas', key: 'lastHeader' } as const, null);
67const gatesState = atom({ plugin: 'atlas', key: 'gates' } as const, []);
68const viewState = atom({ plugin: 'atlas', key: 'atlasView' } as const, 'route'); // docs and the tab strip list route first
69const todoExpandedState = atom({ plugin: 'atlas', key: 'todoExpanded' } as const, null as boolean | null); // pane key `o` override; null follows ATLAS_TODO_EXPANDED
70const todoExpanded = async ($: EngineInterface): Promise<boolean> => (await read($, todoExpandedState)) ?? (await $.env.get('ATLAS_TODO_EXPANDED').catch(() => undefined)) === '1';
71
72// ---- module caches left out of $.state ---------------------------------------
73// builder: a class instance with methods, not JSON (a reload leaves it null until
74// the next session.start). herdrStdout / lastFingerprint: derived perf caches
75// that refill on the next tick. gatesBusy: a run of atlas_gates.py is in flight.
76// pendingDrift: a transient queue drained by the next handler that has `$`.
77let builder: SnapshotBuilder | null = null;
78let herdrStdout = '';
79let lastFingerprint = '';
80let wtAt = -Infinity; // last atlas_wt.py status run (ms clock)
81let wtRows: WtRow[] = [];
82let lastRailPhase: string | null = null; // last phase the band lit, kept through a blocked reply
83let muxStdout = '';
84let muxAgents: (SquadAgent & TeamTool)[] = [];
85let muxFirstSeen: Record<string, number> = {}; // team worker name -> first mux sight (ms), its lane's start
86let muxCalls: Record<string, { uses: number; calls: number[] }> = {}; // team worker name -> pane-log tool_use count and call times (sparkline)
87let sessionUuid = '';
88/** raw host agent id -> display name, kept across rebuilds: a finished agent drops out of the host list but its id must keep resolving. */
89let nameMemo: Record<string, string> = {};
90let gatesBusy = false;
91let pendingDrift: string[] = [];
92let paneChecked = false; // the unasked pane open is decided once per session
93let tickTimer: { cancel(): void } | null = null;
94/** A spawn-recorded agent the host list does not (yet) contain is only presumed gone after this long. */
95const SPAWN_GRACE_MS = 15_000;
96
97// ---- pure helpers (no $) ----------------------------------------------------
98
99/** `<cwd>` basename: the folder half of `<folder>@<branch>`. */
100function folderOf(cwd: string): string {
101 const stripped = cwd.replace(/\/+$/, '');
102 const cut = stripped.lastIndexOf('/');
103 return cut < 0 ? stripped : stripped.slice(cut + 1);
104}
105
106/** PhaseId the answer's header first line pins (`ATLAS | <glyph> <phase>[ n/m] | <state>`), else null. */
107export function headerPhaseOf(answer: string, snapshot: AtlasSnapshot | null): PhaseId | null {
108 const pattern = snapshot?.contract.headerFirstLinePattern ?? '';
109 if (pattern === '') return null;
110 const firstLine = answer.split('\n', 1)[0] ?? '';
111 let re: RegExp;
112 try {
113 re = new RegExp(pattern);
114 } catch {
115 return null;
116 }
117 if (!re.test(firstLine)) return null;
118 const phase = PHASE_RE.exec(firstLine)?.[1];
119 return phase !== undefined && (PHASES as readonly string[]).includes(phase) ? (phase as PhaseId) : null;
120}
121
122/** AgentStatus (d.ts EngineInterface $.agent.list) -> band AgentState. */
123const LIST_STATE: Record<string, AgentState> = {
124 pending: 'spawning',
125 running: 'running',
126 waiting: 'input',
127 idle: 'idle',
128 completed: 'finished',
129 failed: 'failed',
130 killed: 'dead',
131};
132
133/** Host statuses that end a worker, as the turn.complete reason its report is filed under. */
134const HOST_DONE: Record<string, string> = { completed: 'answer', failed: 'error', killed: 'error' };
135
136/** Readable persona label for a host agent type (`atlas:explorer` -> explorer). */
137const typeLabel = (type: string): string => type.replace(/^atlas:/, '').toLowerCase();
138
139/** First 3 words of an Agent call's description as a label (`list the mod pane` -> `list-the-mod`); '' when none. */
140const slugOf = (task: string | undefined): string =>
141 (task ?? '').toLowerCase().match(/[a-z0-9]+/g)?.slice(0, 3).join('-') ?? '';
142
143type ListedRow = { id: string; teammateId?: string; description?: string; type?: string; status: string };
144
145/** One squad row per host list row, keyed by the row's unique agent id (never by position). `taken` is the
146 * id -> label memo of earlier rebuilds: a label another id already holds gets this id's suffix, so a worker
147 * that dropped out of the list can never share a name (and so an activity/lifecycle key) with a new one. */
148export function mapListed(
149 listed: readonly ListedRow[],
150 taskAgents: readonly SquadAgent[],
151 meta: Record<string, SpawnMeta> = {},
152 taken: Record<string, string> = {},
153): { id: string; agent: SquadAgent }[] {
154 const labels = new Map<string, number>();
155 return listed.map((a) => {
156 const recorded = taskAgents.find((t) => t.name === a.id);
157 const spawned = meta[a.id];
158 const persona = personaOf(a, recorded, spawned);
159 const task = (typeof a.description === 'string' && a.description !== '' ? a.description : recorded?.task) || undefined;
160 const base = baseLabel(a, spawned, persona, task);
161 const n = (labels.get(base) ?? 0) + 1;
162 labels.set(base, n);
163 const heldByOther = Object.entries(taken).some(([id, label]) => id !== a.id && label === base);
164 return {
165 id: a.id,
166 agent: {
167 name: n === 1 && !heldByOther ? base : `${base}-${a.id.slice(0, 4)}`,
168 persona,
169 state: LIST_STATE[a.status] ?? 'running',
170 source: 'task' as const,
171 ...(task !== undefined ? { task } : {}),
172 },
173 };
174 });
175}
176
177/** Label for a host agent type; '' for a missing type or the generic `teammate`. */
178const listedLabel = (type: string | undefined): string => (typeof type === 'string' && type !== 'teammate' ? typeLabel(type) : '');
179
180/** First non-empty text, else ''. */
181const firstText = (...parts: (string | undefined)[]): string => parts.find((p) => !!p) ?? '';
182
183const GENERIC_PERSONAS = new Set(['unknown', 'general-purpose']);
184
185const recordedPersona = (recorded: SquadAgent | undefined): string => (recorded && recorded.persona !== 'unknown' ? recorded.persona : '');
186
187/** Persona label for one listed row. The type recorded at agent.spawn (`atlas:explorer`) is the source of truth; the name-keyed record and the list's own type are fallbacks. */
188function personaOf(a: ListedRow, recorded: SquadAgent | undefined, spawned: SpawnMeta | undefined): string {
189 return firstText(listedLabel(spawned?.type), recordedPersona(recorded), listedLabel(a.type), 'unknown');
190}
191
192const teammateName = (teammateId: string | undefined): string | undefined => teammateId?.split('@')[0];
193
194/** Display label before duplicate-suffixing: spawn name, teammate name, a specific persona, the task slug, then weaker fallbacks. */
195function baseLabel(a: ListedRow, spawned: SpawnMeta | undefined, persona: string, task: string | undefined): string {
196 return firstText(
197 spawned?.name,
198 teammateName(a.teammateId),
199 GENERIC_PERSONAS.has(persona) ? '' : persona,
200 slugOf(task),
201 persona === 'unknown' ? '' : persona,
202 listedLabel(a.type),
203 a.id.slice(0, 6),
204 );
205}
206
207/** Sync the squad's task agents with the host's own agent list (ground truth,
208 * catches spawns the agent.spawn hook missed); spawn-recorded entries not yet
209 * listed stay as the instant fallback. The host id (`a142cc7b…`) is never the
210 * display name: persona recorded at spawn, else the agent type, labels the row and
211 * the Agent call's description is its task; the id shows (6 chars) only as a last
212 * resort and as a suffix to keep duplicate labels distinct. Liveness is the host list's
213 * status alone (the lead being idle says nothing: background agents outlive its turn); a
214 * recorded entry the host does not list is finished only once its spawn (meta.at) is older
215 * than SPAWN_GRACE_MS at `now`. Never throws. */
216export function syncAgentsFromList(
217 listed: readonly ListedRow[],
218 taskAgents: readonly SquadAgent[],
219 meta: Record<string, SpawnMeta> = {},
220 now?: number,
221 taken: Record<string, string> = {},
222): SquadAgent[] {
223 const known = new Set(listed.map((a) => a.id));
224 const live: readonly string[] = ['spawning', 'running', 'idle', 'input', 'stuck'];
225 const rest = taskAgents
226 .filter((t) => !known.has(t.name))
227 .map((t) => {
228 const at = meta[t.name]?.at;
229 return now !== undefined && at !== undefined && now - at > SPAWN_GRACE_MS && live.includes(t.state) ? { ...t, state: 'finished' as const } : t;
230 });
231 return [...mapListed(listed, taskAgents, meta, taken).map((m) => m.agent), ...rest].slice(-64);
232}
233
234// ---- $-carrying top-level functions ----------------------------------------
235
236/** FsLike adapter over the engine's `$.fs` (missing path => undefined, never throws). */
237function engineFs($: EngineInterface): FsLike {
238 return {
239 read: async (path) => $.fs.read(path).catch(() => undefined),
240 stat: async (path) => {
241 const s = await $.fs.stat(path).catch(() => undefined);
242 return s === undefined ? undefined : { size: s.size, mtimeMs: s.mtimeMs };
243 },
244 exists: (path) => $.fs.exists(path),
245 list: async (path) => (await $.fs.list(path)).map((entry) => entry.name),
246 };
247}
248
249/** `<path>` signature `mtime:size`, '-' when missing. */
250async function statSig($: EngineInterface, path: string): Promise<string> {
251 try {
252 const s = await $.fs.stat(path);
253 return `${s.mtimeMs}:${s.size}`;
254 } catch {
255 return '-';
256 }
257}
258
259/** Todos + channels + board note-file fingerprint: what a rebuild is due on. */
260async function dataFingerprint($: EngineInterface): Promise<string> {
261 // The host's agent list moves without any data file changing (a subagent
262 // finishing writes nothing), so its id:status pairs belong in the fingerprint,
263 // or the pane keeps showing a finished agent as running.
264 const agentSig = await Promise.resolve()
265 .then(() => $.agent.list())
266 // A running subagent's transcript tail moves with no data file: re-bucket every 5 s while any runs.
267 .then(async (rows) => `${rows.map((a) => `${a.id}:${a.status}`).join(',')}${rows.some((a) => a.status === 'running') ? `@${Math.floor((await $.clock.now()) / MUX_MS)}` : ''}`)
268 .catch(() => '-');
269 // Usage, header phase and header misses move on session.measure / turn.complete
270 // with no file change; they feed the snapshot, so they belong here too.
271 const liveSig = `${JSON.stringify(await read($, usageState))}:${await read($, headerPhaseState)}`;
272 // Tool calls, moods and comms move on events with no file change; a 5 s bucket
273 // keeps the elapsed counters fresh while anything is working.
274 const orch = await read($, orchState);
275 const acts = Object.entries(orch.activity);
276 const orchSig = `${orch.comms.length}:${acts.map(([k, a]) => `${k}=${a.tool ?? ''}${a.toolCount}${a.mood}`).join(',')}:${acts.some(([, a]) => a.tool !== null) ? Math.floor((await $.clock.now()) / 5000) : 0}`;
277 const root = (await read($, snapshotState))?.root;
278 if (root === undefined || root === null || root === '') return `no-root|${agentSig}|${liveSig}`;
279 const base = `${root.replace(/\/+$/, '')}/.atlas/.run`;
280 const parts = [agentSig, liveSig, orchSig, await statSig($, `${base}/todos.json`), await statSig($, `${base}/channels.json`), await statSig($, `${base}/findings.json`)];
281 try {
282 const files = await $.fs.list(`${base}/board`);
283 for (const file of files) parts.push(`${file.name}=${await statSig($, `${base}/board/${file.name}`)}`);
284 } catch {
285 parts.push('no-board');
286 }
287 return parts.join('|');
288}
289
290/** `atlas_wt.py status` rows, re-run at most every WT_MS (git is the slow part). */
291async function worktrees($: EngineInterface, now: number): Promise<WtRow[]> {
292 if (now - wtAt >= WT_MS) {
293 wtAt = now;
294 const root = (await read($, snapshotState))?.root;
295 const out = root ? await $.process.run(['python3', `${$.plugin.root}/scripts/atlas_wt.py`, '--root', root, 'status']).catch(() => undefined) : undefined;
296 wtRows = out?.exitCode === 0 ? parseWt(out.stdout) : [];
297 }
298 return wtRows;
299}
300
301/** Snapshot root, '' when no board is in view. */
302const rootOf = (snap: AtlasSnapshot | null): string => snap?.root || '';
303
304/** Rebuild the snapshot from disk + live state; never throws. */
305async function rebuild($: EngineInterface): Promise<void> {
306 if (builder === null) return;
307 let listed: { id: string; status: string }[] = [];
308 const names: Record<string, string> = {};
309 try {
310 const rows = await $.agent.list();
311 listed = rows.map((a) => ({ id: a.id, status: a.status }));
312 const { meta } = await read($, orchState);
313 const spawnNow = await $.clock.now();
314 await update($, taskAgentsState, (agents) => {
315 // the same rows the squad gets, keyed by host id: a label belongs to one id, never to a list position
316 for (const m of mapListed(rows, agents, meta, nameMemo)) names[m.id] = m.agent.name;
317 return syncAgentsFromList(rows, agents, meta, spawnNow, nameMemo);
318 });
319 Object.assign(nameMemo, names);
320 // A worker the host lists as finished whose own turn.complete never reached us (or carried no
321 // agentId): report it by the host row's id, never by guessing a running worker.
322 const gone = rows.filter((a) => HOST_DONE[a.status] !== undefined);
323 if (gone.length > 0) {
324 await update($, orchState, (o) =>
325 gone.reduce(
326 (acc, a) =>
327 acc.stage[a.id] === undefined || acc.stage[a.id] === 'reported'
328 ? acc
329 : onComplete(acc, a.id, { reason: HOST_DONE[a.status] ?? 'answer', answer: '', tokens: 0 }, spawnNow),
330 o,
331 ),
332 );
333 }
334 } catch {
335 // no agent list in this harness: keep spawn-recorded agents
336 }
337 try {
338 const now = await $.clock.now();
339 const snap = await builder.build({
340 headerPhase: await read($, headerPhaseState),
341 taskAgents: await read($, taskAgentsState),
342 mux: muxAgents,
343 herdrStdout,
344 usage: await read($, usageState),
345 now,
346 orch: await read($, orchState),
347 names: { ...nameMemo },
348 listed,
349 wt: await worktrees($, now),
350 });
351 // The snapshot write redraws the band, pane and restyles that read it.
352 await update($, snapshotState, () => snap);
353 } catch (err) {
354 $.ui.log(`atlas: rebuild failed: ${String(err)}`, { to: 'debug' });
355 // keep the last good snapshot
356 }
357}
358
359/** 1.5 s tick: rebuild only when the data files moved. */
360async function rebuildTick($: EngineInterface): Promise<void> {
361 if (builder === null) return;
362 const fingerprint = await dataFingerprint($);
363 if (fingerprint === lastFingerprint && (await read($, snapshotState)) !== null) return;
364 lastFingerprint = fingerprint;
365 await rebuild($);
366}
367
368/** 5 s tick: herdr pane status, only once the pane was opened. */
369async function herdrTick($: EngineInterface): Promise<void> {
370 if (!(await read($, paneOpenedState)) || (await read($, snapshotState)) === null) return;
371 const out = await $.process
372 .run(['python3', `${$.plugin.root}/scripts/atlas_herdr.py`, 'status'])
373 .catch(() => undefined);
374 if (out === undefined || out.stdout === herdrStdout) return;
375 herdrStdout = out.stdout;
376 await rebuild($);
377}
378
379/** 5 s tick: atlas_mux.py status of the lead channel's run (`--run` is the channel's last segment) and of each
380 * spawn-team team on that channel (a team registers under its team id); rebuilds when the output or a pane tool moved. */
381async function muxTick($: EngineInterface): Promise<void> {
382 const snap = await read($, snapshotState);
383 const root = rootOf(snap);
384 if (root === '' || snap === null) return;
385 // No early return on zero runs: a reaped team (record archived, no board channel) must clear its lanes, not freeze them.
386 const { runs, own } = await muxRuns($, root, snap);
387 const outs = await Promise.all(runs.map((r) => muxStatus($, root, r.id)));
388 const agents = await withPaneTools($, root, ownRows(outs.flatMap((o, i) => parseMuxStatus(o, runs[i]!.members)), own));
389 const sig = `${outs.join('\n')}|${agents.map((a) => `${a.tool ?? ''}${a.lastTool ?? ''}${a.target ?? ''}${a.uses ?? ''}`).join(',')}`;
390 if (sig === muxStdout) return;
391 muxStdout = sig;
392 muxAgents = agents;
393 await rebuild($);
394}
395
396/** `--root` is a `status` option: before the subcommand argparse rejects it (exit 2), which kept every mux row out. */
397async function muxStatus($: EngineInterface, root: string, run: string): Promise<string> {
398 const out = await $.process.run(['python3', `${$.plugin.root}/scripts/atlas_mux.py`, 'status', '--run', run, '--root', root]).catch(() => undefined);
399 return out?.exitCode === 0 ? out.stdout : '';
400}
401
402/** Status runs: the board channel's, plus each `.atlas/.run/teams/<id>.json` team on the board channel or on this
403 * session's lead channel (`<folder>@<branch>/lead-<session6>`, which the board channel need not be). `own`: the
404 * worker names this session may show (team members and board members). */
405async function muxRuns($: EngineInterface, root: string, snap: AtlasSnapshot): Promise<{ runs: { id: string; members: string[] }[]; own: Set<string> }> {
406 const dir = `${root.replace(/\/+$/, '')}/.atlas/.run/teams`;
407 const names = (await $.fs.list(dir).catch(() => [])).map((e) => e.name).filter((n) => n.endsWith('.json'));
408 const records = await Promise.all(names.map(async (name) => ({ name, text: (await $.fs.read(`${dir}/${name}`).catch(() => undefined)) ?? '' })));
409 const board = snap.channel ?? '';
410 const teams = teamsOf(records, [board, `lead-${snap.sessionId.slice(0, 6)}`]);
411 const runs = [...(board ? [{ id: board.split('/').pop() ?? board, members: [] }] : []), ...teams];
412 return { runs, own: new Set([...teams.flatMap((t) => t.members), ...snap.members.map((m) => m.name)]) };
413}
414
415/** Tool uses that appeared in a pane log since the last read become calls at `now` (the first read is the baseline). */
416function paneCalls(name: string, uses: number, now: number): number[] {
417 const prev = (muxCalls[name] ??= { uses, calls: [] });
418 for (let n = prev.uses; n < uses; n++) prev.calls = pushCall(prev.calls, now);
419 prev.uses = uses;
420 return prev.calls;
421}
422
423/** Running team workers get their tool / last tool / target from their pane log (the pane feed writes stream-json there)
424 * and a first-seen start, so the lane's age counts up. ponytail: reads each running worker's whole log every MUX_MS;
425 * tail-read if logs grow past a few MB. */
426async function withPaneTools($: EngineInterface, root: string, agents: SquadAgent[]): Promise<(SquadAgent & TeamTool)[]> {
427 const logs = `${root.replace(/\/+$/, '')}/.atlas/.run/logs`;
428 const now = await $.clock.now();
429 return Promise.all(
430 agents.map(async (a) => {
431 const startedAt = (muxFirstSeen[a.name] ??= now);
432 if (a.state !== 'running') return a;
433 const log = (await $.fs.read(`${logs}/${a.name}.log`).catch(() => undefined)) ?? '';
434 const pane = paneTool(log);
435 return { ...a, state: paneExit(log) ?? a.state, startedAt, ...pane, calls: paneCalls(a.name, pane.uses ?? 0, now) };
436 }),
437 );
438}
439
440/** Current branch of `cwd` (fall back to 'main' outside a repo). */
441async function gitBranch($: EngineInterface, cwd: string): Promise<string> {
442 try {
443 const out = await $.process.run(['git', '-C', cwd, 'rev-parse', '--abbrev-ref', 'HEAD']);
444 const branch = out.stdout.trim();
445 return branch === '' ? 'main' : branch;
446 } catch {
447 return 'main';
448 }
449}
450
451/** Gate groups from one atlas_gates.py run; a nonzero exit or bad JSON becomes a single error group. */
452function groupsFromRun(out: { exitCode: number; stdout: string; stderr: string }): GateGroup[] {
453 try {
454 if (out.exitCode !== 0) throw new Error(out.stderr.trim().split('\n').pop() || `exit ${out.exitCode}`);
455 return parseGates(out.stdout);
456 } catch (err) {
457 return [gatesError(err instanceof Error ? err.message : String(err))];
458 }
459}
460
461/** Run atlas_gates.py and store its groups; a missing script, nonzero exit or bad JSON becomes an error group. Skips when a run is in flight. */
462async function runGates($: EngineInterface): Promise<void> {
463 if (gatesBusy || sessionUuid === '') return;
464 const root = rootOf(await read($, snapshotState));
465 if (root === '') return;
466 gatesBusy = true;
467 try {
468 const out = await $.process
469 .run(['python3', `${$.plugin.root}/scripts/atlas_gates.py`, '--session', sessionUuid, '--root', root, '--json'])
470 .catch((err: unknown) => ({ exitCode: -1, stdout: '', stderr: String(err) }));
471 const groups = groupsFromRun(out);
472 await update($, gatesState, () => groups);
473 } finally {
474 gatesBusy = false;
475 }
476}
477
478/** Board root and channel for the note intent. */
479const noteContext = (snapshot: AtlasSnapshot | null): { root: string; channel: string | null } => ({ root: rootOf(snapshot), channel: snapshot?.channel ?? null });
480
481/** Argv for posting `note` to `who` through the note intent, or the toast text explaining why not. */
482function noteArgv($: EngineInterface, who: string, note: string, snapshot: AtlasSnapshot | null): { argv: string[] } | { error: string } {
483 const ctx = noteContext(snapshot);
484 const res = intentToArgv({ t: 'note', channel: ctx.channel ?? '', to: who, text: note }, { pluginRoot: $.plugin.root, ...ctx });
485 if (!res.ok) return { error: res.error };
486 return res.kind === 'cli' ? { argv: res.argv } : { error: 'no board in view' };
487}
488
489/** Post the draft to the selected member (the lead when none) through the same note intent /atlas-say uses. */
490async function sendNote($: EngineInterface, text: string): Promise<void> {
491 const note = text.trim();
492 if (note === '') return;
493 const who = (await read($, recipientState)) ?? 'lead';
494 const built = noteArgv($, who, note, await read($, snapshotState));
495 if ('error' in built) {
496 $.ui.toast(`atlas: ${built.error}`, { timeoutMs: 6000 });
497 return; // the draft stays
498 }
499 const out = await $.process.run(built.argv);
500 if (out.exitCode !== 0) {
501 $.ui.toast(`atlas: ${out.stderr.trim() || `exit ${out.exitCode}`}`, { timeoutMs: 6000 });
502 return;
503 }
504 await update($, draftState, () => '');
505 $.ui.toast(`Sent to ${who}`, { timeoutMs: 3000 });
506 void rebuild($);
507}
508
509/** Run a handler's async work, reporting a failure as a toast instead of an unhandled rejection. */
510const guardedBy = ($: EngineInterface) => (fn: () => Promise<unknown>): void => {
511 fn().catch((err: unknown) => $.ui.toast(`atlas: ${String(err)}`, { timeoutMs: 6000 }));
512};
513
514/** Closures for the sidebar view: each writes $.state (never the render hook) and reports failures as toasts. */
515function sidebarHandlers($: EngineInterface, surface: 'terminal'): SidebarHandlers {
516 const guarded = guardedBy($);
517 return {
518 selectRecipient: (name) => guarded(() => update($, recipientState, (cur) => (cur === name ? null : name))),
519 toggle: (key) => guarded(() => update($, expandedState, (cur) => (cur === key ? null : key))),
520 draftChange: (text) => guarded(() => update($, draftState, () => text)),
521 send: (text) => guarded(() => sendNote($, text)),
522 focus: (key) => guarded(() => $.ui.focus({ requestId: PANE_ID, key })),
523 setView: (v) => guarded(() => update($, viewState, () => v)),
524 toggleTodos: () => guarded(async () => { const open = await todoExpanded($); await update($, todoExpandedState, () => !open); }),
525 expand: (key) =>
526 guarded(async () => {
527 await update($, expandedState, () => key);
528 await $.ui.focus({ requestId: PANE_ID, key });
529 }),
530 copy: (text) =>
531 guarded(async () => {
532 const res = await $.ui.copy({ text, surface });
533 $.ui.toast(res.isCopied ? 'Copied' : 'Copy failed', { timeoutMs: 3000 });
534 }),
535 };
536}
537
538/** Band footer presses: open (or focus) the pane on a view, as /atlas-cc does, or fold the board. */
539function bandHandlers($: EngineInterface): BandHandlers {
540 const guarded = guardedBy($);
541 return {
542 openView: (v) =>
543 guarded(async () => {
544 // Open first, before any await: the engine counts the open as asked (placed at any width) only while the press is behind it.
545 const opened = $.ui.open(PANE_ON_DEMAND);
546 await update($, viewState, () => v);
547 await update($, paneOpenedState, () => true);
548 toastUnplaced($)(await opened);
549 }),
550 toggleTodos: () => guarded(async () => { const open = await todoExpanded($); await update($, todoExpandedState, () => !open); }),
551 };
552}
553
554/** Script groups plus the header group built from the last answer. */
555async function gateGroups($: EngineInterface): Promise<GateGroup[]> {
556 return [...(await read($, gatesState)).filter((g) => g.id !== 'header'), headerGroup(await read($, lastHeaderState))];
557}
558
559// ---- entrypoint -------------------------------------------------------------
560
561export async function register(on: On, options: PluginOptions): Promise<void> {
562 // Sibling registrars, wired by their real signatures. Persona routing pins
563 // model/effort and records spawned agents and turn completions; the
564 // prompt reinforce line reads the live contract; restyles and turn-boundary
565 // notifications read the snapshot from $.state themselves. (register has no
566 // `$`, so drift logs queue and a handler drains them.)
567 registerRouting(on, {
568 getPersonas: (snapshot) => snapshot?.personas ?? [],
569 logDrift: (msg) => {
570 pendingDrift.push(msg);
571 },
572 });
573 registerCompletion(on);
574 registerDiscipline(on, {
575 reinforce: (snapshot) =>
576 snapshot?.contract.headerFirstLinePattern
577 ? `atlas contract: open replies with a header line matching ${snapshot.contract.headerFirstLinePattern} (phase: ${snapshot.phase})`
578 : null,
579 });
580 registerRestyle(on); // its turn.start also drops the previous turn's header phase
581 registerNotify(on, { soundEnabled: () => options.atlas_mod_sound === true });
582
583 // Session boot: read the kill switch, resolve the board, build the first
584 // snapshot, start timers and register commands — interactive sessions only;
585 // headless (-p / SDK) stays inert.
586 on('session.start', async ($, e, next) => {
587 try {
588 const modOn = (await $.env.get('ATLAS_MOD').catch(() => undefined)) !== 'off';
589 await update($, isModOn, () => modOn);
590 // A fresh session is unmeasured again, whatever an earlier one measured,
591 // and its agents are its own.
592 await update($, usageState, () => ({ tokens: null, contextPct: null }));
593 await update($, taskAgentsState, () => []);
594 await update($, paneOpenedState, () => false);
595 paneChecked = false;
596 if (!modOn) return next(e);
597 const interactive = e.isInteractive;
598 await update($, isInteractive, () => interactive);
599 builder = new SnapshotBuilder(engineFs($), $.plugin.root);
600 const sessionId = await $.session.id();
601 sessionUuid = sessionId;
602 nameMemo = {};
603 muxStdout = '';
604 muxAgents = [];
605 muxFirstSeen = {};
606 muxCalls = {};
607 const envRoot = await $.env.get('ATLAS_PROJECT_ROOT');
608 await builder.init(e.cwd, { ATLAS_PROJECT_ROOT: envRoot }, sessionId, folderOf(e.cwd), await gitBranch($, e.cwd));
609 await rebuild($);
610 if (interactive) {
611 await $.command.register({ name: 'atlas-cc', description: 'Open and focus the Atlas sidebar' });
612 await $.command.register({ name: 'atlas-say', description: 'Post a note to the lead channel', argumentHint: '<message>' });
613 await $.command.register({ name: 'atlas-todo', description: 'Add a todo to the board', argumentHint: '<text>' });
614 $.clock.every(REBUILD_MS, () => {
615 void rebuildTick($);
616 });
617 $.clock.every(HERDR_MS, () => {
618 void herdrTick($);
619 });
620 $.clock.every(MUX_MS, () => {
621 void muxTick($);
622 });
623 $.clock.every(GATES_MS, () => {
624 void runGates($);
625 });
626 void runGates($);
627 // The tick only runs while a worker is live, so an idle session redraws nothing.
628 tickTimer?.cancel();
629 tickTimer = $.clock.every(TICK_MS, () => {
630 void (async () => {
631 const snap = await read($, snapshotState);
632 if (snap && activeCount(liveWorkers(snap, Date.now())) > 0) await update($, spinnerTickState, (n) => n + 1);
633 })().catch(() => undefined);
634 });
635 }
636 } catch (err) {
637 // a failed boot must not block the session
638 $.ui.log(`atlas: boot failed: ${String(err)}`, { to: 'debug' });
639 }
640 return next(e);
641 });
642
643 // Usage figures (tokens / context fill), pushed after each turn.
644 on('session.measure', async ($, e, next) => {
645 if (await read($, isModOn)) {
646 await update($, usageState, (usage) => ({
647 tokens: e.context.tokens ?? usage.tokens,
648 contextPct: e.context.percent ?? usage.contextPct,
649 }));
650 }
651 return next(e);
652 });
653
654 // Header phase from the last main-loop ANSWER (reason 'answer': the matcher
655 // keeps this a distinct registration beside routing.ts's un-matchered
656 // turn.complete drift counter); task agents finish per turn; queued
657 // routing-drift lines reach the debug log here, where `$` exists.
658 on('turn.complete', { reason: 'answer' }, async ($, e, next) => {
659 try {
660 if (!(await read($, isModOn))) return next(e);
661 for (const msg of pendingDrift.splice(0)) $.ui.log(msg, { to: 'debug' });
662 if (e.agentId === undefined) {
663 const phase = headerPhaseOf(e.answer, await read($, snapshotState));
664 await update($, headerPhaseState, () => phase);
665 void runGates($);
666 } else {
667 const agentId = e.agentId;
668 await update($, taskAgentsState, (agents) =>
669 agents.map((agent) => (agent.name === agentId || agent.name === nameMemo[agentId] ? { ...agent, state: 'finished' as const } : agent)),
670 );
671 }
672 } catch {
673 // a drift check never blocks the answer
674 }
675 return next(e);
676 });
677
678 // The band above the prompt: bordered section (terminal + interactive only).
679 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
680 if (e.surface !== 'terminal' || e.props.hasSurvey || !(await read($, isModOn)) || !(await read($, isInteractive))) return next(e);
681 // Unasked, the pane docks only beside a fullscreen transcript wide enough for it; checked once, at the first render that knows the viewport.
682 if (!paneChecked && e.viewport) {
683 paneChecked = true;
684 if (shouldDock(e.viewport)) {
685 await update($, paneOpenedState, () => true);
686 void $.ui.open(PANE_DOCK).then(toastUnplaced($));
687 }
688 }
689 const snapshot = await read($, snapshotState);
690 if (snapshot === null) return next(e);
691 const gates = await gateGroups($);
692 lastRailPhase = railPhase(snapshot, lastRailPhase); // a blocked reply keeps the phase lit before it
693 const band = bandTree($.ui.resolve(e), { snapshot, live: liveWorkers(snapshot, Date.now()), gates, columns: e.props.bodyColumns, tick: await read($, spinnerTickState), maxRows: e.props.maxRows, lastPhase: lastRailPhase, todoExpanded: await todoExpanded($) }, bandHandlers($));
694 return $.ui.resolve(e).Box({ flexDirection: 'column', flexShrink: 0, children: [band, await next(e)] });
695 });
696
697 // The Atlas sidebar: a native tree, so its elements join the engine's focus ring.
698 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
699 if (e.surface !== 'terminal' || e.requestId !== PANE_ID || !(await read($, isModOn))) return next(e);
700 const snapshot = await read($, snapshotState);
701 if (snapshot === null) return next(e);
702 return sidebarTree($.ui.resolve(e), {
703 snapshot,
704 gates: await gateGroups($),
705 ui: { recipient: await read($, recipientState), expanded: await read($, expandedState), draft: await read($, draftState), view: await read($, viewState) },
706 columns: e.props.bodyColumns,
707 rows: e.props.scroll.bodyRows,
708 tick: await read($, spinnerTickState),
709 }, sidebarHandlers($, e.surface));
710 });
711
712 // A tree this plugin drew was refused: surface it in the debug log.
713 on('ui.fault', ($, e, next) => {
714 $.ui.log(`atlas: ${e.component} ${e.module} ${e.phase}: ${e.reason}`, { to: 'debug' });
715 return next(e);
716 });
717
718 // /atlas-cc — open the sidebar and give it the keyboard; it stays until the person closes it.
719 on('command.run', { command: 'atlas-cc' }, async ($) => {
720 await update($, paneOpenedState, () => true);
721 toastUnplaced($)(await $.ui.open(PANE_ON_DEMAND));
722 void rebuild($);
723 return {};
724 });
725
726 // Session over: the spinner clock stops.
727 on('session.end', (_$, e, next) => {
728 tickTimer?.cancel();
729 tickTimer = null;
730 return next(e);
731 });
732
733 // The person closed the pane (Esc or the close mark): /atlas-cc opens it again next time.
734 on('ui.close', async ($, e, next) => {
735 if (e.id === PANE_ID) {
736 await update($, paneOpenedState, () => false);
737 await update($, viewState, () => 'route'); // reopen on the first tab, not the last one used
738 }
739 return next(e);
740 });
741
742 // /atlas-say <message> — post a note to the lead channel.
743 on('command.run', { command: 'atlas-say' }, async ($, e) => {
744 const text = e.args.trim();
745 if (text === '') return { text: 'usage: /atlas-say <message>' };
746 const snapshot = await read($, snapshotState);
747 const res = intentToArgv(
748 { t: 'note', channel: snapshot?.channel ?? '', to: 'lead', text },
749 { pluginRoot: $.plugin.root, root: snapshot?.root ?? '', channel: snapshot?.channel ?? null },
750 );
751 if (!res.ok || res.kind !== 'cli') return { text: `atlas: ${res.ok ? 'no board in view' : res.error}` };
752 const out = await $.process.run(res.argv);
753 void rebuild($);
754 return { text: out.exitCode === 0 ? 'sent' : `atlas: ${out.stderr.trim() || `exit ${out.exitCode}`}` };
755 });
756
757 // /atlas-todo <text> — add a todo at the current phase.
758 on('command.run', { command: 'atlas-todo' }, async ($, e) => {
759 const text = e.args.trim();
760 if (text === '') return { text: 'usage: /atlas-todo <text>' };
761 const snapshot = await read($, snapshotState);
762 const out = await $.process.run([
763 'python3',
764 `${$.plugin.root}/scripts/atlas_todo.py`,
765 'add',
766 '--root',
767 snapshot?.root ?? '',
768 text,
769 '--phase',
770 snapshot?.phase ?? 'research',
771 ]);
772 void rebuild($);
773 return { text: out.exitCode === 0 ? 'added' : `atlas: ${out.stderr.trim() || `exit ${out.exitCode}`}` };
774 });
775}
776mod/snapshot.ts 159 lines1// SnapshotBuilder: composes the mod data-plane readers into one AtlasSnapshot.
2// Pure over FsLike (see contract.ts); register.ts feeds it $.fs + hook state.
3// Never throws: init and build fall back to a valid idle snapshot whenever the
4// underlying data is missing, unreadable or corrupt. Plan §2.
5import type {
6 AtlasSnapshot, ChannelMember, ChannelNote, Contract, FsLike, Persona, PhaseId, SquadAgent,
7} from './contract';
8import { boardRoot, leadChannelName } from './data/root';
9import { computeCounts, currentPhase, groupTodos, readTodos, type TodosCache } from './data/todos';
10import { NoteReader, readRoster } from './data/channels';
11import { loadContract, loadPersonas } from './data/personas';
12import { deriveOrchestration, EMPTY_ORCH, type Orch, type WtRow } from './data/orchestration';
13import type { LiveSnapshot } from './orchestra-types';
14import { mergeSquad, parseHerdrStatus } from './data/herdr';
15
16/** Contract stand-in when <pluginRoot>/contracts/operating-contract.json is missing/corrupt. */
17const EMPTY_CONTRACT: Contract = {
18 phases: [],
19 headerFirstLinePattern: '',
20 itemPhasePrefix: '[<phase>] ',
21 todoPhases: [],
22};
23
24/**
25 * Incremental AtlasSnapshot builder for one session. init() resolves the data
26 * root and loads the plugin-level assets once; every build() re-reads what
27 * changed (todos via an instance-held mtime cache, board notes via the
28 * NoteReader byte cursors) and merges the live hook state on top.
29 */
30export class SnapshotBuilder {
31 private root: string | null = null;
32 private sessionId = '';
33 private candidates: string[] = [];
34 private personas: Persona[] = [];
35 private contract: Contract = EMPTY_CONTRACT;
36 private channel: string | null = null;
37 private members: ChannelMember[] = [];
38 private notes: NoteReader | null = null;
39 private todoCache: TodosCache | undefined; // mtime cache kept on the instance across builds
40 private mergedAt: Record<string, number> = {}; // first time an agent was seen merged (stable comms ts)
41 private finishedAt: Record<string, number> = {}; // first time a worker was seen finished (frozen elapsed)
42
43 constructor(private readonly fs: FsLike, private readonly pluginRoot: string) {}
44
45 /**
46 * Resolve the Atlas root for `cwd` (ATLAS_PROJECT_ROOT override, else nearest
47 * ancestor with `.atlas`), load personas + operating contract, and find the
48 * lead channel from the session roster. Safe to call with no Atlas tree.
49 */
50 async init(
51 cwd: string,
52 env: { ATLAS_PROJECT_ROOT?: string },
53 sessionId: string,
54 folder: string,
55 branch: string,
56 ): Promise<void> {
57 this.sessionId = sessionId;
58 this.candidates = leadChannelName(sessionId, folder, branch);
59 this.root = await boardRoot(this.fs, cwd, env).catch(() => null);
60 try {
61 this.personas = await loadPersonas(this.fs, this.pluginRoot);
62 } catch {
63 this.personas = [];
64 }
65 try {
66 this.contract = await loadContract(this.fs, this.pluginRoot);
67 } catch {
68 this.contract = EMPTY_CONTRACT;
69 }
70 if (this.root !== null) {
71 const fs: FsLike = this.fs;
72 // NoteReader joins entries onto the board dir, so feed it bare names;
73 // memFs/real list() may return full paths (basename of a name is the name).
74 this.notes = new NoteReader(
75 { ...fs, list: async (dir) => (await fs.list(dir)).map((p) => p.slice(p.lastIndexOf('/') + 1)) },
76 this.root,
77 );
78 const roster = await readRoster(this.fs, this.root, this.candidates).catch(() => ({
79 channel: null as string | null,
80 members: [] as ChannelMember[],
81 }));
82 this.channel = roster.channel;
83 this.members = roster.members;
84 }
85 }
86
87 /** Rebuild every AtlasSnapshot field from disk state + live hook state. Never throws. */
88 async build(live: {
89 headerPhase: PhaseId | null;
90 taskAgents: SquadAgent[];
91 mux?: SquadAgent[]; // parseMuxStatus(atlas_mux.py status stdout)
92 herdrStdout?: string;
93 usage: { contextPct: number | null };
94 now: number;
95 orch?: Orch; // event-fed activity/comms/stages (register.ts reads the orch atom)
96 names?: Record<string, string>; // raw agent id -> squad display name
97 listed?: { id: string; status: string }[]; // $.agent.list
98 wt?: WtRow[]; // parseWt(atlas_wt.py status stdout)
99 }): Promise<LiveSnapshot> {
100 const orchestrate = (notes: ChannelNote[], squad: SquadAgent[]) =>
101 deriveOrchestration({
102 orch: live.orch ?? EMPTY_ORCH, names: live.names ?? {}, listed: live.listed ?? [], squad, notes,
103 members: this.members, leadNames: this.candidates, wt: live.wt ?? [],
104 now: live.now, mergedAt: this.mergedAt, finishedAt: this.finishedAt,
105 });
106 const passthrough = {
107 root: this.root,
108 sessionId: this.sessionId,
109 contract: this.contract,
110 personas: this.personas,
111 contextPct: live.usage.contextPct ?? 0,
112 now: live.now,
113 };
114 // No usable root (init never found one, or failed): idle snapshot.
115 if (this.root === null) {
116 return {
117 ...passthrough,
118 channel: null,
119 todos: [],
120 counts: computeCounts([], this.contract),
121 phase: 'research',
122 phaseSource: 'default',
123 notes: [],
124 members: [],
125 squad: [],
126 todoGroups: [],
127 ...orchestrate([], []),
128 };
129 }
130 // Re-read the roster so joins/leaves appear; keep the last good one on error.
131 const roster = await readRoster(this.fs, this.root, this.candidates).catch(() => undefined);
132 if (roster !== undefined) {
133 this.channel = roster.channel;
134 this.members = roster.members;
135 }
136 const memberNames = this.members.map((m) => m.name);
137 const read = await readTodos(this.fs, this.root, this.sessionId, memberNames, this.contract, this.todoCache);
138 this.todoCache = read.cache;
139 const notes: ChannelNote[] = this.channel !== null && this.notes !== null
140 ? await this.notes.read(this.channel)
141 : [];
142 const { panes } = parseHerdrStatus(live.herdrStdout ?? '');
143 const phase = currentPhase(read.items, live.headerPhase);
144 const squad = mergeSquad(this.members, notes, panes, [...live.taskAgents, ...(live.mux ?? [])], live.now);
145 return {
146 ...orchestrate(notes, squad),
147 ...passthrough,
148 channel: this.channel,
149 todos: read.items,
150 counts: computeCounts(read.items, this.contract),
151 phase: phase.phase,
152 phaseSource: phase.source,
153 notes,
154 members: this.members,
155 squad,
156 todoGroups: groupTodos(read.items, this.contract, this.sessionId),
157 };
158 }
159}mod/contract.ts 107 lines1// Shared contract for the Atlas Claude Code mod. EVERY mod file imports types from here.
2// Do not change signatures without telling the lead. Reference API types:
3// plugins/atlas/.claude-plugin/types/claude-code/index.d.ts (generated, gitignored).
4// Plan: docs/plans/2026-10-09-atlas-mod.md
5
6export type PhaseId = 'research' | 'theory' | 'test' | 'validate' | 'implement' | 'verify' | 'done' | 'blocked';
7
8export interface Phase { id: PhaseId; glyph: string }
9
10export interface Contract {
11 phases: Phase[];
12 todoPhases: PhaseId[];
13 headerFirstLinePattern: string;
14 itemPhasePrefix: string; // e.g. "[<phase>] "
15}
16
17export interface TodoItem {
18 id: string;
19 content: string;
20 status: 'pending' | 'in_progress' | 'completed' | string;
21 owner?: string;
22 session_id?: string;
23 phase?: PhaseId;
24 archived?: boolean;
25 evidence?: string;
26}
27
28export interface ChannelNote {
29 ts: string;
30 seq: number;
31 owner: string;
32 to?: string;
33 item?: string;
34 text: string;
35 channel: string;
36 kind?: string; // 'exit' | 'note' | ...
37}
38
39export interface ChannelMember {
40 name: string;
41 role?: string;
42 pane_id?: string;
43 pid?: number;
44 ended_at?: string | null;
45 exit_code?: number | null;
46}
47
48export type AgentState =
49 | 'spawning' | 'running' | 'idle' | 'input' | 'stuck' | 'parked' | 'finished' | 'failed' | 'dead';
50
51export interface Persona {
52 name: string; // 'implementer' (no atlas: prefix)
53 model: string; // frontmatter pin: haiku|sonnet|opus|inherit
54 effort: string; // low|medium|high|...
55 color: string; // frontmatter colour name, e.g. 'green'
56 description: string;
57}
58
59export interface SquadAgent {
60 name: string; // dispatch name, e.g. 'impl-auth'
61 persona: string; // persona name or 'armada-<dept>' or 'unknown'
62 state: AgentState;
63 source: 'task' | 'colony';
64 transport?: string; // atlas_mux.py transport (tmux|herdr|claude-bg) for mux-launched workers
65 task?: string; // todo item content
66 item?: string; // todo item id
67 lastNoteTs?: string;
68 paneId?: string;
69 tokens?: number;
70 model?: string;
71 effort?: string;
72}
73
74export interface PhaseCount { done: number; total: number }
75
76/** Everything the UI needs, rebuilt by the hooks module on file change and passed as Client props. */
77export interface AtlasSnapshot {
78 root: string | null;
79 sessionId: string;
80 channel: string | null;
81 contract: Contract;
82 personas: Persona[];
83 todos: TodoItem[]; // session slice, non-archived
84 counts: { done: number; total: number; byPhase: Record<string, PhaseCount> };
85 phase: PhaseId; // current phase (header > in_progress item > research)
86 phaseSource: 'header' | 'todo' | 'default';
87 notes: ChannelNote[]; // sorted by seq,ts; last 500
88 members: ChannelMember[];
89 squad: SquadAgent[];
90 contextPct: number;
91 now: number; // epoch ms at build
92}
93
94// ---- injectable IO (data readers are pure over this; register.ts adapts $.fs) ----
95export interface FsLike {
96 read(path: string): Promise<string | undefined>;
97 stat(path: string): Promise<{ size: number; mtimeMs: number } | undefined>;
98 exists(path: string): Promise<boolean>;
99 list(path: string): Promise<string[]>;
100}
101
102// ---- brand tokens (mirror scripts/dashboard_ui/css/tokens.css) ----
103export const BRAND = {
104 accent: '#2fbd9f', focus: '#7fe0cb', bg: '#0c1215', surface: '#121a1e', text: '#e6edf0', dim: '#9fb0b8',
105 ok: '#52c872', working: '#62b8e6', input: '#f2aa40', fail: '#ff7570', idle: '#93a4ac', subagent: '#b5a3fa',
106} as const;
107mod/intents.ts 149 lines1// Intents: pure module turning contract `Intent` values into safe argv.
2// Allowlist: ids, owners, channels, phases, text caps, pane ids are strictly validated;
3// argv always begins with 'python3' + a real script path — never a shell string.
4// Read plugins/atlas/scripts/atlas_todo.py, atlas_herdr.py, atlas_mux.py for EXACT flags.
5
6import type { PhaseId } from './contract';
7
8export const PHASES: readonly PhaseId[] = ['research', 'theory', 'test', 'validate', 'implement', 'verify', 'done', 'blocked'];
9
10const ID_RE = /^[A-Za-z0-9_.:-]{1,64}$/;
11const PANE_RE = /^[A-Za-z0-9:_.-]{1,64}$/;
12// Real channel names are `<basename>@<branch>[/lead-<id>]` (atlas_todo main_channel), so they may contain @ and /.
13const CHANNEL_RE = /^[A-Za-z0-9_.:@/-]{1,128}$/;
14const TEXT_CAP = 2000;
15
16// argv always begins: python3 <pluginRoot>/scripts/<script>.py — never a shell string.
17const argvHead = (pluginRoot: string, name: string) => ['python3', `${pluginRoot}/scripts/${name}.py`];
18
19export type IntentResult =
20 | { ok: true; argv: string[]; kind: 'cli' }
21 | { ok: true; kind: 'ui'; tab?: string; agent?: string }
22 | { ok: false; error: string };
23
24/** Validate a shared id-like string (todo ids, owners, channels, agents, names). */
25const idOk = (id: string): boolean => ID_RE.test(id);
26
27/** Validate a herdr pane id (herdr pane regex: ^[A-Za-z0-9:_.-]{1,64}$). */
28const paneOk = (paneId: string): boolean => PANE_RE.test(paneId);
29
30export function intentToArgv(i: unknown, ctx: { pluginRoot: string; root: string; channel: string | null }): IntentResult {
31 if (typeof i !== 'object' || i === null) return { ok: false, error: 'not an intent object' };
32 const x = i as Record<string, unknown>;
33 const t = x.t;
34 const fail = (error: string): IntentResult => ({ ok: false, error });
35
36 const isStr = (v: unknown): v is string => typeof v === 'string';
37 const needId = (v: unknown): IntentResult | { id: string } => {
38 if (!isStr(v)) return fail('missing id');
39 if (!idOk(v)) return fail(`bad id: ${v.slice(0, 32)}`);
40 return { id: v };
41 };
42 const needOwner = (v: unknown): IntentResult | { owner: string } => {
43 if (!isStr(v)) return fail('missing owner');
44 if (!idOk(v)) return fail(`bad owner: ${v.slice(0, 32)}`);
45 return { owner: v };
46 };
47 const needName = (v: unknown, label: string): IntentResult | { name: string } => {
48 if (!isStr(v)) return fail(`missing ${label}`);
49 if (!idOk(v)) return fail(`bad ${label}: ${v.slice(0, 32)}`);
50 return { name: v };
51 };
52 const needPhase = (v: unknown): IntentResult | { phase: PhaseId } => {
53 if (!isStr(v)) return fail('missing phase');
54 if (!(PHASES as readonly string[]).includes(v)) return fail(`bad phase: ${v.slice(0, 32)}`);
55 return { phase: v as PhaseId };
56 };
57 const needText = (v: unknown, label: string): IntentResult | { text: string } => {
58 if (!isStr(v)) return fail(`missing ${label}`);
59 const text = v.trim();
60 if (text.length === 0) return fail(`empty ${label}`);
61 if (text.length > TEXT_CAP) return fail(`${label} too long: ${text.length} > ${TEXT_CAP}`);
62 return { text };
63 };
64 const needPaneId = (v: unknown): IntentResult | { paneId: string } => {
65 if (!isStr(v)) return fail('missing paneId');
66 if (!paneOk(v)) return fail(`bad paneId: ${v.slice(0, 32)}`);
67 return { paneId: v };
68 };
69
70 switch (t) {
71 case 'todo.phase': {
72 const id = needId(x.id); if (!('id' in id)) return id as IntentResult;
73 const phase = needPhase(x.phase); if (!('phase' in phase)) return phase as IntentResult;
74 return {
75 ok: true, kind: 'cli', argv: [
76 ...argvHead(ctx.pluginRoot, 'atlas_todo'), 'status', '--id', id.id, '--status', phase.phase, '--root', ctx.root,
77 ],
78 };
79 }
80 case 'todo.claim': {
81 const id = needId(x.id); if (!('id' in id)) return id as IntentResult;
82 const owner = needOwner(x.owner); if (!('owner' in owner)) return owner as IntentResult;
83 return {
84 ok: true, kind: 'cli', argv: [
85 ...argvHead(ctx.pluginRoot, 'atlas_todo'), 'claim', '--id', id.id, '--owner', owner.owner, '--root', ctx.root,
86 ],
87 };
88 }
89 case 'todo.complete': {
90 const id = needId(x.id); if (!('id' in id)) return id as IntentResult;
91 const evidence = needText(x.evidence, 'evidence'); if (!('text' in evidence)) return evidence as IntentResult;
92 return {
93 ok: true, kind: 'cli', argv: [
94 ...argvHead(ctx.pluginRoot, 'atlas_todo'), 'complete', '--id', id.id, '--evidence', evidence.text, '--root', ctx.root,
95 ],
96 };
97 }
98 case 'note': {
99 const to = needName(x.to, 'to'); if (!('name' in to)) return to as IntentResult;
100 const text = needText(x.text, 'text'); if (!('text' in text)) return text as IntentResult;
101 const channel = ctx.channel;
102 if (channel === null || !CHANNEL_RE.test(channel)) return fail(`bad channel: ${String(channel).slice(0, 32)}`);
103 return {
104 ok: true, kind: 'cli', argv: [
105 ...argvHead(ctx.pluginRoot, 'atlas_todo'), 'note',
106 '--root', ctx.root,
107 '--channel', channel,
108 '--owner', 'human',
109 '--channel', channel,
110 '--to', to.name,
111 text.text,
112 ],
113 };
114 }
115 case 'steer': {
116 const paneId = needPaneId(x.paneId); if (!('paneId' in paneId)) return paneId as IntentResult;
117 const text = needText(x.text, 'text'); if (!('text' in text)) return text as IntentResult;
118 return {
119 ok: true, kind: 'cli', argv: [
120 ...argvHead(ctx.pluginRoot, 'atlas_herdr'), 'prompt', '--pane', paneId.paneId, '--text', text.text,
121 ],
122 };
123 }
124 case 'stop': {
125 const run = x.run;
126 if (!isStr(run)) return fail('missing run');
127 if (!idOk(run)) return fail(`bad run: ${run.slice(0, 32)}`);
128 return {
129 ok: true, kind: 'cli', argv: [
130 ...argvHead(ctx.pluginRoot, 'atlas_mux'), 'kill', '--run', run,
131 ],
132 };
133 }
134 case 'tab': {
135 const tab = x.tab;
136 if (tab !== 'colony' && tab !== 'channel' && tab !== 'board' && tab !== 'squad' && tab !== 'collab') {
137 return fail(`bad tab: ${String(tab).slice(0, 32)}`);
138 }
139 return { ok: true, kind: 'ui', tab };
140 }
141 case 'inspect': {
142 const agent = needName(x.agent, 'agent'); if (!('name' in agent)) return agent as IntentResult;
143 return { ok: true, kind: 'ui', agent: agent.name };
144 }
145 default:
146 return fail(`unknown intent: ${String(t).slice(0, 32)}`);
147 }
148}
149mod/band.tsx 306 lines1// band.tsx: the AbovePrompt "Mission Control" panel, a filled truecolor band: a gradient title bar,
2// the phase rail as chevron pills, one lane per live worker (state chip, spinner, tool chip, eighth-block
3// elapsed bar, tool-call sparkline), an IRC ticker and a footer of task bar + gate chips. Pure: `register.ts`
4// hands it the snapshot, live workers, gate groups, columns, the row budget, the spinner tick.
5// Tiers: >=120 full, 80-119 no role/target, <80 title row only; maxRows collapses lanes first, then the
6// ticker, then the footer (planRows).
7import type { ClientElements, RenderElement } from 'claude-code';
8import type { LiveSnapshot } from './orchestra-types';
9import { activeCount, callBuckets, type LiveWorker } from './data/live';
10import { routeCounts } from './data/todos';
11import { groupOpenCount, openGateCount } from './gates';
12import type { GateGroup, SidebarView } from './pane/sidebar-types';
13import { chip, eighthBar, gradientBar, merge, nickColor, padRow, seg, sparkline, spread } from './paint';
14import { charFrame, type CharState } from './roles';
15import { fitRuns, rowOf, type Segment } from './runs';
16import { todoRows } from './todo-panel';
17import { fitText, formatDuration, MOCHA as M, mix, phaseHue, textWidth } from './theme';
18
19/** `lastPhase`: the last rail phase the caller rendered, lit while the snapshot phase is `blocked`. */
20export type BandModel = { snapshot: LiveSnapshot; live: LiveWorker[]; gates: GateGroup[]; columns: number; tick: number; maxRows?: number; lastPhase?: string | null; todoExpanded?: boolean };
21
22const FULL = 120;
23const MID = 80;
24const REF_MS = 120_000; // an elapsed bar fills over two minutes
25const TICKER_ROWS = 2;
26const BAR_W = 28; // elapsed bar cells at most
27const TARGET_W = 24; // target column at its narrowest (full tier)
28const WAVE_GAP_MS = 10_000; // spawns further apart than this start a new wave
29const RAIL_SKIP = new Set(['validate', 'blocked']);
30/** Brand mark: one cell (the U+25E2 triangles of the mockup draw 2 cells in Ghostty; theme.test.ts). */
31export const MARK = '◆';
32
33/** Footer button presses: open (or focus) the Atlas pane on a view, or fold the board. Absent in fixtures that wire neither. */
34export type BandHandlers = { openView(v: SidebarView): void; toggleTodos(): void };
35/** The band's controls: hotkeys press only while the band holds the keyboard (ctrl+x, Tab, or a click), and are unique within it. */
36export const BAND_BUTTONS: readonly { id: SidebarView | 'board'; key: string; label: string }[] = [
37 { id: 'route', key: 'r', label: 'route' }, { id: 'team', key: 't', label: 'team' }, { id: 'channel', key: 'c', label: 'channel' },
38 { id: 'gates', key: 'g', label: 'gates' }, { id: 'board', key: 'o', label: 'board' },
39];
40const BAND_HINT = `click or ctrl+x Tab, then ${BAND_BUTTONS.map((b) => b.key).join(' ')} `;
41
42type Ctx = { s: LiveSnapshot; rail: string; cols: number; tick: number; full: boolean; now: number; done: number; total: number; open: number; gates: GateGroup[]; todoExpanded: boolean };
43
44const padTo = (t: string, n: number): string => { const f = fitText(t, n); return f + ' '.repeat(Math.max(0, n - textWidth(f))); };
45const clock = (ms: number): string => {
46 const s = Math.max(0, Math.floor(ms / 1000));
47 return s >= 3600 ? formatDuration(ms) : `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`;
48};
49
50// ---- title bar ---------------------------------------------------------------------
51
52/** Spawn clusters among the run's workers, oldest first. ponytail: a 10 s gap heuristic, swap for the dispatch
53 * batch id once the host exposes one. */
54function waves(ws: readonly LiveWorker[]): LiveWorker[][] {
55 const sorted = [...ws].sort((a, b) => a.startedAt - b.startedAt);
56 return sorted.reduce<LiveWorker[][]>((acc, w, i) => {
57 if (i === 0 || w.startedAt - sorted[i - 1]!.startedAt > WAVE_GAP_MS) acc.push([]);
58 acc[acc.length - 1]!.push(w);
59 return acc;
60 }, []);
61}
62const waveOf = (ws: readonly LiveWorker[]): number => waves(ws).length;
63
64/** `<folder>@<branch>` of the board channel; without a board, the project folder. */
65const boardOf = (s: LiveSnapshot): string => (s.channel ? s.channel.split('/')[0]! : (s.root ?? '').replace(/\/+$/, '').split('/').pop() || 'atlas');
66const statsOf = (c: Ctx, shown: LiveWorker[]): string => {
67 const since = Math.min(...shown.map((w) => w.startedAt));
68 const parts = [`◷ ${clock(c.now - since)}`, `${activeCount(shown)} live`, c.s.contextPct > 0 ? `ctx ${c.s.contextPct}%` : ''];
69 return `${parts.filter(Boolean).join(' ')} `;
70};
71
72function titleRow(c: Ctx, all: LiveWorker[], shown: LiveWorker[]): Segment[] {
73 const scope = shown.find((w) => w.scope)?.scope;
74 const left = [`${MARK} ATLAS`, ...(c.full ? ['mission control'] : []), boardOf(c.s), `wave ${waveOf(all)}`, ...(scope ? [scope] : [])].join(' · ');
75 return withChips(c, titleChips(c), (w) => gradientBar(left, statsOf(c, shown), w, M.violet, M.sky));
76}
77
78// ---- phase rail ----------------------------------------------------------------------
79
80const pastPill = (id: string, compact: boolean): Segment => ({ t: compact ? ' ✓ ' : ` ✓ ${id} `, color: M.base, bg: mix(phaseHue(id), M.panel, 0.45) });
81const futurePill = (id: string, compact: boolean): Segment => ({ t: compact ? ' · ' : ` ${id} `, color: M.muted, bg: M.panel2 });
82/** The current phase: bright hue, its white mix cycling with the tick while workers run. */
83const nowPill = (id: string, tick: number, animate: boolean): Segment => {
84 const shimmer = animate ? 0.25 + 0.25 * ((tick % 4) / 3) : 0.15;
85 return { t: ` ◆ ${id.toUpperCase()} `, color: M.base, bg: mix(phaseHue(id), M.white, shimmer), bold: true };
86};
87
88/** Pill for one phase; `rel` is its position against the current phase (0 current, <0 past, >0 future). */
89function pill(id: string, rel: number, tick: number, compact: boolean, animate: boolean): Segment {
90 if (rel < 0) return pastPill(id, compact);
91 return rel > 0 ? futurePill(id, compact) : nowPill(id, tick, animate);
92}
93
94const railIds = (s: LiveSnapshot): string[] => s.contract.phases.map((p) => p.id as string).filter((id) => !RAIL_SKIP.has(id));
95
96/** The lit pill: the snapshot phase when it is on the rail; for `blocked` (or any off-rail phase) the last rail phase
97 * the caller saw, else the first, so a blocked reply never leaves the rail with nothing lit. */
98export function railPhase(s: LiveSnapshot, lastPhase?: string | null): string {
99 const ids = railIds(s);
100 return ids.includes(s.phase) ? s.phase : lastPhase && ids.includes(lastPhase) ? lastPhase : ids[0] ?? s.phase;
101}
102
103function railRow(s: LiveSnapshot, phase: string, tick: number, compact: boolean, animate: boolean): Segment[] {
104 const ids = railIds(s);
105 const at = ids.indexOf(phase);
106 const cells = ids.flatMap((id, i) => [pill(id, i - at, tick, compact, animate), seg(i < ids.length - 1 ? '▶' : ' ', i < at ? phaseHue(id) : M.dim)]);
107 return [seg(' '), ...cells];
108}
109
110// ---- lanes -------------------------------------------------------------------------
111
112const STATE_CHIP: Record<LiveWorker['state'], [string, string]> = {
113 working: ['RUN', M.green], blocked: ['WAIT', M.yellow], failed: ['FAIL', M.red], done: ['DONE', M.teal], parked: ['PARK', M.muted],
114};
115
116const CHAR_STATE: Partial<Record<LiveWorker['state'], CharState>> = { failed: 'failed', done: 'done' };
117
118/** The lane's three-cell character (roles.ts): running with a tool in flight, thinking between calls. */
119function laneChar(w: LiveWorker, c: Ctx): string {
120 const state: CharState = w.state === 'working' ? (w.tool ? 'running' : 'thinking') : CHAR_STATE[w.state] ?? 'blocked';
121 return charFrame(w.role, state, c.tick);
122}
123
124/** `mcp__lean-ctx__ctx_shell` and `mcp__plugin_x_srv__tool` both read as the bare tool name. */
125export const shortTool = (tool: string): string => tool.replace(/^mcp__.*__/, '');
126
127/** The tool in flight; between calls the last one, dimmed, so the cell never blanks out. */
128function laneTool(w: LiveWorker): string {
129 const tool = w.tool ?? w.lastTool;
130 if (tool) return shortTool(tool);
131 return w.state === 'blocked' ? 'queued' : 'starting';
132}
133
134const roleCell = (w: LiveWorker, c: Ctx): Segment[] => (c.full ? [seg(padTo(w.role, 12), M.muted)] : []);
135const targetCell = (w: LiveWorker, c: Ctx, width: number): Segment => (c.full ? seg(` ${padTo(w.target ?? '', width)} `, M.text) : seg(' '));
136
137/** Lane head: state chip, role character, name, role, tool chip (dim between calls), target. */
138function laneHead(w: LiveWorker, c: Ctx, color: string, targetWidth = TARGET_W): Segment[] {
139 const [word, hue] = STATE_CHIP[w.state];
140 const tool = seg(` ${padTo(laneTool(w), 10)} `, w.tool ? color : M.muted, M.panel2, true);
141 return [seg(' '), chip(word.padEnd(4), hue), seg(laneChar(w, c), color), seg(` ${padTo(w.name, c.full ? 23 : 17)} `, M.text, M.panel, true), ...roleCell(w, c), tool, targetCell(w, c, targetWidth)];
142}
143
144const runsWidth = (parts: Segment[]): number => textWidth(parts.map((p) => p.t).join(''));
145
146/** One worker lane: head, elapsed bar sized to the room left (28 cells at most, the target takes the rest),
147 * sparkline of its tool calls, age. */
148function lane(w: LiveWorker, c: Ctx): Segment[] {
149 const color = STATE_CHIP[w.state][1];
150 const elapsed = c.now - w.startedAt;
151 const tail = [seg(' '), ...sparkline(callBuckets(w.calls, c.now), color), seg(` ${formatDuration(elapsed).padStart(6)}`, M.muted)];
152 let head = laneHead(w, c, color);
153 let room = c.cols - runsWidth(head) - runsWidth(tail) - 1;
154 if (c.full && room > BAR_W) [head, room] = [laneHead(w, c, color, TARGET_W + room - BAR_W), BAR_W];
155 const bar = room >= 5 ? eighthBar(elapsed / REF_MS, Math.min(BAR_W, room), mix(color, M.panel, 0.1), mix(color, M.white, 0.35)) : [];
156 return padRow([...head, ...bar, ...tail], c.cols);
157}
158
159// ---- ticker and footer -------------------------------------------------------------
160
161const hhmm = (ts: number): string => { const d = new Date(ts); return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; };
162const nick = (name: string): Segment => seg(name, nickColor(name), M.panel, true);
163
164/** Last two channel messages as `hh:mm from → to: text`, led by the channel chip. */
165function ticker(c: Ctx): Segment[] {
166 const channel = c.s.channel?.split('/').pop() ?? 'lead';
167 const msgs = c.s.comms.slice(-TICKER_ROWS).flatMap((m, i) => [
168 seg(`${i ? ' ' : ''}${hhmm(m.ts)} `, M.dim), nick(m.from), seg(' → ', M.muted), nick(m.to), seg(`: ${m.text.replace(/\s+/g, ' ')}`, M.text),
169 ]);
170 return padRow([seg(' '), chip(`#${channel}`, M.violet), seg(' '), ...msgs], c.cols);
171}
172
173const gateChip = (g: GateGroup): Segment[] => [groupOpenCount(g) || g.error ? chip(`✖ ${g.id}`, M.red) : chip(`✔ ${g.id}`, M.green), seg(' ')];
174const hasGateData = (g: GateGroup): boolean => g.lines.length > 0 || !!g.error;
175
176const taskBar = (c: Ctx, barW: number): Segment[] => [seg(' '), seg('tasks ', M.muted), ...(barW ? eighthBar(c.done / Math.max(1, c.total), barW, M.green, M.teal) : []), seg(` ${c.done}/${c.total} `, M.text)];
177const buttonWidth = (b: { label: string }): number => 3 + textWidth(b.label) + 2; // engine draws `k: label`, two cells between buttons
178
179/** Task bar, gate chips, then the five control Buttons (a row of real clickable keys), the hint flush right when it fits.
180 * Gate chips go first when the row is tight, then the bar. Without handlers: the bar and chips only. */
181function footer(els: ClientElements, c: Ctx, on?: BandHandlers): Segment[] | RenderElement {
182 const chips = c.gates.filter(hasGateData).flatMap(gateChip);
183 if (!on) return padRow([...taskBar(c, 18), ...chips], c.cols);
184 const keysW = BAND_BUTTONS.reduce((n, b) => n + buttonWidth(b), 0);
185 const left = [taskBar(c, 18).concat(chips), taskBar(c, 18), taskBar(c, 0)].find((l) => runsWidth(l) + keysW <= c.cols) ?? taskBar(c, 0);
186 const hint = textWidth(BAND_HINT) + 1 <= c.cols - runsWidth(left) - keysW ? BAND_HINT : '';
187 const press = (id: SidebarView | 'board'): (() => void) => (id === 'board' ? on.toggleTodos : () => on.openView(id));
188 const buttons = BAND_BUTTONS.map((b) => els.Button({
189 key: `band:${b.id}`, plain: true, hotkey: b.key, label: b.label, onPress: press(b.id),
190 children: [els.Text({ color: M.text, backgroundColor: M.panel, children: b.label })],
191 }));
192 const gap = c.cols - runsWidth(left) - keysW - textWidth(hint);
193 const sep = (i: number): RenderElement => els.Text({ backgroundColor: M.panel, children: i ? ' ' : '' });
194 return els.Box({
195 flexDirection: 'row', flexShrink: 0,
196 children: [...fitRuns(els, left, c.cols), ...buttons.flatMap((b, i) => [sep(i), b]), els.Text({ backgroundColor: M.panel, children: ' '.repeat(Math.max(0, gap)) + hint })],
197 });
198}
199
200// ---- row budget --------------------------------------------------------------------
201
202/** Whether the ticker and footer stay: drop the ticker first, then the footer, until `need` lane rows fit. */
203function chrome(room: number, need: number, hasTicker: boolean): [boolean, boolean] {
204 for (const [t, f] of [[hasTicker, true], [false, true], [false, false]] as const) if (room - +t - +f >= need) return [t, f];
205 return [false, false];
206}
207
208/** Hidden workers beat the ticker: trade it for the "+N more" row. */
209const tradesTicker = (kept: boolean, nLanes: number, slots: number): boolean => kept && nLanes > slots && slots < 2;
210const needsMore = (nLanes: number, slots: number): boolean => nLanes > slots && slots >= 2;
211
212/** How many lanes, ticker and footer rows fit `maxRows` (title + rail are fixed). */
213export function planRows(maxRows: number, nLanes: number, hasTicker: boolean): { lanes: number; ticker: boolean; footer: boolean; more: boolean } {
214 const room = Math.max(0, maxRows - 2);
215 const [kept, footer] = chrome(room, Math.min(1, nLanes), hasTicker);
216 const ticker = kept && !tradesTicker(kept, nLanes, room - 1 - +footer);
217 const slots = room - +ticker - +footer;
218 const more = needsMore(nLanes, slots);
219 return { lanes: more ? slots - 1 : Math.min(nLanes, slots), ticker, footer, more };
220}
221
222// ---- assembly ----------------------------------------------------------------------
223
224type Row = Segment[] | RenderElement;
225const panel = (els: ClientElements, rows: Row[], cols: number): RenderElement =>
226 els.Box({ flexDirection: 'column', flexShrink: 0, backgroundColor: M.panel, children: rows.map((r) => (Array.isArray(r) ? rowOf(els, r, cols) : r)) });
227
228/** `last wave: N workers · 4m12s · ✔ ok` over the most recent spawn burst only, empty when no worker ever ran. */
229function lastWave(c: Ctx, all: LiveWorker[]): string {
230 const wave = waves(all).pop();
231 if (!wave) return '';
232 const failed = wave.filter((w) => w.state === 'failed').length;
233 const parts = [`last wave: ${wave.length} worker${wave.length === 1 ? '' : 's'}`, formatDuration(c.now - Math.min(...wave.map((w) => w.startedAt))), failed ? `✖ ${failed} failed` : '✔ ok'];
234 return parts.filter(Boolean).join(' · ');
235}
236
237/** Title chips on the right: a red `BLOCKED` when the last reply declared the blocked phase, then `extra`. */
238const titleChips = (c: Ctx, extra: Segment[] = []): Segment[] => [...(c.s.phase === 'blocked' ? [chip('BLOCKED', M.red)] : []), ...extra];
239
240/** A gradient bar `make(width)` narrowed so `chips` and one trailing cell fit after it; bar alone when they would not. */
241function withChips(c: Ctx, chips: Segment[], make: (width: number) => Segment[]): Segment[] {
242 const room = c.cols - runsWidth(chips) - 1;
243 return chips.length && room >= 40 ? [...make(room), ...chips, seg(' ')] : make(c.cols);
244}
245
246/** Idle title: always calm (grey into teal), `idle · last wave ...` when wide. Open gates are one amber chip, never
247 * "blocked": gates.ts reports what is open, not whether the next Stop is refused. */
248function idleTitle(c: Ctx, all: LiveWorker[]): Segment[] {
249 const wave = c.cols >= MID ? lastWave(c, all) : '';
250 const chips = titleChips(c, c.open ? [chip(`gates ✖${c.open}`, M.peach)] : []);
251 return withChips(c, chips, (w) => gradientBar(`${MARK} ATLAS · ${['idle', wave].filter(Boolean).join(' · ')}`, '', w, M.grey, mix(M.teal, M.panel, 0.55), M.text));
252}
253
254/** Board rows (todo-panel.tsx) under the rail: one collapsed bar, or the list capped to what `maxRows` leaves after
255 * the fixed rows and a few lanes. Empty without a board or without the room. */
256function boardRows(c: Ctx, maxRows: number, fixed: number): Segment[][] {
257 const room = maxRows - fixed;
258 if (!c.s.todos.length || room < 1) return [];
259 return todoRows(c.s.todos, boardOpts(c, room));
260}
261
262/** Expanded list when the board is open and 4+ rows are free (3 reserved for the rail), else one collapsed row. */
263function boardOpts(c: Ctx, room: number): Parameters<typeof todoRows>[1] {
264 const list = c.todoExpanded && room >= 4;
265 return { cols: c.cols, tick: c.tick, expanded: list, maxRows: list ? room - 3 : 1 };
266}
267
268/** The control row while idle, so the keys stay reachable. */
269const idleControls = (els: ClientElements, c: Ctx, maxRows: number, on?: BandHandlers): Row[] => (on && maxRows >= 3 && c.cols >= MID ? [footer(els, c, on)] : []);
270
271/** Idle: the dim title and the settled rail, then the board when there is one, then the control buttons. */
272function idleRows(els: ClientElements, c: Ctx, all: LiveWorker[], maxRows: number, on?: BandHandlers): Row[] {
273 const title = idleTitle(c, all);
274 if (maxRows < 2) return [title];
275 const controls = idleControls(els, c, maxRows, on);
276 return [title, padRow(railRow(c.s, c.rail, c.tick, c.cols < 100, false), c.cols), ...boardRows(c, maxRows, 2 + controls.length), ...controls];
277}
278
279/** <80 columns or <3 rows: the title row alone, phase and counts in it. */
280function narrowRow(c: Ctx, shown: LiveWorker[]): Segment[] {
281 const left = [`${MARK} ATLAS`, `${c.s.phase} ${c.done}/${c.total}`, `${activeCount(shown)} working`, ...(c.open ? [`✖${c.open}`] : [])].join(' · ');
282 return gradientBar(left, '', c.cols, M.violet, M.sky);
283}
284
285/** Title, rail, as many lanes as fit, then "+N more", ticker and footer per `planRows`. */
286function fullRows(els: ClientElements, c: Ctx, all: LiveWorker[], shown: LiveWorker[], maxRows: number, on?: BandHandlers): Row[] {
287 const board = boardRows(c, maxRows - 3, 2); // keep title, rail and one lane
288 const plan = planRows(maxRows - board.length, shown.length, c.s.comms.length > 0);
289 const rows: Row[] = [titleRow(c, all, shown), padRow(railRow(c.s, c.rail, c.tick, c.cols < 100, true), c.cols), ...board, ...shown.slice(0, plan.lanes).map((w) => lane(w, c))].map((r) => (Array.isArray(r) ? merge(r) : r));
290 if (plan.more) rows.push(padRow([seg(` +${shown.length - plan.lanes} more`, M.muted)], c.cols));
291 if (plan.ticker) rows.push(ticker(c));
292 if (plan.footer) rows.push(footer(els, c, on));
293 return rows;
294}
295
296export function bandTree(els: ClientElements, { snapshot: s, live, gates, columns, tick, maxRows = 12, lastPhase = null, todoExpanded = false }: BandModel, on?: BandHandlers): RenderElement {
297 const { done, total } = routeCounts(s.todoGroups, s.contract);
298 const all = live.filter((w) => w.state !== 'parked'); // parked workers are resting
299 const shown = all.filter((w) => w.state !== 'done');
300 const cols = Math.max(1, columns);
301 const c: Ctx = { s, rail: railPhase(s, lastPhase), cols, tick, full: cols >= FULL, now: s.now, done, total, open: openGateCount(gates), gates, todoExpanded };
302 if (!shown.length) return panel(els, idleRows(els, c, all, maxRows, on), cols);
303 if (cols < MID || maxRows < 3) return panel(els, [narrowRow(c, shown)], cols);
304 return panel(els, fullRows(els, c, all, shown, maxRows, on), cols);
305}
306mod/data/live.ts 55 lines1// One worker list for every surface: squad + per-agent activity, lead excluded, urgent first.
2import type { LiveSnapshot } from '../orchestra-types';
3
4export interface LiveWorker {
5 name: string;
6 role: string;
7 state: 'working' | 'blocked' | 'failed' | 'parked' | 'done';
8 tool: string | null;
9 /** The tool answered last; the band shows it dimmed between calls. */
10 lastTool: string | null;
11 /** Current-tool time (moves every call). */
12 since: number;
13 /** First sight of the worker; the age shown to people. */
14 startedAt: number;
15 target: string | null;
16 lastLine: string;
17 /** Tool start times over the last minute (sparkline). */
18 calls: number[];
19 /** Dispatch `SCOPE:` value (`audit/medium`), when the prompt carried one. */
20 scope: string | null;
21}
22
23const ORDER = { blocked: 0, failed: 1, working: 2, parked: 3, done: 4 } as const;
24
25// Takes LiveSnapshot (AtlasSnapshot & activity): AtlasSnapshot alone has no per-agent activity.
26export function liveWorkers(snapshot: LiveSnapshot, now: number): LiveWorker[] {
27 return snapshot.squad
28 .filter((a) => a.name !== 'lead')
29 .map((a): LiveWorker => {
30 const act = snapshot.activity[a.name];
31 // Host-listed completion wins over a stale 'blocked' mood; only live squad states can be blocked.
32 const state = a.state === 'finished' ? 'done'
33 : a.state === 'failed' || a.state === 'dead' ? 'failed'
34 : a.state === 'parked' ? 'parked'
35 : act?.mood === 'blocked' || a.state === 'input' || a.state === 'stuck' ? 'blocked'
36 : act?.mood === 'failed' ? 'failed'
37 : act?.mood === 'done' ? 'done'
38 : 'working';
39 return { name: a.name, role: a.persona, state, tool: act?.tool ?? null, lastTool: act?.lastTool ?? null, since: act?.since ?? now, startedAt: act?.startedAt ?? act?.since ?? now, target: act?.target ?? null, lastLine: act?.lastLine ?? '', calls: act?.calls ?? [], scope: act?.scope ?? null };
40 })
41 .sort((x, y) => ORDER[x.state] - ORDER[y.state]);
42}
43
44/** Calls per `bucketMs` slot over the last `n` slots, oldest first. */
45export function callBuckets(calls: readonly number[], now: number, n = 12, bucketMs = 5000): number[] {
46 const out = new Array<number>(n).fill(0);
47 for (const t of calls) {
48 const slot = n - 1 - Math.floor((now - t) / bucketMs);
49 if (slot >= 0 && slot < n) out[slot]! += 1;
50 }
51 return out;
52}
53
54export const activeCount = (ws: LiveWorker[]) => ws.filter((w) => w.state === 'working' || w.state === 'blocked').length;
55mod/pane/sidebar.tsx 85 lines1// Native-element sidebar: a hotkey tab strip (r route, t team, c channel, g gates) over exactly one view.
2// Plain column: the host pane draws the one frame.
3// Pure: every handler arrives as a closure, nothing here holds state or touches `$`.
4// Plan: docs/plans/2026-10-13-atlas-command-center-redesign.md §3.
5import type { ClientElements, RenderElement } from 'claude-code';
6import { openGateCount } from '../gates';
7import { textWidth } from '../theme';
8import { gateRows } from './sidebar-gates';
9import { channelRows } from './sidebar-channel';
10import { routeRows } from './sidebar-route';
11import { teamRows } from './sidebar-team';
12import type { SidebarHandlers, SidebarModel, SidebarView } from './sidebar-types';
13
14export type { GateGroup, GateLine, SidebarHandlers, SidebarModel, SidebarView } from './sidebar-types';
15
16const HINT_MIN_INNER = 60;
17/** Only the keys the strip draws: r t c g always, o when the board toggle is wired. Hotkeys press while this pane holds the keyboard. */
18const hintFor = (on: SidebarHandlers): string => `press r t c g${on.toggleTodos ? ' o' : ''} · Tab ↑↓ · Esc closes`;
19
20const stripWidth = (ts: { label: string }[]): number => ts.reduce((n, t) => n + textWidth(t.label) + 6, -3);
21const gatesLabels = (open: number): { label: string; short: string } => (open ? { label: `✗ ${open}`, short: `gates ✗${open}` } : { label: '✓ gates', short: 'gates' });
22type TabSpec = { v: SidebarView; key: string; label: string; short: string };
23
24/** The four view tabs and the board label, glyphs dropped when five buttons with them overflow a docked 56-column pane.
25 * `used` is the strip's width so far (labels, `k: ` prefixes, separators); `boardW` the board button's full width. */
26function tabSpecs(open: number, inner: number, hasBoard: boolean): { tabs: TabSpec[]; board: string; used: number; boardW: number } {
27 const tabs: TabSpec[] = [
28 { v: 'route', key: 'r', label: '◎ route', short: 'route' },
29 { v: 'team', key: 't', label: '● team', short: 'team' },
30 { v: 'channel', key: 'c', label: '✉ channel', short: 'channel' },
31 { v: 'gates', key: 'g', ...gatesLabels(open) },
32 ];
33 const compact = stripWidth(tabs) + 13 * +hasBoard > inner;
34 const shown = tabs.map((t) => ({ ...t, label: compact ? t.short : t.label }));
35 const board = ['▤ board', 'board'][+compact]!;
36 return { tabs: shown, board, used: stripWidth(shown), boardW: (6 + textWidth(board)) * +hasBoard };
37}
38
39/** The optional board toggle after the four tabs. */
40function todosTab(els: ClientElements, on: SidebarHandlers, board: string): RenderElement[] {
41 if (!on.toggleTodos) return [];
42 return [els.Text({ dimColor: true, children: ' │ ' }), els.Button({ key: 'tab:todos', plain: true, hotkey: 'o', dimColor: true, children: [els.Text({ children: board })], label: board, onPress: on.toggleTodos })];
43}
44
45/** The right-aligned hint, only when the pane is wide enough and the gap fits. */
46function hintCells(els: ClientElements, inner: number, gap: number, hint: string): RenderElement[] {
47 if (inner < HINT_MIN_INNER || gap < 2) return [];
48 return [els.Text({ children: ' '.repeat(gap) }), els.Text({ dimColor: true, children: hint })];
49}
50
51function tabButtons(els: ClientElements, tabs: TabSpec[], model: SidebarModel, on: SidebarHandlers): RenderElement[] {
52 const children: RenderElement[] = [];
53 tabs.forEach((t, i) => {
54 const cur = t.v === model.ui.view;
55 if (i) children.push(els.Text({ dimColor: true, children: ' │ ' }));
56 children.push(els.Button({
57 key: `tab:${t.v}`, plain: true, hotkey: t.key, dimColor: !cur,
58 children: cur ? [els.Text({ bold: true, inverse: true, children: t.label })] : [els.Text({ children: t.label })],
59 label: t.label, onPress: () => on.setView(t.v),
60 }));
61 });
62 return children;
63}
64
65/** The tab strip: four plain hotkey Buttons (the current one bold), then a dim hint pushed to the right edge when there is room. */
66function tabStrip(els: ClientElements, model: SidebarModel, on: SidebarHandlers, inner: number): RenderElement {
67 const { tabs, board, used, boardW } = tabSpecs(openGateCount(model.gates), inner, !!on.toggleTodos);
68 const hint = hintFor(on);
69 const gap = inner - used - textWidth(hint) - boardW; // the board button: separator 3, `o: ` 3, label
70 const children = [...tabButtons(els, tabs, model, on), ...todosTab(els, on, board), ...hintCells(els, inner, gap, hint)];
71 return els.Box({ key: 'tabs', flexDirection: 'row', flexShrink: 0, children });
72}
73
74const VIEWS = { route: routeRows, team: teamRows, channel: channelRows, gates: gateRows } as const;
75
76export function sidebarTree(els: ClientElements, model: SidebarModel, on: SidebarHandlers): RenderElement {
77 const cols = Math.max(10, model.columns - 1);
78 return els.Box({
79 flexDirection: 'column',
80 flexShrink: 0,
81 width: cols,
82 children: [tabStrip(els, model, on, cols), ...VIEWS[model.ui.view](els, model, on, cols)],
83 });
84}
85mod/gates.ts 58 lines1// gates.ts — pure shaping of the sidebar's Gates data: the atlas_gates.py JSON, the
2// last-reply header check, and the open-gate count shared by the sidebar and the band.
3import type { GateGroup } from './pane/sidebar';
4
5/** Result of testing the last answer's first line against the contract header pattern. */
6export type HeaderCheck = { matched: boolean; phase: string | null } | null;
7
8const GROUP_IDS: readonly string[] = ['completion', 'tripwire', 'routing'];
9
10const optionalString = (key: string, value: unknown): Record<string, string> =>
11 typeof value === 'string' ? { [key]: value } : {};
12
13const isLine = (l: any): boolean => typeof l?.id === 'string' && typeof l.label === 'string' && typeof l.ok === 'boolean';
14
15const isGroup = (g: any): boolean => GROUP_IDS.includes(g?.id) && typeof g.title === 'string' && Array.isArray(g.lines);
16
17function parseLine(l: any): GateGroup['lines'][number] {
18 if (!isLine(l)) throw new Error('bad line');
19 return { id: l.id, label: l.label, ok: l.ok, ...optionalString('detail', l.detail), ...optionalString('fix', l.fix) };
20}
21
22function parseGroup(g: any): GateGroup {
23 if (!isGroup(g)) throw new Error('bad group');
24 return { id: g.id, title: g.title, lines: g.lines.map(parseLine), ...optionalString('error', g.error) };
25}
26
27/** Groups from `atlas_gates.py --json` stdout; throws on anything that is not the documented shape. */
28export function parseGates(stdout: string): GateGroup[] {
29 const doc = JSON.parse(stdout) as { groups?: unknown };
30 if (!Array.isArray(doc.groups)) throw new Error('no groups array');
31 return doc.groups.map(parseGroup);
32}
33
34/** A missing script, nonzero exit or bad JSON shows as one group with an error and no lines. */
35export const gatesError = (reason: string): GateGroup => ({ id: 'completion', title: 'Gates', lines: [], error: reason });
36
37/** The `header` group: whether the last reply opened with the contract header, and its declared phase. */
38export function headerGroup(check: HeaderCheck): GateGroup {
39 if (check === null) return { id: 'header', title: 'Header contract', lines: [] };
40 return {
41 id: 'header',
42 title: 'Header contract',
43 lines: [{
44 id: 'header-matched',
45 label: 'Last reply header matched',
46 ok: check.matched,
47 detail: check.matched ? `declared phase: ${check.phase ?? 'unknown'}` : 'first line did not match the ATLAS header pattern',
48 }],
49 };
50}
51
52/** Open items in one group: its failing lines, plus one when its source could not be read. */
53export const groupOpenCount = (g: GateGroup): number => g.lines.filter((l) => !l.ok).length + (g.error ? 1 : 0);
54
55/** The one open-gate figure the band and the sidebar header both show. */
56export const openGateCount = (groups: readonly GateGroup[]): number =>
57 groups.reduce((n, g) => n + groupOpenCount(g), 0);
58mod/routing.ts 156 lines1// Routing enforcement for the Atlas mod: record agentId→persona so turn.step
2// can pin the persona's effort (persona model pins live in the agent
3// definitions' own frontmatter, honored natively — never rewritten here)
4// and feed turn completions into the orchestration state.
5//
6// Event shapes mirror plugins/atlas/.claude-plugin/types/claude-code/index.d.ts
7// (AgentSpawnInput, TurnStepInput, TurnCompleteInput, PromptSubmitInput).
8import { atom, read, update } from 'claude-code';
9import type { On } from 'claude-code';
10import { EMPTY_ORCH, LEAD, onComplete, onSpawn, onStep } from './data/orchestration';
11import type { AtlasSnapshot, Persona, SquadAgent } from './contract';
12
13const ATLAS_PREFIX = 'atlas:';
14const EFFORTS = ['low', 'medium', 'high'] as const;
15type RoutableEffort = (typeof EFFORTS)[number];
16
17const isRoutableEffort = (effort: string): effort is RoutableEffort =>
18 (EFFORTS as readonly string[]).includes(effort);
19
20// Session state this file reads/writes ($.state, contract in types/index.d.ts).
21// The `$.state` key scan needs each atom declared in the file that reads it, so
22// these re-declare refs register.ts owns.
23const snapshotState = atom({ plugin: 'atlas', key: 'snapshot' } as const, null);
24const taskAgentsState = atom({ plugin: 'atlas', key: 'taskAgents' } as const, []);
25const orchState = atom({ plugin: 'atlas', key: 'orch' } as const, EMPTY_ORCH);
26
27export interface RoutingDeps {
28 /** Personas of the snapshot read from $.state (null before the first build). */
29 getPersonas: (snapshot: AtlasSnapshot | null) => Persona[];
30 logDrift: (msg: string) => void;
31}
32
33/** Track a spawned agent as a live task agent (capped at 64). */
34function withAgent(agents: SquadAgent[], agentId: string, persona: string, task?: string): SquadAgent[] {
35 const extra = task ? { task } : {};
36 return agents.some((a) => a.name === agentId)
37 ? agents.map((a) => (a.name === agentId ? { ...a, persona, ...extra } : a))
38 : [...agents, { name: agentId, persona, state: 'running' as const, source: 'task' as const, ...extra }].slice(-64);
39}
40
41/** The worker name an atlas dispatch puts on its prompt's first line (`name: probe-slow`): the Agent tool has no `name` parameter for a plain subagent. */
42export const promptName = (prompt: string): string | undefined => /^\s*name:\s*([\w.-]+)/i.exec(prompt)?.[1];
43
44const CALL_TTL_MS = 5000;
45interface AgentCall { id: string; name: string; description: string; type: string; at: number }
46
47/** The Agent call `name` that matches this spawn: by tool_use_id (the engine's shared id), else the newest call with the same description + subagent type inside CALL_TTL_MS. */
48const matchCall = (calls: readonly AgentCall[], spawn: { tool_use_id?: string; description: string; subagentType: string }, now: number): string | undefined =>
49 (calls.find((c) => spawn.tool_use_id && c.id === spawn.tool_use_id)
50 ?? [...calls].reverse().find((c) => now - c.at <= CALL_TTL_MS && c.description === spawn.description && c.type === spawn.subagentType))?.name;
51
52/**
53 * Wires persona routing: `agent.spawn` records agentId→persona for matched
54 * atlas roles (models pass through untouched — the agent definition's own
55 * frontmatter pin decides); `turn.step` pins the recorded persona's effort
56 * onto that agent's model requests. Unknown roles, armada dispatches and
57 * forks pass through untouched. Every hook fails open: on any error the
58 * event moves on unchanged.
59 */
60export function registerRouting(on: On, deps: RoutingDeps): void {
61 // agentId -> persona name, recorded on spawn, read on turn.step.
62 const byAgent = new Map<string, string>();
63 // Agent/Task tool calls carrying a `name`, awaiting their agent.spawn (the Agent tool's own name, which agent.spawn may lack).
64 let calls: AgentCall[] = [];
65
66 on('tool.call', { tool: /^(Agent|Task)$/ }, async ($, e, next) => {
67 try {
68 const str = (v: unknown): string => (typeof v === 'string' ? v : '');
69 const a = e as unknown as Record<string, unknown>; // SAFETY: a RegExp matcher cannot narrow e; Agent/Task args are an open string-keyed bag, every read goes through str()
70 const name = str(a.name);
71 if (name) {
72 const at = await $.clock.now();
73 calls = [...calls.filter((c) => at - c.at <= CALL_TTL_MS), { id: str(a.tool_use_id), name, description: str(a.description), type: str(a.subagent_type), at }].slice(-16);
74 }
75 } catch {
76 // name capture never blocks a dispatch
77 }
78 return next(e);
79 });
80
81 on('agent.spawn', async ($, e, next) => {
82 let persona: Persona | undefined;
83 try {
84 if (e.subagentType.startsWith(ATLAS_PREFIX)) {
85 persona = deps.getPersonas(await read($, snapshotState)).find((p) => p.name === e.subagentType.slice(ATLAS_PREFIX.length));
86 }
87 } catch {
88 persona = undefined;
89 }
90 if (persona && !e.fork && e.model !== undefined && e.model !== 'inherit') {
91 // An explicit non-inherit model on a known role should have been denied
92 // by the dispatch tripwire; never deny here, just note the drift.
93 try {
94 deps.logDrift(`agent.spawn: atlas:${e.subagentType.slice(ATLAS_PREFIX.length)} asked for model ${e.model}, pinned ${persona.model}`);
95 } catch {
96 // logging never blocks a spawn
97 }
98 }
99 // Models pass through untouched: the agent definition's frontmatter pin
100 // is honored natively, so the event is never rewritten.
101 const result = await next(e);
102 if (!e.fork && typeof result.agentId === 'string') {
103 // Record every spawned subagent (generic `task` included) so the band's
104 // conveyor shows it; non-atlas types carry persona 'unknown', which
105 // mergeSquad resolves through personaOf.
106 if (persona) byAgent.set(result.agentId, persona.name);
107 try {
108 const agentId = result.agentId;
109 const label = e.subagentType.replace(ATLAS_PREFIX, '').toLowerCase();
110 await update($, taskAgentsState, (agents) => withAgent(agents, agentId, persona?.name ?? (label || 'unknown'), e.description));
111 const now = await $.clock.now();
112 const name = e.name || matchCall(calls, e, now) || promptName(e.prompt);
113 const scope = /^\s*SCOPE:\s*(\S+)/m.exec(e.prompt ?? '')?.[1];
114 await update($, orchState, (o) => onSpawn(o, agentId, e.description || e.prompt, now, { ...(name ? { name } : {}), ...(scope ? { scope } : {}), type: e.subagentType }));
115 } catch {
116 // recording never blocks a spawn
117 }
118 }
119 return result;
120 });
121
122 on('turn.step', async function* ($, e, next) {
123 let effort: RoutableEffort | undefined;
124 try {
125 const now = await $.clock.now();
126 await update($, orchState, (o) => onStep(o, e.agentId ?? LEAD, now));
127 } catch {
128 // the activity feed never blocks a step
129 }
130 try {
131 if (e.agentId !== undefined) {
132 const name = byAgent.get(e.agentId);
133 const persona = name === undefined ? undefined : deps.getPersonas(await read($, snapshotState)).find((p) => p.name === name);
134 if (persona && isRoutableEffort(persona.effort)) effort = persona.effort;
135 }
136 } catch {
137 effort = undefined;
138 }
139 yield* effort !== undefined ? next({ ...e, effort }) : next(e);
140 });
141}
142
143/** Feeds each completed turn (main loop or subagent) into the orchestration state. Never throws; the answer always moves on. */
144export function registerCompletion(on: On): void {
145 on('turn.complete', async ($, e, next) => {
146 try {
147 const now = await $.clock.now();
148 const tokens = (e.usage?.input_tokens ?? 0) + (e.usage?.output_tokens ?? 0);
149 await update($, orchState, (o) => onComplete(o, e.agentId ?? LEAD, { reason: e.reason, answer: e.answer, tokens }, now));
150 } catch {
151 // the activity feed never blocks an answer
152 }
153 return next(e);
154 });
155}
156mod/discipline.ts 251 lines1// discipline.ts — context discipline for the Atlas mod: push heavy inline work to
2// subagents, flag contract drift, and sharpen vague prompts with repo facts.
3// Every hook is gated by ATLAS_MOD!=off + an interactive session, fails open
4// (a throw never blocks a tool, a prompt or an answer) and only ever adds
5// model-only context or a rate-limited toast; it never rewrites user text.
6// register.ts owns the single turn.complete / session.measure registrations and
7// calls afterAnswer / onMeasure from them (an event may register once per matcher).
8import { atom, read, update } from 'claude-code';
9import type { On, EngineInterface } from 'claude-code';
10import type { AtlasSnapshot } from './contract';
11import { PHASE_RE } from './restyle';
12import type { HeaderCheck } from './gates';
13import { EMPTY_ORCH, LEAD, onToolEnd, onToolStart } from './data/orchestration';
14
15/** Inline tool output at or above this many chars (~1k tokens) counts as heavy. */
16export const HEAVY_CHARS = 4000;
17/** Heavy inline calls in one turn, with no delegation, before Atlas speaks up. */
18export const HEAVY_LIMIT = 4;
19/** A turn that grew the context by this many tokens inline is worth a nudge. */
20export const BIG_TURN_TOKENS = 30_000;
21/** Minimum gap between Atlas toasts, whatever raised them. */
22export const TOAST_GAP_MS = 30_000;
23const VAGUE_MAX_CHARS = 60;
24const VAGUE_MAX_WORDS = 8;
25
26/** tool -> subagent that should have done the reading/running instead. */
27const ROLE: Record<string, string> = { Read: 'atlas:explorer', Grep: 'atlas:explorer', Glob: 'atlas:explorer', Bash: 'atlas:runner' };
28
29interface Discipline {
30 heavy: number; // heavy inline calls this turn
31 delegated: boolean; // a subagent was dispatched this turn
32 hint: string; // tool of the latest heavy call
33 toastAt: number;
34}
35const INITIAL: Discipline = { heavy: 0, delegated: false, hint: '', toastAt: 0 };
36
37const disciplineState = atom({ plugin: 'atlas', key: 'discipline' } as const, INITIAL);
38const orchState = atom({ plugin: 'atlas', key: 'orch' } as const, EMPTY_ORCH);
39const isInteractive = atom({ plugin: 'atlas', key: 'isInteractive' } as const, false);
40const lastHeaderState = atom({ plugin: 'atlas', key: 'lastHeader' } as const, null);
41const isModOn = atom({ plugin: 'atlas', key: 'isModOn' } as const, true);
42const snapshotState = atom({ plugin: 'atlas', key: 'snapshot' } as const, null);
43
44// ---- pure helpers -------------------------------------------------------------
45
46const QUOTE_RE = /["'`\u201c\u201d]/;
47const ANCHOR_RE = /[\w.-]+\/[\w./-]+|\.\w{1,5}\b|\d|\b(test|tests|should|must|until|expect|passes)\b/i;
48
49/** Prompts that are never vague: empty, a slash command, or quoting something. */
50const isExempt = (t: string): boolean => t === '' || t.startsWith('/') || QUOTE_RE.test(t);
51
52const isShort = (t: string): boolean => t.length <= VAGUE_MAX_CHARS && t.split(/\s+/).length <= VAGUE_MAX_WORDS;
53
54/** Short prompt with no path, number, quote or acceptance word: nothing to anchor on. */
55export function isVague(text: string): boolean {
56 const t = text.trim();
57 return !isExempt(t) && isShort(t) && !ANCHOR_RE.test(t);
58}
59
60/** Repo, branch, phase and up to 3 open todo titles, as `key value` parts. */
61function factsParts(snapshot: AtlasSnapshot, branch: string): string[] {
62 const open = snapshot.todos.filter((t) => t.status !== 'completed').slice(0, 3).map((t) => t.content.slice(0, 80));
63 const parts = [`repo ${snapshot.root}`, `branch ${branch || 'unknown'}`, `phase ${snapshot.phase}`];
64 return open.length > 0 ? [...parts, `open todos: ${open.join(' | ')}`] : parts;
65}
66
67/** `atlas facts:` line (repo root, branch, open todo titles) or null with no board. */
68export function factsLine(snapshot: AtlasSnapshot | null, branch: string): string | null {
69 if (snapshot?.root == null) return null;
70 return `atlas facts: ${factsParts(snapshot, branch).join('; ')}`;
71}
72
73// ---- $-carrying helpers ---------------------------------------------------------
74
75async function isActive($: EngineInterface): Promise<boolean> {
76 return (await read($, isModOn)) && (await read($, isInteractive));
77}
78
79async function patch($: EngineInterface, fn: (d: Discipline) => Partial<Discipline>): Promise<void> {
80 await update($, disciplineState, (d) => ({ ...d, ...fn(d) }));
81}
82
83/** One Atlas toast per TOAST_GAP_MS, however many rules fire. */
84async function toastOnce($: EngineInterface, text: string): Promise<void> {
85 const now = await $.clock.now();
86 if (now - (await read($, disciplineState)).toastAt < TOAST_GAP_MS) return;
87 await patch($, () => ({ toastAt: now }));
88 $.ui.toast(text, { timeoutMs: 8000 });
89}
90
91// ---- tool.call meter ----------------------------------------------------------------
92
93type ToolEvent = { agentId?: string; tool: string };
94type ToolResult = { text?: string };
95
96const bytesOf = (result: ToolResult): number => result.text?.length ?? 0;
97const isHeavyCall = (tool: string, bytes: number): boolean => ROLE[tool] !== undefined && bytes >= HEAVY_CHARS;
98
99async function trackToolStart($: EngineInterface, agent: string, tool: string, input?: unknown): Promise<void> {
100 try {
101 const t = await $.clock.now();
102 await update($, orchState, (o) => onToolStart(o, agent, tool, t, input));
103 } catch {
104 // the activity feed never blocks a tool
105 }
106}
107
108async function trackToolEnd($: EngineInterface, agent: string): Promise<void> {
109 try {
110 const t = await $.clock.now();
111 await update($, orchState, (o) => onToolEnd(o, agent, t));
112 } catch {
113 // the activity feed never blocks a tool
114 }
115}
116
117/** Delegation resets the meter; a heavy inline read/run counts toward the toast. */
118async function countCall($: EngineInterface, tool: string, bytes: number): Promise<void> {
119 if (tool === 'Agent') return patch($, () => ({ delegated: true, heavy: 0 }));
120 if (!isHeavyCall(tool, bytes)) return;
121 const heavy = (await read($, disciplineState)).heavy + 1;
122 await patch($, () => ({ heavy, hint: tool }));
123 if (heavy === HEAVY_LIMIT) await toastOnce($, `atlas: ${heavy} heavy inline ${tool} calls — delegate to ${ROLE[tool]}`);
124}
125
126async function meterHeavy($: EngineInterface, e: ToolEvent, result: ToolResult): Promise<void> {
127 try {
128 if (e.agentId === undefined && (await isActive($))) await countCall($, e.tool, bytesOf(result));
129 } catch {
130 // the meter never blocks a tool
131 }
132}
133
134// ---- prompt.submit ------------------------------------------------------------------
135
136/** Run `fn`, null when it throws: sharpening never blocks a prompt. */
137async function attempt<T>(fn: () => Promise<T>): Promise<T | null> {
138 try {
139 return await fn();
140 } catch {
141 return null;
142 }
143}
144
145const isComposer = (e: { origin?: { kind: string } }): boolean => (e.origin?.kind ?? 'composer') === 'composer';
146
147async function branchOf($: EngineInterface, root: string): Promise<string> {
148 const git = await $.process.run(['git', '-C', root, 'rev-parse', '--abbrev-ref', 'HEAD']);
149 return git.exitCode === 0 ? git.stdout.trim() : '';
150}
151
152const needsFacts = (e: { text: string; origin?: { kind: string } }): boolean => isComposer(e) && isVague(e.text);
153
154/** `atlas facts:` line for a vague composer prompt, else null. */
155async function sharpenLine($: EngineInterface, e: { text: string; origin?: { kind: string } }, snapshot: AtlasSnapshot | null): Promise<string | null> {
156 const root = snapshot?.root;
157 if (root == null || !needsFacts(e)) return null;
158 return attempt(async () => factsLine(snapshot, await branchOf($, root)));
159}
160
161const appendContext = (context: readonly string[] | undefined, extra: string[]): readonly string[] | undefined =>
162 extra.length === 0 ? context : [...(context ?? []), ...extra];
163
164async function promptContext(
165 $: EngineInterface,
166 e: { text: string; context?: readonly string[]; origin?: { kind: string } },
167 deps: DisciplineDeps,
168): Promise<readonly string[] | undefined> {
169 try {
170 if (!(await isActive($))) return e.context;
171 const snapshot = await read($, snapshotState);
172 const extra = [deps.reinforce(snapshot), await sharpenLine($, e, snapshot)].filter((l): l is string => !!l);
173 return appendContext(e.context, extra);
174 } catch {
175 return e.context;
176 }
177}
178
179// ---- turn.complete ------------------------------------------------------------------
180
181type TurnUsage = { input_tokens?: number; cache_creation_input_tokens?: number } | undefined;
182
183const orZero = (n: number | undefined): number => n ?? 0;
184/** New context this turn: uncached input plus freshly cached tokens. */
185const grewTokens = (usage: TurnUsage): number => orZero(usage?.input_tokens) + orZero(usage?.cache_creation_input_tokens);
186
187const phaseOf = (firstLine: string): string | null => PHASE_RE.exec(firstLine)?.[1] ?? null;
188
189function headerCheck(pattern: string, answer: string): NonNullable<HeaderCheck> {
190 const firstLine = answer.split('\n', 1)[0] ?? '';
191 const matched = new RegExp(pattern).test(firstLine);
192 return { matched, phase: matched ? phaseOf(firstLine) : null };
193}
194
195async function checkHeader($: EngineInterface, answer: string): Promise<void> {
196 const pattern = (await read($, snapshotState))?.contract.headerFirstLinePattern;
197 if (!pattern) return;
198 const check = headerCheck(pattern, answer);
199 await update($, lastHeaderState, () => check);
200 if (!check.matched) await toastOnce($, 'atlas: reply is missing the ATLAS | <phase> | <state> header');
201}
202
203async function nudgeBigTurn($: EngineInterface, usage: TurnUsage, d: Discipline, role: string): Promise<void> {
204 const grew = grewTokens(usage);
205 if (grew >= BIG_TURN_TOKENS && d.heavy > 0 && !d.delegated) {
206 await toastOnce($, `atlas: that turn added ${Math.round(grew / 1000)}k tokens inline — dispatch ${role}`);
207 }
208}
209
210async function auditTurn($: EngineInterface, e: { answer: string; usage?: TurnUsage }): Promise<void> {
211 await checkHeader($, e.answer);
212 const d = await read($, disciplineState);
213 const role = ROLE[d.hint] ?? 'atlas:explorer';
214 await nudgeBigTurn($, e.usage, d, role);
215 await patch($, () => ({ heavy: 0, delegated: false }));
216}
217
218// ---- hooks ------------------------------------------------------------------------
219
220export interface DisciplineDeps {
221 /** Contract reminder appended to every prompt (null = none). */
222 reinforce: (snapshot: AtlasSnapshot | null) => string | null;
223}
224
225/** Wires tool.call (heavy-inline meter) and prompt.submit (reinforce + sharpen). */
226export function registerDiscipline(on: On, deps: DisciplineDeps): void {
227 on('tool.call', async ($, e, next) => {
228 const agent = e.agentId ?? LEAD;
229 await trackToolStart($, agent, e.tool, e); // tool args ride flat on the event, like routing.ts reads Agent args
230 const result = await next(e);
231 await trackToolEnd($, agent);
232 await meterHeavy($, e, result);
233 return result;
234 });
235
236 on('prompt.submit', async ($, e, next) => {
237 const context = await promptContext($, e, deps);
238 return context === e.context ? next(e) : next({ ...e, context });
239 });
240
241 // A distinct matcher from register.ts's { reason: 'answer' } hook: one hook per matcher.
242 on('turn.complete', { reason: 'answer', isAborted: false }, async ($, e, next) => {
243 try {
244 if (e.agentId === undefined && (await isActive($))) await auditTurn($, e);
245 } catch {
246 // a drift check never blocks the answer
247 }
248 return next(e);
249 });
250}
251mod/restyle.tsx 317 lines1// Restyled built-in sites for the Atlas mod (plan docs/plans/2026-10-09-atlas-mod.md §3.3).
2// Spinner, ToolUse (Agent/Task atlas personas), AssistantMessage, TurnDuration, PromptHint.
3// Every site falls back to the engine's own drawing (`next(e)`) when the snapshot is
4// missing, ATLAS_MOD=off, or anything in the draw path throws: restyles decorate, never break.
5
6import { atom, read, update } from 'claude-code'
7import type { AgentInfo, EngineInterface, On, RenderElement, RenderNode } from 'claude-code'
8import { BRAND } from './contract'
9import type { AgentState, AtlasSnapshot, Persona, PhaseId } from './contract'
10import { contractGlyph, fitText, formatDuration, glyph, MOCHA, phaseHue, textWidth } from './theme'
11import { MARK as BRAND_MARK, shortTool } from './band'
12import type { ActivityEntry, LiveSnapshot } from './orchestra-types'
13import { activeCount, liveWorkers } from './data/live'
14import type { LiveWorker } from './data/live'
15import { bubbleTree, waveTree, workerCard } from './transcript/cards'
16import type { CardExtra } from './transcript/cards'
17import { cardExtra, launchedId, notificationRow } from './transcript/outcome'
18import type { Outcome } from './transcript/outcome'
19
20// The live snapshot is read from $.state while drawing, so a snapshot write redraws
21// exactly the restyled sites (null outside an atlas session).
22const snapshotState = atom({ plugin: 'atlas', key: 'snapshot' } as const, null)
23const headerPhaseState = atom({ plugin: 'atlas', key: 'headerPhase' } as const, null) // register.ts sets it per answer
24
25// ---- structural props the tree builders read (sites' real props satisfy these) ----
26
27export interface ToolUsePropsLike { tool: string; output?: unknown; input: unknown; isRunning: boolean; isErrored: boolean; isInterrupted: boolean }
28export interface AssistantPropsLike { text: string; isFirstOfReply?: boolean; isSummary?: true }
29export interface TurnDurationPropsLike { durationMs: number }
30/** Turn-over-turn memory for the TurnDuration receipt (owned by one registerRestyle call). */
31export interface ReceiptTracker { done?: number; turnStart?: number | null }
32
33// ---- plain element builders (StyledElement is plain data; no DOM, no globals) ----
34
35type ElemProps = Record<string, string | number | boolean>
36// 10+ call sites across the five sites: locks the StyledElement plain-data shape in one place.
37const txt = (props: ElemProps, ...children: RenderNode[]): RenderElement => ({ type: 'Text', props, children })
38
39// Width-safe brand mark (1 cell everywhere).
40const MARK = '▸'
41export const PHASE_RE = /\b(research|theory|test|validate|implement|verify|done|blocked)\b/
42
43/** The persona behind an `atlas:<name>` subagentType, from the snapshot's roster. */
44export function personaFor(snap: AtlasSnapshot, subagentType?: string): Persona | undefined {
45 if (!subagentType || !subagentType.startsWith('atlas:')) return undefined
46 const name = subagentType.slice('atlas:'.length)
47 return snap.personas.find((p) => p.name === name)
48}
49
50// ---- Spinner: the engine's own spinner plus ` · ⏸ blocked · working tool` (header/band own phase and counts) ----
51
52/** Squad + activity as live workers; a bare AtlasSnapshot has no activity, so every worker reads as working. */
53const workersOf = (snap: AtlasSnapshot): LiveWorker[] => liveWorkers({ activity: {}, comms: [], todoGroups: [], ...snap }, snap.now)
54
55export function spinnerSuffix(snap: AtlasSnapshot): string {
56 const ws = workersOf(snap)
57 const blocked = ws.find((w) => w.state === 'blocked')
58 const parts = [
59 ...(blocked ? [`⏸ ${blocked.name}`] : []),
60 ...ws.filter((w) => w.state === 'working').slice(0, 2).map((w) => `${w.name} ${w.tool ?? ''}`.trim()),
61 ]
62 return parts.length ? ` ${glyph.dot} ${parts.join(` ${glyph.dot} `)}` : ''
63}
64
65// ---- ToolUse: deployment card for persona spawns; one-line summary for huge Bash/Read/Grep output ----
66
67const NOISY = new Set(['Bash', 'Read', 'Grep'])
68const NOISY_BYTES = 8000
69
70function fmtBytes(n: number): string {
71 return n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}MB` : n >= 1000 ? `${(n / 1000).toFixed(1)}kB` : `${n}B`
72}
73
74const isSettled = (p: ToolUsePropsLike): boolean => !p.isRunning && !p.isErrored && !p.isInterrupted
75
76const isNoisyCandidate = (p: ToolUsePropsLike & { output?: unknown }): boolean =>
77 NOISY.has(p.tool) && isSettled(p) && p.output != null
78
79const outputSize = (output: unknown): number => {
80 try { return typeof output === 'string' ? output.length : JSON.stringify(output).length } catch { return 0 }
81}
82
83const targetText = (i: { command?: string; file_path?: string; pattern?: string }): string =>
84 String(i.command ?? i.file_path ?? i.pattern ?? '')
85
86const toolTarget = (input: unknown): string =>
87 targetText((input ?? {}) as { command?: string; file_path?: string; pattern?: string }).split('\n', 1)[0]?.slice(0, 60) ?? ''
88
89
90/** `Bash npm test ▸ 48.2kB` for a finished call whose output is huge; null keeps the engine's row. */
91export function noisyTree(props: ToolUsePropsLike & { output?: unknown }): RenderElement | null {
92 if (!isNoisyCandidate(props)) return null
93 const size = outputSize(props.output)
94 if (size < NOISY_BYTES) return null
95 return txt({},
96 txt({ color: BRAND.subagent, bold: true }, `${props.tool} `),
97 txt({ color: BRAND.dim }, toolTarget(props.input)),
98 txt({ color: BRAND.accent, inverse: true }, ` ▸ ${fmtBytes(size)} `),
99 )
100}
101
102const STATUS_TO_STATE: Record<string, AgentState> = {
103 pending: 'spawning', running: 'running', waiting: 'input', idle: 'idle',
104 completed: 'finished', failed: 'failed', killed: 'dead',
105}
106
107/** Status when the session lists no agent for the call: no output and not running = the call is still being written. */
108const fallbackStatus = (props: ToolUsePropsLike): string => {
109 if (props.isRunning) return 'running'
110 if (props.isErrored) return 'failed'
111 if (props.isInterrupted) return 'killed'
112 return props.output === undefined ? 'pending' : 'completed'
113}
114
115function agentStatus(
116 agents: AgentInfo[],
117 input: { subagentType?: string; name?: string; description?: string },
118 props: ToolUsePropsLike,
119): { state: AgentState; label: string; name?: string } {
120 const id = launchedId(props.output)
121 const agent =
122 (id ? agents.find((a) => a.id === id) : undefined) ??
123 (input.name ? agents.find((a) => a.name === input.name) : undefined) ??
124 (input.description ? agents.find((a) => a.description === input.description) : undefined) ??
125 (props.isRunning && input.subagentType
126 ? agents.find((a) => a.type === input.subagentType && a.status === 'running')
127 : undefined)
128 const raw = agent?.status ?? fallbackStatus(props)
129 return { state: STATUS_TO_STATE[raw] ?? 'running', label: raw, name: agent?.name }
130}
131
132/** Deployment card: bordered `◆ impl-auth · implementer · sonnet·low ● running`. Null keeps the engine's row. */
133export function toolUseTree(snap: AtlasSnapshot, props: ToolUsePropsLike, agents: AgentInfo[], columns = 0, extra: CardExtra = {}): RenderElement | null {
134 if (props.tool !== 'Agent' && props.tool !== 'Task') return noisyTree(props)
135 const input = (props.input ?? {}) as { subagentType?: string; subagent_type?: string; name?: string; description?: string }
136 const persona = personaFor(snap, input.subagentType ?? input.subagent_type)
137 if (!persona) return null
138 const status = agentStatus(agents, { ...input, subagentType: input.subagentType ?? input.subagent_type }, props)
139 const call = { ...props, input: { model: persona.model, effort: persona.effort, ...input } }
140 const now = nowOf(snap, status.name ?? input.name)
141 return workerCard(call, status.state, columns > 8 ? columns - 2 : 0, status.name ?? persona.name, status.label, { ...(now ? { now } : {}), ...extra })
142}
143
144const activityOf = (snap: AtlasSnapshot, name?: string): ActivityEntry | undefined => (snap as Partial<LiveSnapshot>).activity?.[name ?? '']
145
146/** `ctx_read register.ts` from the tool in flight, else the last one; '' before the first call. */
147const doingOf = (a: ActivityEntry): string => {
148 const tool = a.tool ?? a.lastTool
149 return tool ? `${shortTool(tool)} ${a.target ?? ''}`.trim() : ''
150}
151
152/** What a live worker is doing, from the snapshot's activity: `ctx_read register.ts · 1m12s · 9 tools`. */
153function nowOf(snap: AtlasSnapshot, name?: string): string {
154 const a = activityOf(snap, name)
155 if (!a) return ''
156 return [doingOf(a), `${formatDuration(snap.now - (a.startedAt ?? a.since))} · ${a.toolCount} tools`].filter(Boolean).join(' · ')
157}
158
159// ---- AssistantMessage: the ATLAS contract header drawn on one styled row; headerless replies stay engine-drawn ----
160
161const PILL_NARROW = 60 // below this many columns the header keeps its two pills only
162const fixed = { wrap: 'truncate-end' }
163const isNarrow = (columns: number): boolean => columns > 0 && columns < PILL_NARROW
164const firstText = (k: RenderNode): string => (k as { children: string[] }).children[0] ?? ''
165
166/** ` ◆ ATLAS ` on violet and ` ⚒ IMPLEMENT ` on the phase hue, both dark bold text. */
167function pills(mid: string, phase: PhaseId): RenderNode[] {
168 const mark = contractGlyph(mid.trim().split(/\s+/)[0] ?? '')
169 return [
170 txt({ ...fixed, color: MOCHA.base, backgroundColor: MOCHA.violet, bold: true }, ` ${BRAND_MARK} ATLAS `),
171 txt({ ...fixed, color: MOCHA.base, backgroundColor: phaseHue(phase), bold: true }, ` ${mark} ${phase.toUpperCase()} `),
172 ]
173}
174
175/** ` 3/5` muted, then ` sentence` cut to `room` cells. ponytail: room <= 0 (width unknown) draws it whole; the
176 * truncate-end wrap is the backstop. */
177function tailOf(nm: RegExpExecArray | null, sentence: string, room: number): RenderNode[] {
178 const count = nm ? ` ${nm[1]}/${nm[2]}` : ''
179 const left = room - textWidth(count) - 1
180 const rest = sentence ? ` ${left > 0 ? fitText(sentence, left) : sentence}` : ''
181 return [txt({ ...fixed, color: MOCHA.muted }, count), txt(fixed, rest)].filter((k) => firstText(k) !== '')
182}
183
184/** Filled pills then the state text, never a glued pipe; under 60 columns the two pills only. */
185function headerPills(mid: string, phase: PhaseId, nm: RegExpExecArray | null, sentence: string, columns: number): RenderNode[] {
186 const kids = pills(mid, phase)
187 if (isNarrow(columns)) return kids
188 return [...kids, ...tailOf(nm, sentence, columns - kids.reduce<number>((n, k) => n + textWidth(firstText(k)), 0))]
189}
190
191/** `ATLAS | ⚒ implement 3/5 | sentence` drawn in place as filled pills, body kept under it. */
192export function assistantTree(snap: AtlasSnapshot, props: AssistantPropsLike, columns = 0): RenderElement | null {
193 if (props.isSummary || !props.text) return null
194 const first = props.text.split('\n', 1)[0] ?? ''
195 let re: RegExp
196 try { re = new RegExp(snap.contract.headerFirstLinePattern) } catch { return null }
197 if (!re.test(first)) return null
198 const [, mid = '', ...tail] = first.split('|')
199 const phase = (PHASE_RE.exec(mid)?.[1] ?? snap.phase) as PhaseId
200 const nm = /(\d+)\s*\/\s*(\d+)/.exec(mid)
201 const rest = props.text.slice(first.length).replace(/^\n/, '')
202 const kids = headerPills(mid, phase, nm, tail.join('|').trim(), columns)
203
204 const row: RenderElement = { type: 'Box', props: { flexDirection: 'row', flexShrink: 0 }, children: kids }
205 if (!rest.trim()) return row
206 return { type: 'Box', props: { flexDirection: 'column' }, children: [row, { type: 'Markdown', props: { text: rest } }] }
207}
208
209// ---- TurnDuration: `▸ 4m12s · +3 done · 2 workers · ctx 41%` ----
210
211export function durationTree(snap: AtlasSnapshot, props: TurnDurationPropsLike, tracker: ReceiptTracker, now: number | null = null): RenderElement {
212 // The host figure has been seen reading 9m02s for a 77s turn: the mod's own clock caps it.
213 const measured = tracker.turnStart != null && now != null ? Math.max(0, now - tracker.turnStart) : null
214 const durationMs = measured === null ? props.durationMs : Math.min(props.durationMs, measured)
215 const delta = snap.counts.done - (tracker.done ?? snap.counts.done)
216 const active = activeCount(workersOf(snap))
217 const parts = [
218 formatDuration(durationMs),
219 delta > 0 ? `+${delta} done` : null,
220 active > 0 ? `${active} workers` : null,
221 snap.contextPct > 0 ? `ctx ${snap.contextPct}%` : null,
222 ].filter((part): part is string => part !== null)
223 tracker.done = snap.counts.done
224 return txt({ dimColor: true },
225 txt({ color: BRAND.accent }, `${MARK} `),
226 parts.join(' · '),
227 )
228}
229
230// ---- registration ----
231
232/** Resolves the snapshot a site may restyle with: null when the mod is off.
233 * `cache` holds the per-registration ATLAS_MOD read; registerRestyle owns one.
234 * ponytail: read once per registration (one per session); a mid-session flip
235 * to off is picked up on reload. */
236async function ready($: EngineInterface, cache: { envOff?: boolean }): Promise<AtlasSnapshot | null> {
237 if (cache.envOff === undefined) cache.envOff = (await $.env.get('ATLAS_MOD')) === 'off'
238 return cache.envOff ? null : await read($, snapshotState)
239}
240
241/** The engine clock in ms, or null where it is unavailable. */
242async function nowMs($: EngineInterface): Promise<number | null> {
243 try { return await $.clock.now() } catch { return null }
244}
245
246export function registerRestyle(on: On): void {
247 const cache: { envOff?: boolean } = {}
248 const tracker: ReceiptTracker = {}
249 const outcomes = new Map<string, Outcome>() // ended workers' result lines, per registration
250
251 // The clock that bumps spinnerTick lives in register.ts; reading it while drawing
252 // subscribes this Spinner instance so each tick redraws the globe.
253 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
254 try {
255 const snap = await ready($, cache)
256 return next(snap ? { ...e, props: { ...e.props, suffix: spinnerSuffix(snap) } } : e)
257 } catch { return next(e) }
258 })
259
260 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
261 try {
262 const snap = await ready($, cache)
263 if (!snap) return next(e)
264 const agents = await $.agent.list().catch(() => [] as AgentInfo[])
265 const extra = e.props.tool === 'Agent' || e.props.tool === 'Task' ? await cardExtra($, e.props.output, agents, outcomes) : {}
266 return toolUseTree(snap, e.props, agents, e.viewport?.columns ?? 0, extra) ?? next(e)
267 } catch { return next(e) }
268 })
269
270 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
271 try {
272 const snap = await ready($, cache)
273 return snap ? (assistantTree(snap, e.props, e.viewport?.columns ?? 0) ?? next(e)) : next(e)
274 } catch { return next(e) }
275 })
276
277 on('ui.render', { component: 'TurnDuration' }, async ($, e, next) => {
278 try {
279 const snap = await ready($, cache)
280 return snap ? durationTree(snap, e.props, tracker, await nowMs($)) : next(e)
281 } catch { return next(e) }
282 })
283
284 // Turn clock at turn start. A header phase belongs to the turn that wrote
285 // it, so a new turn starts without one (the engine allows one un-matchered turn.start per module: this one).
286 // ponytail: turn.start carries no loop id, so a subagent's turn start clears it too; the next answer restores it.
287 on('turn.start', async ($, e, next) => {
288 tracker.turnStart = await nowMs($)
289 await update($, headerPhaseState, () => null).catch(() => undefined)
290 return next(e)
291 })
292
293 // Waves: 2+ atlas dispatches grouped by the engine draw as one strip. Wave numbers are
294 // per registration, keyed by the group's first call id.
295 const waves = new Map<string, number>()
296 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
297 try {
298 if (!(await ready($, cache))) return next(e)
299 const calls = (e.props.calls ?? []) as ReadonlyArray<ToolUsePropsLike & { tool_use_id?: string }>
300 const key = calls[0]?.tool_use_id ?? String(waves.size)
301 if (!waves.has(key)) waves.set(key, waves.size + 1)
302 return waveTree(calls, waves.get(key) ?? 1, e.viewport?.columns ?? 0) ?? next(e)
303 } catch { return next(e) }
304 })
305
306 // Channel / agent messages as chat bubbles; a background atlas worker's notification as its result
307 // row (the engine folds a batch's launch rows into a tree no hook sees). Prompts and ctrl+o stay engine-drawn.
308 on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
309 try {
310 if (e.props.isExpanded || e.props.origin?.kind === 'composer') return next(e)
311 if (!(await ready($, cache))) return next(e)
312 if (e.props.task) return (await notificationRow($, e.props.task, outcomes)) ?? next(e)
313 return bubbleTree(e.props.text, e.props.from?.name, 'lead', e.viewport?.columns ?? 0) ?? next(e)
314 } catch { return next(e) }
315 })
316}
317mod/notify.ts 134 lines1// notify.ts — turn-boundary notifications: toast workers spawning/finishing/failing/blocked,
2// notes addressed to the lead or the human, completed todos, and phase advances
3// (advance.wav when sound is on); the one prompt.suggest: reply to a blocked worker, else the in-progress todo.
4import { atom, read } from 'claude-code';
5import type { On } from 'claude-code';
6import type { AtlasSnapshot, PhaseId } from './contract';
7import { liveWorkers } from './data/live';
8import type { LiveWorker } from './data/live';
9import type { LiveSnapshot } from './orchestra-types';
10import { formatDuration } from './theme';
11
12export type NotifyEvent =
13 | { type: 'agent-spawn'; name: string }
14 | { type: 'agent-complete'; name: string; age: string; lastLine: string }
15 | { type: 'agent-fail'; name: string; lastLine: string }
16 | { type: 'agent-blocked'; name: string; lastLine: string }
17 | { type: 'note'; sender: string; preview: string }
18 | { type: 'todo-done'; text: string }
19 | { type: 'phase'; phase: PhaseId };
20
21/** Squad + activity as live workers; a snapshot without orchestration fields reads as activity-free. */
22const workersOf = (s: AtlasSnapshot): LiveWorker[] =>
23 liveWorkers({ activity: {}, comms: [], todoGroups: [], ...s, squad: (s.squad ?? []).filter((a) => a?.name) } as LiveSnapshot, s.now);
24
25const fit = (text: string, n: number): string => (text.length > n ? `${text.slice(0, n - 1)}…` : text);
26
27/** Pure diff of successive snapshots; null prev (first sight) yields nothing. Never throws. */
28export function diffSnapshots(prev: AtlasSnapshot | null, next: AtlasSnapshot): NotifyEvent[] {
29 if (!prev) return [];
30 const events: NotifyEvent[] = [];
31
32 // Workers: toast spawn and the transition into done/failed/blocked, once per change.
33 const before = new Map(workersOf(prev).map((w) => [w.name, w.state]));
34 for (const w of workersOf(next)) {
35 const was = before.get(w.name);
36 if (was === w.state) continue;
37 if (was === undefined) {
38 if (w.state === 'working') events.push({ type: 'agent-spawn', name: w.name });
39 } else if (w.state === 'done') {
40 const age = next.now - w.startedAt;
41 events.push({ type: 'agent-complete', name: w.name, age: age > 0 ? formatDuration(age) : '', lastLine: w.lastLine });
42 } else if (w.state === 'failed') events.push({ type: 'agent-fail', name: w.name, lastLine: w.lastLine });
43 else if (w.state === 'blocked') events.push({ type: 'agent-blocked', name: w.name, lastLine: w.lastLine });
44 }
45
46 // New notes addressed to the lead or the human (seq not seen before).
47 const seenSeqs = new Set((prev.notes ?? []).map((n) => n?.seq));
48 for (const n of next.notes ?? []) {
49 if (!n || seenSeqs.has(n.seq)) continue;
50 if (!/lead|human/i.test(n.to ?? '')) continue;
51 events.push({ type: 'note', sender: n.owner ?? 'unknown', preview: (n.text ?? '').slice(0, 60) });
52 }
53
54 // Todos newly completed.
55 const prevStatus = new Map<string, string>();
56 for (const t of prev.todos ?? []) if (t?.id) prevStatus.set(t.id, t.status ?? '');
57 for (const t of next.todos ?? []) {
58 if (!t?.id || t.status !== 'completed' || prevStatus.get(t.id) === 'completed') continue;
59 events.push({ type: 'todo-done', text: t.content ?? '' });
60 }
61
62 // Phase advance.
63 if (prev.phase && next.phase && prev.phase !== next.phase) {
64 events.push({ type: 'phase', phase: next.phase });
65 }
66
67 return events;
68}
69
70/** The one prompt suggestion: reply to the first blocked worker, else the in-progress todo as `<phase>: <title>`, else null. */
71export function nextStepSuggestion(s: AtlasSnapshot): string | null {
72 const blocked = workersOf(s).find((w) => w.state === 'blocked');
73 if (blocked) return `reply ${blocked.name}: ${blocked.lastLine}`;
74 const t = (s.todos ?? []).find((x) => x?.id && x.status === 'in_progress' && !x.archived);
75 // ponytail: strip any "[phase] " prefix generically instead of parsing contract.itemPhasePrefix
76 return t ? `${t.phase ?? s.phase}: ${(t.content ?? '').replace(/^\[[^\]]*\]\s*/, '')}` : null;
77}
78
79// The live snapshot is read from $.state (declared here for the key scan).
80const snapshotState = atom({ plugin: 'atlas', key: 'snapshot' } as const, null);
81
82let lastSnapshot: AtlasSnapshot | null = null;
83
84/**
85 * Registers answer-turn-boundary notifications. `on` is the mod's On; deps
86 * inject the sound toggle (the snapshot is read from $.state). Toasts and the next-step
87 * suggestion fire on answer-turn boundaries. Notifications never break the turn.
88 */
89export function registerNotify(
90 on: On,
91 deps: { soundEnabled: () => boolean },
92): void {
93 on('turn.complete', { reason: 'answer' }, async ($, e, next) => {
94 try {
95 const snap = await read($, snapshotState);
96 if (snap) {
97 for (const ev of diffSnapshots(lastSnapshot, snap)) {
98 switch (ev.type) {
99 case 'agent-spawn':
100 $.ui.toast(`▸ ${ev.name} spawned`);
101 break;
102 case 'agent-complete':
103 $.ui.toast(`✓ ${ev.name}${ev.age ? ` ${ev.age}` : ''}${ev.lastLine ? ` · ${fit(ev.lastLine, 40)}` : ''}`);
104 break;
105 case 'agent-fail':
106 $.ui.toast(`✗ ${ev.name} ${ev.lastLine}`.trimEnd());
107 break;
108 case 'agent-blocked':
109 $.ui.toast(`⏸ ${ev.name} blocked: ${ev.lastLine}`.trimEnd());
110 break;
111 case 'note':
112 $.ui.toast(`${ev.sender}: ${ev.preview}`);
113 break;
114 case 'todo-done':
115 $.ui.toast(`✓ ${ev.text}`);
116 break;
117 case 'phase':
118 if (deps.soundEnabled()) {
119 void $.audio.play({ asset: 'mod/fx/advance.wav' }).catch(() => {});
120 }
121 break;
122 }
123 }
124 lastSnapshot = snap;
125 const suggestion = nextStepSuggestion(snap);
126 if (suggestion) void $.prompt.suggest({ text: suggestion });
127 }
128 } catch {
129 // a bad snapshot must never break the turn
130 }
131 return next(e);
132 });
133}
134