SLOPSHOPPER

atlas

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

newpanebandspinnerrowsguard
★ 2v12.4.5Apache-2.0updated 2026-10-15w159/tech-tools/plugins/atlas
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · atlas
│ ┃ Atlas ✕ › fix the failing auth test and add an audit log call │ ┃ r: route │ t: team │ c: channel │ g: gates │ │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ◆ ATLAS · RESEARCH │ │ ▸ 0s · ctx 49% │ │ › /atlas-cc │ │ ◆ ATLAS · idle tasks 0/0 r: route t: team c: channel g: gates o: board ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
◆ ATLAS · idle tasks 0/0 r: route t: team c: channel g: gates o: board ⟨Claude Code's own drawing⟩
Pane · Atlas
r: route │ t: team │ c: channel │ g: gates │ o: board
Turn line
▸ 0s · ctx 49%
Claude's reply
◆ ATLAS · RESEARCH
README

atlas

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.

The skill fleet (37 skills, plainly named)

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.

SkillModeWhat it does
atlas-auditautoAudits 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-autopilotautoConsent-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-bakeoffautoDecides 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-brainstormautoGrills 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-compoundautoCaptures 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-auditautoAudits a live database read-only via parallel subagents: schema inventory, schema-to-code reconciliation, privileges, naming. Use before a schema or permission change.
atlas-debugautoDiagnoses 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-doctorautoAtlas 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-dogfoodautoDrives 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-explainautoExplains 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-frontendautoBuilds, 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-gitignoreautoGenerates 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-handoffautoProduces 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-hardenautoWrites 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-loopautoMatches 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-optimizeautoSingle-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-orchestrateautoOrchestrates 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-planautoWrites 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-promptautoRewrites 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-proofautoHuman-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-prototypeautoBuilds 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-pulseautoGenerates 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-readmeautoGenerates 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-refactorautoRefactors, 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-feedbackautoResolves 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-reviewautoRisk-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-watchautoWatches 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-setupMANUALManual 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-shipautoShips 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-simplifyautoBehavior-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-statusautoBuilds 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-sweepautoTurns 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-xcodeautoTests 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-testautoUX 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-validateautoVerifies 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-wikiautoGenerates 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-worktreeautoGit-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.

Layout

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/)

Getting started

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.

Hooks

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.

HookEventPurpose
session_boot.pySessionStartActivate 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 --hookSessionStartRollback 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.pyUserPromptSubmitOptional 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.pyPreToolUse (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.pyPreToolUse (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.pyPreToolUse (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

Source 37 files
mod/register.ts 776 lines
1// 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}
776
mod/snapshot.ts 159 lines
1// 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 lines
1// 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;
107
mod/intents.ts 149 lines
1// 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}
149
mod/band.tsx 306 lines
1// 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}
306
mod/data/live.ts 55 lines
1// 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;
55
mod/pane/sidebar.tsx 85 lines
1// 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}
85
mod/gates.ts 58 lines
1// 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);
58
mod/routing.ts 156 lines
1// 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}
156
mod/discipline.ts 251 lines
1// 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}
251
mod/restyle.tsx 317 lines
1// 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}
317
mod/notify.ts 134 lines
1// 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