AOS fleet view: which Claude sessions are working, which need you, and the active projects


Language / Sprache / Idioma: English · Deutsch · Português
<b>The operating system for AI agent harnesses.</b><br/> One install: the same skills in nine harnesses. Subagents and the hook-enforced GO gate on Claude Code, Antigravity, Codex and OpenCode.
Built for <b>event and media technicians</b>, <b>designers</b> and <b>managers</b>, for <b>builders of custom installations</b> (show control, 3D, PCB and enclosure), and just as much for <b>everyday coding</b> of apps, tools and plugins. The skill library and the MCP servers cover both worlds.
AOS installs a curated skill library, a subagent roster, gate hooks and a runnable multi-agent build pipeline into every coding-agent harness on your machine.
npx -y @hybridlabor-api/aos@latest
<img alt="Claude Code" src="https://img.shields.io/badge/Claude%20Code-D97757?logo=anthropic&logoColor=white"> <img alt="Antigravity" src="https://img.shields.io/badge/Google%20Antigravity-4285F4?logo=google&logoColor=white"> <img alt="Codex CLI" src="https://img.shields.io/badge/Codex%20CLI-412991?logo=openai&logoColor=white"> <img alt="OpenCode" src="https://img.shields.io/badge/OpenCode-111827"> <img alt="Cursor" src="https://img.shields.io/badge/Cursor-000000?logo=cursor&logoColor=white"> <img alt="Windsurf" src="https://img.shields.io/badge/Windsurf-0B100F"> <img alt="Roo Code / Cline" src="https://img.shields.io/badge/Roo%20Code%20%2F%20Cline-5B21B6"> <img alt="Aider" src="https://img.shields.io/badge/Aider-14B814"> <img alt="AOS CLI" src="https://img.shields.io/badge/AOS%20CLI-pi--based-0d1117">
<sub>Recommended: the full installer above. Alternatively, use the skills CLI:</sub>
<table> <tr> <td width="100%" valign="top" align="left"><b>Install skills only</b><br/><br/><code>npx skills add hybridlabor-api/aos</code><br/><br/><sub>Gate hooks and MCP servers require the full installer. Plugin marketplace route is in progress; see <a href="#plugins-and-marketplace">Plugins and marketplace</a>.</sub></td> </tr> </table>
<sub>Skills use the open <a href="https://agentskills.io">Agent Skills</a> format (<code>SKILL.md</code>). AOS installs them for <b>Claude Code, Google Antigravity, Codex CLI, OpenCode, Cursor, Windsurf, Roo Code / Cline, Aider and the AOS CLI</b>, so all of them behave the same way.</sub>
<img src="docs/assets/readme/architecture-hero.svg" alt="How AOS fits together: nine harnesses feed one kernel of 270 skills, 45 subagents, gate hooks and 21 MCP servers, which drives three pipelines, session-to-session messaging and local tools, with the GO gate in front of every release command" width="100%">
| Harness default | With AOS | |
|---|---|---|
| Skills | Each harness has its own skill folder and its own copies. | <!-- count:skills -->270<!-- /count --> skills in eight categories, written as <name>/SKILL.md into every harness you have. One source tree. |
| Subagents | Agent files written by hand, per harness, in that harness's format. | <!-- count:agents -->45<!-- /count --> subagents compiled into each harness's native agent format (Claude Code, Antigravity, Codex, OpenCode only; others use rules in AGENTS.md). |
| Release safety | A rule in a prompt that the agent is asked to respect. | A PreToolUse hook (Claude Code, Antigravity, Codex, OpenCode) or rules in AGENTS.md (others) that blocks git push, npm publish, npm version and recursive rm until your own message is the literal word GO. |
| Multi-agent builds | Agents that call agents, with the routing inside their prompts. | A dispatcher graph: nodes never invoke each other, state is durable, a repair loop has a no-progress guard, escalation goes to you. |
| Plans | Scroll back through the chat to find what was agreed. | Plan Canvas: annotate the plan in a browser, then approve it before any agent builds. |
| Visibility | Read logs in several terminals. | agenttrail live map per repo, and a fleet band in Claude Code with gate mode, token-weather and who waits for your GO. |
| Sessions | Each session is alone in its terminal. | A2A between live Claude Code, OpenCode, agy and Codex sessions on localhost. An inbound message is never a GO. |
| Beyond code | Coding assistants know code. | 19 media-eventtech skills and MCP servers for grandMA3, Resolume, TouchDesigner, Unreal, DaVinci, After Effects, Blender and Rhino; godmode-hardware-pcb for KiCad and OpenSCAD; playbooks for offers, invoices, crew call sheets and event trackers. |
<table> <tr> <td width="33%" valign="top">🎯 <b>Skills</b><br/>270 curated skills in eight categories, discoverable by every harness as <code><name>/SKILL.md</code>.<br/>→ <a href="#skills-at-a-glance">Skills at a glance</a></td> <td width="33%" valign="top">👥 <b>Subagents</b><br/>45 subagents: Architect, TechLead, Reviewer, Godmodes and specialist reviewers.<br/>→ <a href="#the-dispatcher-graph">The dispatcher graph</a></td> <td width="33%" valign="top">🔌 <b>MCP servers</b><br/>21 servers for creative software, OS control, memory and cross-harness delegation.<br/>→ <a href="#mcp-servers">MCP servers</a></td> </tr> <tr> <td width="33%" valign="top">🚀 <b>Pipelines & GO gate</b><br/><code>/startcycle</code>, <code>/startcycle-graph</code>, <code>/startcycle-graph-user</code>, and hook-enforced release gate.<br/>→ <a href="#the-pipelines">The pipelines</a></td> <td width="33%" valign="top">💬 <b>Sessions that talk</b><br/>A2A between Claude Code, OpenCode, agy and Codex sessions on localhost.<br/>→ <a href="#a2a-sessions-that-talk-to-each-other">A2A</a></td> <td width="33%" valign="top">🛠️ <b>Tools</b><br/>Plan Canvas, agenttrail, archify, AOS Store, AOS Hub, aos sessions, fleet band, aos doctor.<br/>→ <a href="#skills-at-a-glance">Tools</a></td> </tr> </table>
<table> <tr> <td width="20%" valign="top"><b>Event & media tech</b><br/>• Live show control: grandMA3, Resolume, TouchDesigner<br/>• Media production: Unreal, DaVinci, After Effects</td> <td width="20%" valign="top"><b>Design</b><br/>• UI/UX design system and brand discovery<br/>• Landing pages, app redesigns, Plan Canvas</td> <td width="20%" valign="top"><b>Management</b><br/>• Offers, invoices, budget tracking<br/>• Crew sheets, event planning, meeting actions</td> <td width="20%" valign="top"><b>Custom builds</b><br/>• PCB design and OpenSCAD enclosures<br/>• 3D assets, Rhino, Blender modeling</td> <td width="20%" valign="top"><b>General dev</b><br/>• Backend, frontend, testing, and shipping<br/>• 45 subagents, three pipelines, library skills</td> </tr> </table>
See Playbooks for guided workflows across these domains.
| I want to... | Run |
|---|---|
| Install for every harness I have | npx -y @hybridlabor-api/aos@latest |
| Install with low context overhead | npx -y @hybridlabor-api/aos@latest --profile=minimal |
| Refresh only the gates and hooks | npx -y @hybridlabor-api/aos@latest --hooks-only |
| See what would change first | npx -y @hybridlabor-api/aos@latest --dry-run |
| Check a broken setup | aos doctor |
| Open the local control page (services, catalog, store, plans) | aos hub |
| Browse and add skills and agents | aos store ui |
| Add the code knowledge graph | npx -y @hybridlabor-api/aos@latest -y --modules=codebase-memory |
| Plan a feature, then build it | /plan, then /startcycle-graph |
<img src="docs/assets/readme/agenttrail-live.gif" alt="agenttrail live plan map of a running multi-agent build" width="100%"><br/> <sub><b>agenttrail.</b> The live map of a multi-agent build while it runs. <a href="docs/assets/readme/agenttrail-live.mp4">▶ Full-quality video</a></sub>
<table> <tr> <td width="50%" valign="top" align="center"> <img src="docs/assets/readme/plan-canvas.png" alt="Plan Canvas with an annotation on a plan element" width="100%"><br/> <sub><b>Plan Canvas.</b> Annotate the plan in the browser, then approve it before any agent builds.</sub> </td> <td width="50%" valign="top" align="center"> <img src="docs/assets/readme/ao-pipeline.png" alt="AO workspace: the AOS Pipeline monitor with plan, gate, build, review and ship stages and the role-to-model routing" width="100%"><br/> <sub><b>AO workspace.</b> The pipeline monitor, plan to ship, with role and model routing per node.</sub> </td> </tr> <tr> <td width="50%" valign="top" align="center"> <img src="docs/assets/readme/aos-store.png" alt="AOS Store web UI: skills, agents and playbooks by scope, category and source" width="100%"><br/> <sub><b>AOS Store.</b> Skills, agents and playbooks on 127.0.0.1:4322.</sub> </td> <td width="50%" valign="top" align="center"> <img src="docs/assets/readme/codenotch-usage.png" alt="Codenotch: agents running and busy per harness, AOS and AO versions" width="100%"><br/> <sub><b>Codenotch.</b> Agents running and busy per harness.</sub> </td> </tr> </table>
<img src="docs/assets/readme/fleet-band.png" alt="Fleet band in Claude Code showing gate mode, context use and sessions with their GO counts" width="100%"><br/> <sub><b>Fleet band.</b> In Claude Code: gate mode, token-weather, and the sessions that wait for your GO.</sub>
<img src="docs/assets/readme/aos-installer.gif" alt="AOS installer: boot animation, pre-flight telemetry and the setup menu" width="100%"><br/> <sub><b>Installer.</b> <code>npx -y @hybridlabor-api/aos@latest</code>: boot, pre-flight telemetry, then the setup menu. <a href="docs/assets/readme/aos-installer.mp4">▶ Full-quality video</a></sub>
/startcycle - linear build pipeline (Architect, TechLead, parallel build, Reviewer) with file hand-offs in production_artifacts//startcycle-graph - dispatcher graph with durable state.json, Reviewer repair loop, quality gate and escalationarchitect, techlead, godmode-ui-ux, godmode-engineering, godmode-media-eventtech, reviewer and godmode-shipping; they never call each other/startcycle-graph-user - throwaway 2-4 node fan-out for any project, nothing persistent left behind/plan - draft, render in a browser canvas, annotate, await approval, then hand offplan-canvas - local canvas where you annotate elements, chat and approve or request changesplan-arbiter - compare competing plans from several agents and produce one recommended plan/bdbrainstorm - multi-agent brainstorm that ends in a hand-off to /startcycle-graph/grill-me - relentless interview to sharpen a plan or designbdbmediastorm - brainstorming for live event tech, show control and real-time media/playbooks - list every pb-* playbook with duration, difficulty and requirements, and start onepb-bug-fix - GitHub issue to tested fix and PR; push and PR only after GOpb-ship - triage, review and gate the day's PRs; merge only after GOpb-release-aos - release AOS to npm through the release-please PRpb-show-build - build a show across grandMA3, Resolume and TouchDesigner, offline firstpb-offer - draft a client offer priced from your price list; send only after GOfactory-collect - Factory: collect and triage feedback, telemetry, errors and issue reportsfactory-lookback - audit recurring problems across sources for systemic fixesfactory-review-prs - review a configured PR queue; approve and merge stay with youfactory-human-digest - read-only digest of what still needs a human decisiongogate - show or explain the go-gate mode (hard, soft, off) and the time-limited grants; CLI: aos-gogateaos-a2a - list, send to and reply to live harness peers over A2A on localhostmaster-session - supervise several sessions: roster, status requests, GO board; workers can be spawned through aos-acpmcsc - delegate a task to another installed CLI harness (agy, OpenCode, Codex)agenttrail - live browser map of a multi-agent build; CLI: aos-trailaos-store - browse, preview and install skills and agents from a local web UI (aos store)memb-skill - local-first long-term memory engine (memB)memb-ingest - ingest project files and conversation logs into memBopenwiki-skill - initialize, update and visualize codebase wikis with OpenWikisynapse-integration-skill - BDB Synapse 3D codebase visualizer integration/doctor - check the machine and the current project, print suggested permissions.allow entries, write no settingsaos-setup - bring a machine to a complete, verified AOS installationaos-project-init - set up a project folder: slug, wiki, memory, Synapse map, AGENTS.mdgodmode-engineering - strict DDD, TypeScript strictness, Clean Architecture, 5-step debugging triagegodmode-ui-ux - brand discovery, Anti-Slop rules, DTCG design tokens, fluid motiongodmode-eventtech - show-control execution for grandMA3, Resolume, Unreal, Rhino, Vectorworks and Adobe MCPgodmode-hardware-pcb - schematics, PCB layout, KiCad and OpenSCAD enclosures with DFM/DRC/ERC sign-offgodmode-shipping - final gatekeeper: pre-launch checks, feature-flag rollouts, rollback planningread-the-damn-docs - forces a docs pass before coding third-party APIs from memorystay-within-limits - respect 5-hour and weekly usage limits in long or parallel runsquick-recap - end each response with a red/yellow/green status lineFull catalogs (every skill, subagent, playbook and MCP server) are in the collapsible lists under What's included.
v5 changes how AOS gets onto a machine and how its parts talk to each other.
| Area | What changed |
|---|---|
| Installer | Rebuilt flow: Kernel, Harnesses, Packages, Optional, Verify. Profiles minimal, standard, full; --no-hooks, --hooks-only, --without=<ids>; repair and uninstall from the manifest; one version check at the start with Update as the default. See Install and docs/install-options.md. |
| MCP gateway | aos-gateway: one local endpoint (1mcp behind a token-checking forwarder) in front of an allow list of shipped servers, aos-gateway adopt for your own stdio servers, and one MCP per app (Blender, Resolume, After Effects). Installed with the default profile; opt out with aos-gateway direct, --without=gateway or --profile=minimal. docs/mcp-gateway.md. |
| A2A | Live Claude Code, OpenCode, agy and Codex sessions message each other with aos-a2a. An inbound message is never a GO. Any MCP-capable harness can join through the aos-a2a MCP server. docs/a2a.md. |
| Native agents | Subagents are compiled into the formats and paths each harness really reads, including OpenCode and agy. No model is pinned unless you set one: docs/agent-models.md. |
| Fleet mod | A band in Claude Code: gate mode, token-weather (context percent as a weather word plus a sparkline), and which sessions work or wait for you with their GO counts. aos-gogate shows gate state read-only. |
| AOS Hub | One local page for everything AOS runs on demand, on 127.0.0.1:7900: Services, Catalog, Store and Plans. aos hub, aos services, aos restart. It replaces the old Launchpad HTTP server; aos-dashboard is now a thin alias. docs/hub.md. |
| agenttrail | One live map per repo, started automatically, with a link in your session. A v2 control room is in progress. |
| Plan Canvas | Plans open in a local canvas; annotations are dispatched back to the agent. |
| go-gate | Read, test and build commands with no write or network effect pass without a GO; deny logs mask credentials; hooks in four harnesses; a machine-wide gate profile (strict, work, off) narrows or widens it. |
| Playbooks and Factory | 35 pb-* playbooks, the nine factory-* skills and plan-arbiter. |
| Orchestrator chain | master session, project orchestrator, package orchestrator, mapped to real mechanisms. |
In progress, not claimed as shipped. agenttrail v2 control room, A2A intercom and a2abook, A2A spawn mode, skill-create and instinct-*.
The graph is harness-neutral and runs on Claude Code's Dynamic Workflows, Antigravity's parallel execution, and any compatible agent harness.
%%{init: {"theme": "base", "themeVariables": {"lineColor": "#6e7681", "edgeLabelBackground": "#f6f1e8", "textColor": "#15171A", "clusterBkg": "#F7F4EC", "clusterBorder": "#B9B5AA", "titleColor": "#15171A", "primaryColor": "#FFFFFF", "primaryBorderColor": "#15171A", "primaryTextColor": "#15171A"}}}%%
flowchart TB
subgraph SG[" "]
direction TB
U(["User"])
A["<b>Architect</b><br/>System Plan"]
T["<b>TechLead</b><br/>Capability Map"]
UX["<b>UI_UX</b><br/>Frontend"]
EN["<b>Engineering</b><br/>Backend"]
ME["<b>Media_EventTech</b><br/>Creative"]
R["<b>Reviewer</b><br/>QA"]
S["<b>Shipping</b><br/>Gate"]
U --> A --> T
T --> UX & EN & ME
UX & EN & ME --> R
R --> S
T -.->|reject| A
R -.->|findings| UX
S -.->|gate fail| EN
R -.->|escalate| U
end
classDef plan fill:#E6F4EA,stroke:#1A7F3C,color:#15171A
classDef build fill:#FFFFFF,stroke:#15171A,color:#15171A
classDef gate fill:#1A7F3C,stroke:#1A7F3C,color:#FFFFFF
classDef human fill:#F7F4EC,stroke:#5B6168,color:#15171A
classDef store fill:#F7F4EC,stroke:#B9B5AA,color:#15171A
linkStyle default stroke:#6e7681,stroke-width:2px
style SG fill:#F7F4EC,stroke:#B9B5AA,color:#15171A
class A,T plan
class UX,EN,ME build
class R,S gate
class U human
Requirements. Node.js >= 20. macOS, Linux and Windows (PowerShell).
npx -y @hybridlabor-api/aos@latest
First run. The installer detects which harnesses are present, asks which to target and which tier (Pro MEDIA or Basic), copies the skills into each harness's skill directory, compiles the subagents, wires the hooks, merges the MCP configuration into each harness's own config file (existing entries are kept), and offers the optional modules listed below.
Every later run opens a menu instead:
| Menu item | What it does |
|---|---|
| Quick Update | Refreshes skills, templates, hooks and installed modules to the version you just ran |
| Run System Checkup / Doctor | Runs aos doctor: dependencies, file placement, daemons, hooks |
| Drop Local Project Harness | Copies the dispatcher contract into the current directory (see below) |
| Reconfigure System | Change targets, tier, profile or options |
| Uninstall AOS | Removes what the installer placed; your data stays |
| Flag | Effect |
|---|---|
--profile=standard (default) | Skills, agents, rules, all hooks, MCPs, OpenWiki, fleet. Token Saver is off by default (use --profile=full to include it); for CLI output compression install rtk yourself, AOS does not install it. |
--profile=minimal | Skills, agents and harness rules only. No context-injection hooks, OpenWiki, Token Saver, Codenotch, optional modules, gateway or fleet. The low-context choice. |
--profile=full | standard plus every optional module (same as --modules=all with -y). |
| --no-hooks | Skips only the cont
hooks/register.tsx 1209 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { FleetActivity, FleetProgress, FleetRole, FleetSelf, FleetSession, GateBlock, GateProfile, GateState, PlanComponent, PlanTask } from '../types'
5
6const PANE = 'aos-fleet'
7const GATE_PANE = 'gogate-panel'
8const PLAN_PANE = 'aos-plan'
9const HUB_PANE = 'aos-hub'
10type PaneInput = Parameters<EngineInterface['ui']['resolve']>[0] & { props: { bodyColumns?: number } }
11const STALE_MS = 5 * 60_000
12const ROLES: FleetRole[] = ['master', 'task-manager', 'orchestrator', 'worker']
13const ATTENTION_PHASES = ['ready_to_ship', 'escalated']
14
15const fleet = atom({ plugin: 'bdb-aos-fleet', key: 'fleet' } as const, [])
16const frame_ = atom({ plugin: 'bdb-aos-fleet', key: 'frame' } as const, 0)
17const tab = atom({ plugin: 'bdb-aos-fleet', key: 'tab' } as const, 'fleet')
18const gate = atom({ plugin: 'bdb-aos-fleet', key: 'gate' } as const, { mode: 'soft', grants: [], raw: '' })
19const gateMsg = atom({ plugin: 'bdb-aos-fleet', key: 'gateMsg' } as const, '')
20const blocks = atom({ plugin: 'bdb-aos-fleet', key: 'blocks' } as const, [] as GateBlock[])
21const profile = atom({ plugin: 'bdb-aos-fleet', key: 'profile' } as const, null as GateProfile | null)
22const self = atom({ plugin: 'bdb-aos-fleet', key: 'self' } as const, {
23 activity: 'idle',
24})
25const plan = atom({ plugin: 'bdb-aos-fleet', key: 'plan' } as const, [] as PlanComponent[])
26const planFile = atom({ plugin: 'bdb-aos-fleet', key: 'planFile' } as const, '')
27
28export const roleFromName = (name: string): FleetRole =>
29 /master/i.test(name)
30 ? 'master'
31 : /task.?manager/i.test(name)
32 ? 'task-manager'
33 : /orchestr/i.test(name)
34 ? 'orchestrator'
35 : 'worker'
36
37export const groupProjects = (list: readonly FleetSession[]) => {
38 const projects = new Map<string, FleetSession[]>()
39 for (const s of list) {
40 const key = s.repo ? `${s.repo}${s.branch ? `@${s.branch}` : ''}` : '(no repo)'
41 projects.set(key, [...(projects.get(key) ?? []), s])
42 }
43 return [...projects].map(([key, sessions]) => ({
44 key,
45 sessions,
46 phase: sessions.find(s => s.phase)?.phase,
47 }))
48}
49
50const RANK: Record<FleetActivity, number> = { attention: 0, working: 1, idle: 2 }
51const CARD = 18
52const ROLE_COLOR: Record<FleetRole, number> = {
53 master: 0xd97757,
54 'task-manager': 0xd4709a,
55 orchestrator: 0x6a9be0,
56 worker: 0x6cc4b0,
57}
58const DEFAULT_COLOR = 0x01000000
59
60const SPRITE_W = 8
61const SPRITE_BODY = ['.XXXXXX.', 'XX.XX.XX', '.XXXXXX.']
62const SPRITE_LEGS = [['.X....X.'], ['X.X..X.X']]
63
64const dim = (rgb: number) =>
65 (((rgb >> 16) & 0xff) * 0.4) << 16 | (((rgb >> 8) & 0xff) * 0.4) << 8 | ((rgb & 0xff) * 0.4)
66
67export const roleColor = (s: FleetSession) =>
68 s.activity === 'idle' ? dim(ROLE_COLOR[s.role]) : ROLE_COLOR[s.role]
69
70export const fit = (text: string, width: number) =>
71 text.length > width ? `${text.slice(0, width - 1)}…` : text
72
73const hex = (rgb: number) => `#${rgb.toString(16).padStart(6, '0')}`
74
75// Half blocks pack two sprite pixel rows into one terminal row.
76export function spriteCells(s: FleetSession, frame: number) {
77 const color = roleColor(s)
78 const pixels = [...SPRITE_BODY, ...SPRITE_LEGS[s.activity === 'working' ? frame % 2 : 0]!]
79 const words: number[] = []
80 for (let r = 0; r < pixels.length; r += 2) {
81 for (let c = 0; c < SPRITE_W; c++) {
82 const top = pixels[r]![c] === 'X'
83 const bottom = pixels[r + 1]![c] === 'X'
84 const glyph = top && bottom ? 0x2588 : top ? 0x2580 : bottom ? 0x2584 : 0x20
85 words.push(glyph, glyph === 0x20 ? DEFAULT_COLOR : color, DEFAULT_COLOR)
86 }
87 }
88 const bytes = new Uint8Array(Uint32Array.from(words).buffer) as Uint8Array & { toBase64(): string }
89 return bytes.toBase64()
90}
91
92const DOT: Record<FleetActivity, { glyph: string; color: string }> = {
93 attention: { glyph: '▲', color: 'yellow' },
94 working: { glyph: '●', color: 'green' },
95 idle: { glyph: '○', color: 'gray' },
96}
97
98const PRESETS = [
99 { label: 'release 2h', text: 'gogate grant push-feature,github-write,merge,publish 2h', frees: 'push-feature · github-write · merge · publish', color: 'red' },
100 { label: 'feature 2h', text: 'gogate grant push-feature,github-write 2h', frees: 'push-feature · github-write', color: 'yellow' },
101 { label: 'soft', text: 'gogate soft', frees: '', color: undefined },
102 { label: 'hard', text: 'gogate hard', frees: '', color: undefined },
103 { label: 'status', text: 'gogate status', frees: '', color: undefined },
104]
105// Card colours: HARD is the safe ask (green), SOFT the waiting state (yellow), OFF the danger (red).
106const MODE_COLOR: Record<string, string> = { hard: 'green', soft: 'yellow', off: 'red' }
107const LOCK_COLOR: Record<string, number> = { hard: 0x2ea043, soft: 0xd7a017, off: 0xd9534f }
108const LOCK_PIXELS: Record<string, string[]> = {
109 hard: ['.XXXX.', 'XX..XX', 'XXXXXX', 'XXXXXX'],
110 soft: ['XXXXXX', 'X....X', 'XXXXXX', 'XXXXXX'],
111 off: ['.XXXXX', 'X...X.', 'XXXXXX', 'XXXXXX'],
112}
113const LOCK_GLYPH: Record<string, string> = { hard: '🔒', soft: '🔓', off: '🔓' }
114const MEANING: Record<string, string> = {
115 hard: 'every guarded command needs a GO',
116 soft: 'guarded commands wait until you grant or type GO',
117 off: 'gate does not block anything',
118}
119const SEG_MODES = ['hard', 'soft', 'off'] as const
120const MAX_BLOCKS = 3
121const CMD_MAX = 60
122const DEMO_MIN = 74
123
124export const demoGate = (): GateState => ({
125 mode: 'soft',
126 grants: [
127 { scope: 'push-feature', minutesLeft: DEMO_MIN },
128 { scope: 'github-write', minutesLeft: DEMO_MIN },
129 ],
130 raw: 'AOS go-gate, session demo: mode soft',
131})
132
133export const demoFleet = (): FleetSession[] =>
134 ['demo-orchestrator', 'demo-web', 'demo-api'].map(name => ({
135 id: `demo:${name}`,
136 name,
137 role: roleFromName(name),
138 activity: 'working',
139 repo: name.replace('demo-', ''),
140 cwd: `~/demo/${name.replace('demo-', '')}`,
141 updatedAt: 0,
142 }))
143
144export const demoBlocks = (now: number): GateBlock[] => [
145 { cmd: 'git push origin feat/demo-landing', at: now - 3 * 60_000 },
146 { cmd: 'npm publish', at: now - 8 * 60_000 },
147]
148
149// Mask secrets first, then truncate — the masked result must never leak a token tail.
150export const maskCommand = (cmd: string) => {
151 const s = cmd
152 .replace(/\b(?:sk-[A-Za-z0-9_-]{8,}|ghp_[A-Za-z0-9]{8,}|gho_[A-Za-z0-9]{8,}|github_pat_[A-Za-z0-9_]{8,}|xox[bp]-[A-Za-z0-9-]{8,}|AKIA[A-Z0-9]{12,})/g, '***')
153 .replace(/\bBearer\s+[^\s"']+/gi, 'Bearer ***')
154 .replace(/(--(?:token|password|secret|api-key)[= ])[^\s&"']+/gi, '$1***')
155 .replace(/\b[A-Za-z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD)=[^\s&"']+/gi, m => `${m.split('=')[0]}=***`)
156 .replace(/\b[A-Za-z0-9]{32,}\b/g, '***')
157 return s.length > CMD_MAX ? `${s.slice(0, CMD_MAX - 1)}…` : s
158}
159
160export const presetForCommand = (cmd: string) =>
161 /merge|publish/i.test(cmd) ? PRESETS[0]!.text : PRESETS[1]!.text
162
163const isDemo = async ($: EngineInterface) => (await $.env.get('AOS_FLEET_DEMO')) === '1'
164const GRANT_BAR = 10
165const GRANT_DEFAULT_MIN = 120
166const LIST_STALE_MIN = 15
167const MORE_W = 10
168
169export const grantBar = (minutesLeft: number) => {
170 const total = Math.max(GRANT_DEFAULT_MIN, minutesLeft)
171 const on = Math.max(0, Math.min(GRANT_BAR, Math.ceil((minutesLeft / total) * GRANT_BAR)))
172 return { on, off: GRANT_BAR - on }
173}
174
175// Grant rows turn yellow under 30 minutes and red under 10.
176export const grantColor = (minutesLeft: number) =>
177 minutesLeft >= 30 ? 'green' : minutesLeft >= 10 ? 'yellow' : 'red'
178
179export type PlanState = 'Completed' | 'Blocked' | 'Underway' | 'Waiting'
180
181export function parsePlan(text: string): PlanComponent[] {
182 const comps: PlanComponent[] = []
183 let cur: PlanComponent | null = null
184 let lastTask: PlanTask | null = null
185 let inFence = false
186 let taskTotal = 0
187 for (const raw of text.split('\n')) {
188 const t = raw.trim()
189 if (t.startsWith('```')) {
190 inFence = !inFence
191 continue
192 }
193 if (inFence) continue
194 const h = /^##\s+(.+?)\s*\{#([\w-]+)\}\s*$/.exec(t)
195 if (h) {
196 if (comps.length >= 200) break
197 cur = { id: h[2]!, name: h[1]!, needs: [], tasks: [] }
198 comps.push(cur)
199 lastTask = null
200 continue
201 }
202 if (!cur) continue
203 const needs = /^needs:\s*\[([^\]]*)\]/.exec(t)
204 if (needs) {
205 cur.needs = needs[1]!.split(',').map(s => s.trim()).filter(Boolean)
206 continue
207 }
208 const task = /^-\s+\[([ ~x!])\]\s+(.+?)(?:\s*\{#([\w-]+)\})?\s*$/.exec(t)
209 if (task) {
210 if (taskTotal >= 200) continue
211 const tt: PlanTask = { id: task[3] ?? `t${cur.tasks.length}`, text: task[2]!, mark: task[1] as PlanTask['mark'] }
212 cur.tasks.push(tt)
213 taskTotal++
214 lastTask = tt
215 continue
216 }
217 const by = /^(?:by|from):\s*(\S+)/.exec(t)
218 if (by && lastTask && !lastTask.by) {
219 lastTask.by = by[1]!
220 }
221 }
222 return comps
223}
224
225export function planStats(c: PlanComponent): { done: number; total: number; doing: number; stuck: number; state: PlanState } {
226 const total = c.tasks.length
227 const done = c.tasks.filter(t => t.mark === 'x').length
228 const doing = c.tasks.filter(t => t.mark === '~').length
229 const stuck = c.tasks.filter(t => t.mark === '!').length
230 const state: PlanState =
231 total > 0 && done === total ? 'Completed' : stuck > 0 ? 'Blocked' : doing > 0 || done > 0 ? 'Underway' : 'Waiting'
232 return { done, total, doing, stuck, state }
233}
234
235export const demoPlan = (): PlanComponent[] => [
236 { id: 'setup', name: 'Setup Pulse rig', needs: [], tasks: [
237 { id: 'rig', text: 'Patch fixtures and check signal', mark: 'x', by: 'claude' },
238 { id: 'net', text: 'Join show network', mark: 'x', by: 'codex' },
239 { id: 'cue', text: 'Load opening cue list', mark: '~', by: 'agy' },
240 ] },
241 { id: 'look', name: 'Design the look', needs: ['setup'], tasks: [
242 { id: 'pal', text: 'Pick palette for act one', mark: 'x', by: 'claude' },
243 { id: 'viz', text: 'Build generative visual', mark: '~', by: 'opencode' },
244 { id: 'media', text: 'Queue media clips', mark: ' ', by: 'claude' },
245 ] },
246 { id: 'show', name: 'Run the show', needs: ['look'], tasks: [
247 { id: 'reh', text: 'Rehearse transitions', mark: ' ', by: 'agy' },
248 { id: 'go', text: 'Call the GO cues', mark: ' ', by: 'codex' },
249 { id: 'oops', text: 'Fix stuck blackout', mark: '!', by: 'claude' },
250 ] },
251]
252
253const PLAN_CANDIDATES = ['PLAN.md', 'production_artifacts/00_execution_plan.md', 'production_artifacts/PLAN.md']
254
255async function loadPlan($: EngineInterface) {
256 if (await isDemo($)) {
257 await update($, plan, () => demoPlan())
258 await update($, planFile, () => 'demo')
259 return
260 }
261 const cwd = await $.session.cwd()
262 const repo = await $.session.repo().catch(() => null)
263 const root = (repo as { root?: string } | null)?.root ?? cwd
264 for (const f of PLAN_CANDIDATES) {
265 try {
266 const text = await $.fs.read(`${root}/${f}`)
267 const parsed = parsePlan(text)
268 if (parsed.length > 0) {
269 await update($, plan, () => parsed)
270 await update($, planFile, () => f)
271 return
272 }
273 } catch {
274 // try next candidate
275 }
276 }
277 await update($, plan, () => [])
278 await update($, planFile, () => '')
279}
280
281// The hero lock as half-block pixel cells; packed like spriteCells (two pixel rows per screen row).
282const LOCK_W = 6
283export function lockCells(mode: string) {
284 const color = LOCK_COLOR[mode] ?? LOCK_COLOR.soft!
285 const pixels = LOCK_PIXELS[mode] ?? LOCK_PIXELS.soft!
286 const words: number[] = []
287 for (let r = 0; r < pixels.length; r += 2) {
288 for (let c = 0; c < LOCK_W; c++) {
289 const top = pixels[r]![c] === 'X'
290 const bottom = pixels[r + 1]![c] === 'X'
291 const glyph = top && bottom ? 0x2588 : top ? 0x2580 : bottom ? 0x2584 : 0x20
292 words.push(glyph, glyph === 0x20 ? DEFAULT_COLOR : color, DEFAULT_COLOR)
293 }
294 }
295 const bytes = new Uint8Array(Uint32Array.from(words).buffer) as Uint8Array & { toBase64(): string }
296 return bytes.toBase64()
297}
298
299// How many chips (own width each, 2-cell gaps) fit; leaves room for "+k more" when any are dropped.
300export function chipsThatFit(widths: readonly number[], budget: number) {
301 const take = (room: number) => {
302 let used = 0
303 let n = 0
304 for (const w of widths) {
305 if (used + (n ? 2 : 0) + w > room) break
306 used += (n ? 2 : 0) + w
307 n++
308 }
309 return n
310 }
311 const n = take(budget)
312 return n < widths.length ? take(budget - MORE_W) : n
313}
314
315export const listAge = (updated: string | undefined, now: number) => {
316 const t = updated ? new Date(updated).getTime() : NaN
317 return Number.isNaN(t) ? 0 : Math.floor((now - t) / 60_000)
318}
319
320export function parseGateStatus(text: string): GateState {
321 const mode = /: mode (\w+)/.exec(text)?.[1] ?? 'soft'
322 const grants = [...text.matchAll(/^\s+grant (\S+) until \S+ \((-?\d+) min left/gm)].map(m => ({
323 scope: m[1]!,
324 minutesLeft: Number(m[2]),
325 }))
326 return { mode, grants, raw: text.trim() }
327}
328
329export const timeLeft = (min: number) => (min >= 60 ? `${Math.floor(min / 60)}h${String(min % 60).padStart(2, '0')}` : `${min}m`)
330
331export function parseProfile(text: string): GateProfile | null {
332 const m = /^profile:\s*(strict|work|off)\b(?:\s*\((?:(\d+) min left|no expiry)\))?/m.exec(text)
333 if (!m) return null
334 const note = /^note:\s*(.+)$/m.exec(text)?.[1]?.trim()
335 return { profile: m[1] as GateProfile['profile'], minutesLeft: m[2] ? Number(m[2]) : null, ...(note ? { note } : {}) }
336}
337
338export const isReleaseClassBlock = (text: string) => text.includes('release class')
339
340export const demoProfile = (): GateProfile => ({ profile: 'work', minutesLeft: 187 })
341
342const PROFILE_COLOR: Record<string, string> = { strict: 'gray', work: 'yellow', off: 'red' }
343
344// Display only: runs the read-only status CLI; a missing CLI shows nothing.
345async function readProfile($: EngineInterface) {
346 if (await isDemo($)) {
347 await update($, profile, () => demoProfile())
348 return
349 }
350 const home = await $.env.get('HOME')
351 let found: GateProfile | null = null
352 for (const cmd of [['aos-gate', 'status'], ['node', `${home}/.agents/bin/aos-gate.mjs`, 'status']]) {
353 try {
354 const r = await $.process.run(cmd)
355 found = r.exitCode === 0 ? parseProfile(r.stdout) : null
356 } catch {
357 found = null
358 }
359 if (found) break
360 }
361 await update($, profile, () => found)
362}
363
364async function readGate($: EngineInterface) {
365 if (await isDemo($)) {
366 await update($, gate, () => demoGate())
367 return
368 }
369 const home = await $.env.get('HOME')
370 const r = await $.process.run(['node', `${home}/.claude/hooks/go-grant.mjs`, '--status', '--session', await $.session.id()])
371 await update($, gate, () => parseGateStatus(r.stdout))
372}
373
374// The only place that writes the prompt box, and only from a Button press.
375async function openGatePane($: EngineInterface) {
376 await readGate($).catch(() => undefined)
377 await readProfile($).catch(() => undefined)
378 await $.ui.open({ id: GATE_PANE, title: 'go-gate' })
379}
380
381async function fillPreset($: EngineInterface, text: string) {
382 if ((await $.prompt.read()).text) return update($, gateMsg, () => 'Prompt is not empty – clear it first')
383 await $.prompt.fill({ text })
384 return update($, gateMsg, () => `▸ in your prompt: ${text}`)
385}
386
387let dir = ''
388let id = ''
389let project: Pick<FleetSession, 'repo' | 'branch' | 'phase' | 'cwd'> & {
390 pipelineReason?: string
391} = { cwd: '' }
392
393function setSelf($: EngineInterface, patch: Partial<FleetSelf>) {
394 return update($, self, s => ({ ...s, ...patch }))
395}
396
397let peers: FleetSession[] = []
398let peersAt = 0
399
400// Sessions without this mod still show, from ListAgents: busy or idle, never why.
401export function parsePeers(text: string, now: number): FleetSession[] {
402 return [...text.matchAll(/^\s+(.+?) \[([0-9a-f]+)\]\s+·\s+\S+\s+·\s+(\S+)/gm)].map(m => ({
403 id: `peer:${m[2]}`,
404 name: m[1]!,
405 role: roleFromName(m[1]!),
406 activity: m[3] === 'idle' ? 'idle' : 'working',
407 reason: m[3] === 'idle' || m[3] === 'busy' ? undefined : m[3],
408 cwd: '',
409 updatedAt: now,
410 unmanaged: true,
411 }))
412}
413
414const BAR = 8
415
416// token-weather's scale: the forecast word doubles as the advice.
417const WEATHER = [
418 { upTo: 25, icon: '☀', word: 'Clear', advice: 'ok', color: 'yellow' },
419 { upTo: 50, icon: '☁', word: 'Cloudy', advice: 'ok', color: 'cyan' },
420 { upTo: 75, icon: '☂', word: 'Showers', advice: 'ok', color: 'blue' },
421 { upTo: 90, icon: '☇', word: 'Storm', advice: 'compact soon', color: 'magenta' },
422 { upTo: Infinity, icon: '↯', word: 'Compact now', advice: 'compact or start a new session', color: 'red' },
423]
424const BARS = '▁▂▃▄▅▆▇█'
425export const sparkline = (values: readonly number[]) => {
426 const top = Math.max(...values, 1)
427 return values.map(v => BARS[Math.min(7, Math.floor((v / top) * 7))]).join('')
428}
429export const short = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n))
430
431let reading: { tokens: number; window: number; percent: number } | undefined
432let history: number[] = []
433
434async function takeReading($: EngineInterface) {
435 const { context } = await $.session.usage()
436 if (!context.window) return
437 const tokens = context.tokens ?? 0
438 reading = { tokens, window: context.window, percent: context.percent ?? (tokens / context.window) * 100 }
439 if (tokens > 0) history = [...history, tokens].slice(-12)
440 $.ui.invalidate('ui.render')
441}
442
443export const weather = (percent: number) => WEATHER.find(w => percent < w.upTo) ?? WEATHER[WEATHER.length - 1]!
444export const filled = (p: FleetProgress) => (p.total ? Math.round((p.done / p.total) * BAR) : 0)
445
446type Extras = {
447 progress: Map<string, FleetProgress>
448 waits: Map<string, string>
449 boards?: { project: string; p: FleetProgress }[]
450 updated?: string
451}
452let extras: Extras = { progress: new Map(), waits: new Map() }
453
454type TaskState = {
455 updated?: string
456 sessions?: Record<string, { map_url?: unknown }>
457 tasks?: { owner?: string; status?: string }[]
458}
459type BoardSummary = { project?: string; components?: { status?: string }[] }
460
461// TASK MANAGER's list says who waits on a GO or on Tim; agenttrail boards say how far each plan is.
462export function tasksExtras(state: TaskState): Extras & { ports: Map<string, number[]> } {
463 const progress = new Map<string, FleetProgress>()
464 const waits = new Map<string, string>()
465 const byOwner = new Map<string, string[]>()
466 for (const t of state.tasks ?? []) {
467 if (!t.owner || !t.status || t.status === 'geparkt') continue
468 const key = t.owner.toLowerCase()
469 byOwner.set(key, [...(byOwner.get(key) ?? []), t.status])
470 }
471 for (const [owner, statuses] of byOwner) {
472 const n = (s: string) => statuses.filter(x => x === s).length
473 progress.set(owner, { done: n('erledigt'), total: statuses.length, blocked: 0, source: 'tasks' })
474 const parts = [n('wartet-auf-GO') && `${n('wartet-auf-GO')} GO`, n('wartet-auf-Tim') && `${n('wartet-auf-Tim')} Tim`]
475 if (parts.some(Boolean)) waits.set(owner, parts.filter(Boolean).join(' · '))
476 }
477 const ports = new Map<string, number[]>()
478 for (const [name, info] of Object.entries(state.sessions ?? {})) {
479 const found = [...JSON.stringify(info.map_url ?? '').matchAll(/:(\d{4,5})/g)].map(m => Number(m[1]))
480 if (found.length) ports.set(name.toLowerCase(), found)
481 }
482 return { progress, waits, ports, updated: state.updated }
483}
484
485export function boardProgress(boards: readonly BoardSummary[]): FleetProgress | undefined {
486 const all = boards.flatMap(b => b.components ?? [])
487 if (!all.length) return undefined
488 const n = (s: string) => all.filter(c => c.status === s).length
489 return { done: n('done'), total: all.length, blocked: n('blocked'), source: 'plan' }
490}
491
492async function loadExtras($: EngineInterface) {
493 const home = await $.env.get('HOME')
494 const state: TaskState = JSON.parse(await $.fs.read(`${home}/.aos/tasks/state.json`))
495 const next = tasksExtras(state)
496 const boards = new Map<number, BoardSummary>()
497 for (const port of new Set([...next.ports.values()].flat())) {
498 const r = await $.http.fetch(`http://127.0.0.1:${port}/summary`).catch(() => null)
499 if (r?.ok) boards.set(port, JSON.parse(r.text))
500 }
501 for (const [name, ports] of next.ports) {
502 const p = boardProgress(ports.flatMap(port => boards.get(port) ?? []))
503 if (p) next.progress.set(name, p)
504 }
505 const shown = [...boards.values()].flatMap(b => {
506 const p = boardProgress([b])
507 return p && b.project ? [{ project: b.project, p }] : []
508 })
509 extras = { progress: next.progress, waits: next.waits, boards: shown, updated: next.updated }
510}
511
512async function listAgents($: EngineInterface) {
513 const listed = await $.tool.call({ tool: 'ListAgents' })
514 const text = 'text' in listed && listed.text ? listed.text : ''
515 peers = parsePeers(text, await $.clock.now())
516 peersAt = await $.clock.now()
517 return /This session is (.+?) \[/.exec(text)?.[1]
518}
519
520async function resolveName($: EngineInterface) {
521 const env = await $.env.get('AOS_SESSION_NAME')
522 if (env) return env
523 return listAgents($)
524}
525
526async function readProject($: EngineInterface) {
527 const cwd = await $.session.cwd()
528 const repo = await $.session.repo()
529 const root = repo?.root ?? cwd
530 const git = await $.process.run(['git', '-C', root, 'branch', '--show-current']).catch(() => null)
531 let phase: string | undefined
532 let pipelineReason: string | undefined
533 try {
534 const state = JSON.parse(await $.fs.read(`${root}/production_artifacts/state.json`))
535 phase = typeof state.phase === 'string' ? state.phase : undefined
536 if (state.needs_human) pipelineReason = 'pipeline needs you'
537 else if (phase && ATTENTION_PHASES.includes(phase)) pipelineReason = `pipeline ${phase}`
538 } catch {
539 // no graph run in this repo
540 }
541 project = {
542 cwd,
543 repo: repo ? root.split('/').pop() : undefined,
544 branch: git && git.exitCode === 0 ? git.stdout.trim() || undefined : undefined,
545 phase,
546 pipelineReason,
547 }
548}
549
550async function publish($: EngineInterface, ended = false) {
551 if (await isDemo($)) return
552 if (!id) return
553 dir ||= `${await $.env.get('HOME')}/.aos/fleet`
554 const me = await read($, self)
555 const name = me.name ?? id.slice(0, 8)
556 const pipelineAttention = me.activity !== 'working' && project.pipelineReason
557 const record: FleetSession = {
558 id,
559 name,
560 role: me.role ?? roleFromName(name),
561 activity: pipelineAttention ? 'attention' : me.activity,
562 reason: pipelineAttention ? project.pipelineReason : me.reason,
563 repo: project.repo,
564 branch: project.branch,
565 phase: project.phase,
566 cwd: project.cwd,
567 updatedAt: await $.clock.now(),
568 context: reading?.percent,
569 ended,
570 }
571 await $.fs.write(`${dir}/${id}.json`, JSON.stringify(record, null, 2))
572}
573
574async function loadFleet($: EngineInterface) {
575 if (await isDemo($)) {
576 await update($, fleet, () => demoFleet())
577 return
578 }
579 dir ||= `${await $.env.get('HOME')}/.aos/fleet`
580 if (!(await $.fs.exists(dir))) return
581 const now = await $.clock.now()
582 const entries = await $.fs.list(dir)
583 const sessions: FleetSession[] = []
584 for (const entry of entries) {
585 if (entry.kind !== 'file' || !entry.name.endsWith('.json')) continue
586 try {
587 const s: FleetSession = JSON.parse(await $.fs.read(`${dir}/${entry.name}`))
588 if (!s.ended && now - s.updatedAt < STALE_MS) sessions.push(s)
589 } catch {
590 // half-written by another session; next tick reads it
591 }
592 }
593 // ponytail: ListAgents spawns the PreToolUse hooks, so peers refresh every 15 s, not every tick
594 if (now - peersAt > 15_000) {
595 await listAgents($).catch(() => undefined)
596 await loadExtras($).catch(() => undefined)
597 }
598 const known = new Set(sessions.map(s => s.name))
599 sessions.push(...peers.filter(p => !known.has(p.name)))
600 for (const [i, s] of sessions.entries()) {
601 const key = s.name.toLowerCase()
602 const wait =
603 extras.waits.get(key) ?? (s.context !== undefined && s.context >= 90 ? `${Math.round(s.context)}% ctx` : undefined)
604 sessions[i] = {
605 ...s,
606 progress: extras.progress.get(key),
607 ...(wait && s.activity !== 'attention' ? { activity: 'attention', reason: wait } : {}),
608 }
609 }
610 sessions.sort((a, b) => RANK[a.activity] - RANK[b.activity] || a.name.localeCompare(b.name))
611 await update($, fleet, () => sessions)
612}
613
614async function tick($: EngineInterface) {
615 if ((await read($, fleet)).some(s => s.activity === 'working')) await update($, frame_, f => f + 1)
616}
617
618// -----------------------------------------------------------------------------
619// Pane body renderers (extracted from the ui.render hooks so the validator
620// can inspect $-taking helpers at top level).
621// -----------------------------------------------------------------------------
622
623const PLAN_BAR = 10
624const PLAN_MARK: Record<string, { glyph: string; color: string }> = {
625 x: { glyph: '✓', color: 'green' },
626 '~': { glyph: '●', color: 'yellow' },
627 '!': { glyph: '✗', color: 'red' },
628 ' ': { glyph: '○', color: 'gray' },
629}
630const PLAN_COLOR: Record<string, string> = { Completed: 'green', Underway: 'yellow', Blocked: 'red', Waiting: 'gray' }
631const PLAN_RANK: Record<string, number> = { '~': 0, '!': 1, ' ': 2, x: 3 }
632const TAB_ACCENT = { fleet: 0x2ea043, gate: 0xd7a017, plan: 0x6a9be0 }
633
634export async function renderFleetBody($: EngineInterface, e: PaneInput) {
635 const ui = $.ui.resolve(e)
636 const { Box, Text } = ui
637 const Raster = 'Raster' in ui ? ui.Raster : undefined
638 const list = await read($, fleet)
639 const frame = await read($, frame_)
640 const fits = Math.max(1, Math.floor((e.props.bodyColumns ?? 80) / CARD))
641 if (!Raster) return <Text dimColor>The fleet cards need the terminal.</Text>
642 const rows: FleetSession[][] = []
643 for (let i = 0; i < list.length; i += fits) rows.push(list.slice(i, i + fits))
644 return (
645 <Box flexDirection="column">
646 {list.length === 0 && <Text dimColor>No sessions reporting yet.</Text>}
647 {rows.map((shown, i) => (
648 <Box key={`row-${i}`} flexDirection="column" marginBottom={1}>
649 <Box flexDirection="row">
650 {shown.map(s => (
651 <Box key={s.id} flexDirection="column" width={CARD}>
652 <Raster key={`sprite-${s.id}`} columns={SPRITE_W} rows={2} cells={spriteCells(s, frame)} />
653 <Text bold={s.id === id} color={hex(roleColor(s))}>{fit(s.name, CARD - 2)}</Text>
654 <Text color={DOT[s.activity].color}>{DOT[s.activity].glyph} {fit(s.reason ?? (s.activity === 'working' ? s.phase ?? 'working' : 'idle'), CARD - 4)}</Text>
655 {s.progress ? (
656 <Box flexDirection="row">
657 <Text color="green">{'■'.repeat(filled(s.progress))}</Text>
658 <Text dimColor>{'■'.repeat(BAR - filled(s.progress))}</Text>
659 <Text dimColor>{' '}{s.progress.done}/{s.progress.total}</Text>
660 {s.progress.blocked > 0 && <Text color="yellow"> !{s.progress.blocked}</Text>}
661 </Box>
662 ) : (
663 <Text> </Text>
664 )}
665 {s.context !== undefined ? (
666 <Text color={weather(s.context).color}>{weather(s.context).icon} {Math.round(s.context)}% {weather(s.context).advice}</Text>
667 ) : (
668 <Text> </Text>
669 )}
670 </Box>
671 ))}
672 </Box>
673 </Box>
674 ))}
675 <Text dimColor wrap="truncate-end">{(extras.boards ?? []).map(b => `${b.project} ${b.p.done}/${b.p.total}`).join(' ')}</Text>
676 </Box>
677 )
678}
679
680export async function renderGateBody($: EngineInterface, e: PaneInput) {
681 const ui = $.ui.resolve(e)
682 const { Box, Text, Button } = ui
683 const Raster = 'Raster' in ui ? ui.Raster : undefined
684 const { mode, grants, raw } = await read($, gate)
685 const msg = await read($, gateMsg)
686 const prof = await read($, profile)
687 const width = e.props.bodyColumns ?? 80
688 const scopeW = Math.max(...grants.map(x => x.scope.length), 0)
689 const demo = await isDemo($)
690 const nowMs = await $.clock.now()
691 const blockList = demo ? demoBlocks(nowMs) : await read($, blocks)
692 const segment = (m: (typeof SEG_MODES)[number]) => {
693 if (m === mode)
694 return (
695 <Text key={`seg-${m}`} bold backgroundColor={MODE_COLOR[m]} color="black">
696 {` ${m.toUpperCase()} `}
697 </Text>
698 )
699 if (m === 'off')
700 return (
701 <Text key={`seg-${m}`} color={MODE_COLOR[m]}>
702 {` ${m.toUpperCase()} `}
703 </Text>
704 )
705 return (
706 <Button
707 key={`seg-${m}`}
708 plain
709 label={m.toUpperCase()}
710 onPress={() => void fillPreset($, m === 'hard' ? 'gogate hard' : 'gogate soft')}
711 />
712 )
713 }
714 const heroCard = (
715 <Box key="hero" flexDirection="column" borderStyle="round" borderColor={MODE_COLOR[mode] ?? 'yellow'} paddingX={1} marginBottom={1}>
716 <Box key="hero-head" flexDirection="row" justifyContent="space-between">
717 <Box key="hero-left" flexDirection="row">
718 {Raster ? (
719 <Raster key="lock" columns={6} rows={2} cells={lockCells(mode)} />
720 ) : (
721 <Text key="lock-fallback" color={MODE_COLOR[mode] ?? 'yellow'}>{LOCK_GLYPH[mode] ?? '🔒'}</Text>
722 )}
723 <Text key="mode-word" bold>{' '}{mode.toUpperCase()}</Text>
724 </Box>
725 <Text key="hero-tag" dimColor wrap="truncate-end">go-gate</Text>
726 </Box>
727 <Text key="hero-meaning" dimColor wrap="truncate-end">{MEANING[mode] ?? MEANING.soft}</Text>
728 {prof && (
729 <Text key="hero-profile" wrap="truncate-end">
730 <Text color={PROFILE_COLOR[prof.profile]}>
731 {`profile: ${prof.profile} · ${prof.minutesLeft === null ? 'no expiry' : `${timeLeft(prof.minutesLeft)} left`}`}
732 </Text>
733 {prof.note && <Text dimColor>{` (config ignored: ${prof.note})`}</Text>}
734 </Text>
735 )}
736 <Box key="hero-segments" flexDirection="row">
737 {SEG_MODES.map((m, i) => (
738 <Box key={`seg-slot-${m}`} flexDirection="row">
739 {i > 0 && <Text>{' '}</Text>}
740 {segment(m)}
741 </Box>
742 ))}
743 </Box>
744 </Box>
745 )
746 const grantsCard = (
747 <Box key="grants" flexDirection="column" borderStyle="round" borderColor="gray" paddingX={1} marginBottom={1}>
748 <Box key="grants-head" flexDirection="row" justifyContent="space-between">
749 <Text bold>GRANTS</Text>
750 <Text dimColor wrap="truncate-end">{grants.length} active</Text>
751 </Box>
752 {grants.length === 0 ? (
753 <Text dimColor wrap="truncate-end">no grants — guarded commands stay blocked</Text>
754 ) : (
755 grants.map(x => {
756 const bar = grantBar(x.minutesLeft)
757 return (
758 <Box key={`grant-${x.scope}`} flexDirection="row" justifyContent="space-between">
759 <Box key={`grant-${x.scope}-l`} flexDirection="row">
760 <Text wrap="truncate-end">{x.scope.padEnd(scopeW)}</Text>
761 <Text color={grantColor(x.minutesLeft)}>{'■'.repeat(bar.on)}</Text>
762 <Text dimColor>{'□'.repeat(bar.off)}</Text>
763 </Box>
764 <Text dimColor wrap="truncate-end">{timeLeft(x.minutesLeft)} left</Text>
765 </Box>
766 )
767 })
768 )}
769 </Box>
770 )
771 const blockedCard = (
772 <Box key="blocked" flexDirection="column" borderStyle="round" borderColor="gray" paddingX={1}>
773 <Text bold>LAST BLOCKED</Text>
774 {blockList.length === 0 ? (
775 <Text dimColor wrap="truncate-end">nothing blocked yet</Text>
776 ) : (
777 blockList.map((b, i) => {
778 const mins = Math.max(0, Math.round((nowMs - b.at) / 60_000))
779 return (
780 <Box key={`row-blk-${i}`} flexDirection="column">
781 <Box key={`row-blk-${i}-l`} flexDirection="row">
782 <Text color="yellow">▸ </Text>
783 <Button key={`blk-${i}`} plain label={b.cmd} onPress={() => void fillPreset($, presetForCommand(b.cmd))} />
784 {b.releaseClass && <Text color="red">{' '}release class</Text>}
785 </Box>
786 <Text dimColor wrap="truncate-end"> {mins}m ago</Text>
787 </Box>
788 )
789 })
790 )}
791 </Box>
792 )
793 const presetRow = (p: (typeof PRESETS)[number], i: number, primary: boolean) => (
794 <Box key={`row-${p.label}`} flexDirection="row">
795 {p.color && <Text color={p.color}>● </Text>}
796 <Button
797 key={p.label}
798 label={p.label}
799 hotkey={String(i + 1)}
800 {...(primary ? { variant: 'primary' as const } : { plain: true as const })}
801 onPress={() => void fillPreset($, p.text)}
802 />
803 {p.frees && (
804 <Text dimColor wrap="truncate-end">{' '}{p.frees}</Text>
805 )}
806 </Box>
807 )
808 const presetsCard = (
809 <Box key="presets" flexDirection="column" borderStyle="round" borderColor="gray" paddingX={1}>
810 <Text bold>PRESETS</Text>
811 {presetRow(PRESETS[0]!, 0, true)}
812 {presetRow(PRESETS[1]!, 1, false)}
813 <Box key="row-compact" flexDirection="row">
814 <Text color="gray">● </Text>
815 {PRESETS.slice(2).map((p, i) => (
816 <Box key={`cp-${p.label}`} marginRight={1}>
817 <Button key={p.label} label={p.label} hotkey={String(i + 3)} plain onPress={() => void fillPreset($, p.text)} />
818 </Box>
819 ))}
820 </Box>
821 </Box>
822 )
823 const wide = width >= 96
824 const body = wide ? (
825 <Box key="cols" flexDirection="row">
826 <Box key="col-left" flexGrow={1} flexDirection="column">{heroCard}{presetsCard}</Box>
827 <Box key="col-right" flexGrow={1} flexDirection="column">{grantsCard}{blockedCard}</Box>
828 </Box>
829 ) : (
830 <Box key="stack" flexDirection="column">{heroCard}{grantsCard}{blockedCard}{presetsCard}</Box>
831 )
832 return (
833 <Box flexDirection="column">
834 {body}
835 {raw && !/: mode \w+/.test(raw) && (
836 <Text dimColor wrap="truncate-end">{raw}</Text>
837 )}
838 {msg && (
839 <Text color="yellow" wrap="truncate-end">{msg}</Text>
840 )}
841 <Text dimColor wrap="truncate-end">click prefills your prompt · you press Enter to record</Text>
842 </Box>
843 )
844}
845
846export async function renderPlanBody($: EngineInterface, e: PaneInput) {
847 const ui = $.ui.resolve(e)
848 const { Box, Text } = ui
849 const comps = await read($, plan)
850 const totals = comps.map(planStats)
851 const doneAll = totals.reduce((a, s) => a + s.done, 0)
852 const totalAll = totals.reduce((a, s) => a + s.total, 0)
853 if (comps.length === 0) {
854 return (
855 <Box flexDirection="column">
856 <Text dimColor wrap="truncate-end">no plan file found (PLAN.md or production_artifacts/00_execution_plan.md)</Text>
857 </Box>
858 )
859 }
860 const cards = comps.map((c, i) => {
861 const s = totals[i]!
862 const filled = totalAll === 0 || s.total === 0 ? 0 : Math.round((s.done / s.total) * PLAN_BAR)
863 const rows = [...c.tasks].sort((a, b) => PLAN_RANK[a.mark]! - PLAN_RANK[b.mark]!).slice(0, 6)
864 const more = c.tasks.length - rows.length
865 return (
866 <Box key={`plan-${c.id}`} flexDirection="column" borderStyle="round" borderColor={PLAN_COLOR[s.state] ?? 'gray'} paddingX={1} marginBottom={1}>
867 <Box key={`plan-${c.id}-head`} flexDirection="row" justifyContent="space-between">
868 <Text bold wrap="truncate-end">{c.name}</Text>
869 <Text color={PLAN_COLOR[s.state]} wrap="truncate-end">{s.state}</Text>
870 </Box>
871 <Box key={`plan-${c.id}-bar`} flexDirection="row">
872 <Text color="green">{'■'.repeat(filled)}</Text>
873 <Text dimColor>{'□'.repeat(PLAN_BAR - filled)}</Text>
874 <Text dimColor wrap="truncate-end"> {s.done}/{s.total}</Text>
875 </Box>
876 {rows.map(t => (
877 <Box key={`plan-${c.id}-${t.id}`} flexDirection="row" justifyContent="space-between">
878 <Box key={`plan-${c.id}-${t.id}-l`} flexDirection="row">
879 <Text color={PLAN_MARK[t.mark]!.color}>{PLAN_MARK[t.mark]!.glyph} </Text>
880 <Text wrap="truncate-end">{t.text}</Text>
881 </Box>
882 {t.by && <Text dimColor wrap="truncate-end">{t.by[0]!.toUpperCase()}</Text>}
883 </Box>
884 ))}
885 {more > 0 && <Text dimColor wrap="truncate-end">+{more} more</Text>}
886 </Box>
887 )
888 })
889 const wide = (e.props.bodyColumns ?? 80) >= 96
890 const body = wide ? (
891 <Box key="cols" flexDirection="row">
892 <Box key="col-left" flexGrow={1} flexDirection="column">{cards.filter((_, i) => i % 2 === 0)}</Box>
893 <Box key="col-right" flexGrow={1} flexDirection="column">{cards.filter((_, i) => i % 2 === 1)}</Box>
894 </Box>
895 ) : (
896 <Box key="stack" flexDirection="column">{cards}</Box>
897 )
898 return (
899 <Box flexDirection="column">
900 <Box flexDirection="row" justifyContent="space-between">
901 <Text bold wrap="truncate-end">PLAN</Text>
902 <Text dimColor wrap="truncate-end">{doneAll}/{totalAll} tasks</Text>
903 </Box>
904 {body}
905 </Box>
906 )
907}
908
909export const register: Register = on => {
910 on('session.start', async ($, e, next) => {
911 const result = await next(e)
912 $.ui.status(undefined)
913 id = await $.session.id()
914 await $.command.register({ name: 'aos-fleet', description: 'Show the AOS fleet pane' })
915 await $.command.register({ name: 'gogate-panel', description: 'Show the go-gate status and grant presets' })
916 await $.command.register({ name: 'aos-plan', description: 'Show the live plan box' })
917 await $.command.register({ name: 'aos-hub', description: 'Show fleet, gate and plan in one pane' })
918 await $.command.register({
919 name: 'aos-role',
920 description: `Set this session's AOS role: ${ROLES.join(' | ')}`,
921 })
922 const name = await resolveName($).catch(() => undefined)
923 if (name) await setSelf($, { name })
924 await readProject($)
925 await takeReading($).catch(() => undefined)
926 await publish($)
927 await loadFleet($)
928 await loadPlan($)
929 await readGate($).catch(() => undefined)
930 await readProfile($).catch(() => undefined)
931 $.clock.every(30_000, () => void readGate($).catch(() => undefined))
932 $.clock.every(30_000, () => void readProfile($).catch(() => undefined))
933 $.clock.every(5_000, () => void loadFleet($))
934 $.clock.every(5_000, () => void loadPlan($).catch(() => undefined))
935 $.clock.every(30_000, () => void publish($))
936 $.clock.every(700, () => void tick($))
937
938 const me = await read($, self)
939 if ((me.role ?? roleFromName(me.name ?? '')) !== 'worker') {
940 void $.ui.open({ id: PANE, title: 'AOS fleet' })
941 }
942 return result
943 })
944
945 on('session.end', async ($, e, next) => {
946 await publish($, true)
947 return next(e)
948 })
949
950 on('turn.start', async ($, e, next) => {
951 await setSelf($, { activity: 'working', reason: undefined })
952 void publish($)
953 return next(e)
954 })
955
956 on('turn.complete', async ($, e, next) => {
957 if (e.agentId) return next(e)
958 await takeReading($).catch(() => undefined)
959 const me = await read($, self)
960 if (me.activity !== 'attention') await setSelf($, { activity: 'idle', reason: undefined })
961 const name = await resolveName($).catch(() => undefined)
962 if (name) await setSelf($, { name })
963 await readProject($)
964 await publish($)
965 await readGate($).catch(() => undefined)
966 return next(e)
967 })
968
969 on('classic.PermissionRequest', async ($, e, next) => {
970 await setSelf($, { activity: 'attention', reason: `allow? ${e.tool_name}` })
971 await publish($)
972 return next(e)
973 })
974
975 on('tool.call', async ($, e, next) => {
976 if (e.tool === 'ListAgents') return next(e)
977 if (e.tool === 'AskUserQuestion') {
978 await setSelf($, { activity: 'attention', reason: 'question' })
979 await publish($)
980 }
981 const result = await next(e)
982 const blocked = result.isError && result.text && /go-gate/i.test(result.text)
983 if (blocked) {
984 const command = e.tool === 'Bash' ? maskCommand(e.command) : String(e.tool)
985 if (!(await isDemo($))) {
986 const at = await $.clock.now()
987 const releaseClass = isReleaseClassBlock(result.text ?? '')
988 await update($, blocks, list => [{ cmd: command, at, releaseClass }, ...list].slice(0, MAX_BLOCKS))
989 }
990 await setSelf($, { activity: 'attention', reason: `GO: ${command}` })
991 await publish($)
992 } else if ((await read($, self)).activity === 'attention') {
993 await setSelf($, { activity: 'working', reason: undefined })
994 await publish($)
995 }
996 return result
997 })
998
999 on('command.run', { command: 'aos-fleet' }, async $ => {
1000 await loadFleet($)
1001 await $.ui.open({ id: PANE, title: 'AOS fleet' })
1002 return { text: 'AOS fleet pane opened.' }
1003 })
1004
1005 on('command.run', { command: 'gogate-panel' }, async $ => {
1006 await openGatePane($)
1007 return { text: 'go-gate pane opened.' }
1008 })
1009
1010 on('command.run', { command: 'aos-plan' }, async $ => {
1011 await loadPlan($)
1012 await $.ui.open({ id: PLAN_PANE, title: 'plan' })
1013 return { text: 'plan pane opened.' }
1014 })
1015
1016 on('command.run', { command: 'aos-hub' }, async ($, e) => {
1017 if (e.args) {
1018 const arg = e.args.trim() as 'fleet' | 'gate' | 'plan'
1019 if (arg === 'fleet' || arg === 'gate' || arg === 'plan') {
1020 await update($, tab, () => arg)
1021 }
1022 }
1023 await loadFleet($)
1024 await readGate($)
1025 await readProfile($).catch(() => undefined)
1026 await loadPlan($)
1027 await $.ui.open({ id: HUB_PANE, title: 'AOS hub' })
1028 return { text: 'AOS hub pane opened.' }
1029 })
1030
1031 on('command.run', { command: 'aos-role' }, async ($, e) => {
1032 const role = e.args.trim() as FleetRole
1033 if (!ROLES.includes(role)) return { text: `Unknown role "${e.args}". Use: ${ROLES.join(', ')}` }
1034 await setSelf($, { role })
1035 await publish($)
1036 await loadFleet($)
1037 return { text: `AOS role set to ${role}.` }
1038 })
1039
1040 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
1041 return renderFleetBody($, e)
1042 })
1043
1044 on('ui.render', { component: 'Pane', requestId: GATE_PANE }, async ($, e) => {
1045 return renderGateBody($, e)
1046 })
1047
1048 on('ui.render', { component: 'Pane', requestId: PLAN_PANE }, async ($, e) => {
1049 return renderPlanBody($, e)
1050 })
1051
1052 on('ui.render', { component: 'Pane', requestId: HUB_PANE }, async ($, e) => {
1053 const ui = $.ui.resolve(e)
1054 const { Box, Text, Button } = ui
1055 const active = await read($, tab)
1056 const body = active === 'fleet' ? await renderFleetBody($, e) : active === 'gate' ? await renderGateBody($, e) : await renderPlanBody($, e)
1057 const tabBars = (['fleet', 'gate', 'plan'] as const).map(k => {
1058 const label = k === 'fleet' ? 'Fleet' : k === 'gate' ? 'Gate' : 'Plan'
1059 const hotkey = k === 'fleet' ? 'f' : k === 'gate' ? 'g' : 'p'
1060 return active === k ? (
1061 <Text key={`tab-${k}`} bold backgroundColor={hex(TAB_ACCENT[k])} color="black">{label}</Text>
1062 ) : (
1063 <Button key={`tab-${k}`} plain label={label} hotkey={hotkey} onPress={() => void update($, tab, () => k)} />
1064 )
1065 })
1066 return (
1067 <Box flexDirection="column">
1068 <Box flexDirection="row">{tabBars}</Box>
1069 <Text dimColor wrap="truncate-end">{'\u2500'.repeat(e.props.bodyColumns ?? 80)}</Text>
1070 {body}
1071 </Box>
1072 )
1073 })
1074
1075 // token-weather's line for this session, then one line for the fleet.
1076 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1077 const list = await read($, fleet)
1078 const g = await read($, gate)
1079 const prof = await read($, profile)
1080 if (e.props.hasSurvey || (list.length === 0 && !reading && !g.raw)) return next(e)
1081 const { Box, Text, Button } = $.ui.resolve(e)
1082 const wide = e.props.bodyColumns >= 70
1083 const others = list.filter(s => s.id !== id)
1084 const idle = others.filter(s => s.activity === 'idle')
1085 const busy = others.filter(s => s.activity !== 'idle')
1086
1087 const frame = await read($, frame_)
1088 const age = listAge(extras.updated, await $.clock.now())
1089
1090 const weatherLine = (reading || g.raw) && (
1091 <Box flexDirection="row" paddingX={1}>
1092 <Text wrap="truncate-end">
1093 {reading && (
1094 <Text color={weather(reading.percent).color} bold wrap="truncate-end">
1095 {weather(reading.percent).icon} {weather(reading.percent).word}
1096 </Text>
1097 )}
1098 {reading && <Text wrap="truncate-end"> {Math.round(reading.percent)}% of context</Text>}
1099 {reading && (
1100 <Text dimColor wrap="truncate-end">
1101 {' '}
1102 {short(reading.tokens)} / {short(reading.window)}
1103 </Text>
1104 )}
1105 {reading && wide && history.length > 1 && <Text dimColor wrap="truncate-end"> last turns </Text>}
1106 {reading && wide && history.length > 1 && (
1107 <Text color={weather(reading.percent).color} wrap="truncate-end">
1108 {sparkline(history)}
1109 </Text>
1110 )}
1111 {reading && reading.percent >= 75 && (
1112 <Text color={weather(reading.percent).color} wrap="truncate-end">
1113 {' '}
1114 {weather(reading.percent).advice}
1115 </Text>
1116 )}
1117 </Text>
1118 </Box>
1119 )
1120
1121 // The gate sits first so the weather text, not the button, is what truncates.
1122 const gateLine = g.raw && (
1123 <Box flexDirection="row" paddingX={1}>
1124 <Text color={MODE_COLOR[g.mode] ?? 'yellow'}>● </Text>
1125 <Button key="gate" plain label={`gate ${g.mode}`} onPress={() => void openGatePane($)} />
1126 <Text dimColor wrap="truncate-end">
1127 {g.grants.map(x => ` · ${x.scope} ${timeLeft(x.minutesLeft)}`).join('')}
1128 {prof && prof.profile !== 'strict' ? ` · ${prof.profile}${prof.minutesLeft === null ? '' : ` ${timeLeft(prof.minutesLeft)}`}` : ''}
1129 {' '}
1130 </Text>
1131 </Box>
1132 )
1133
1134 const room = Math.max(1, e.props.bodyColumns - 2)
1135 const reasonMax = Math.min(24, Math.max(8, Math.floor(room / 3)))
1136 const chips = busy.map(s => ({
1137 key: s.id,
1138 color: hex(roleColor(s)),
1139 glyph: s.activity === 'working' && frame % 2 ? '▄▀▀▄' : '█▀▀█',
1140 name: fit(s.name, 18),
1141 tail: s.reason ? fit(s.reason, reasonMax) : s.progress ? `${s.progress.done}/${s.progress.total}` : '',
1142 attention: s.activity === 'attention',
1143 }))
1144 const idleText = idle.length > 0 ? `○ ${idle.length} idle` : ''
1145 const staleText = age > LIST_STALE_MIN ? `· list ${timeLeft(age)} old` : ''
1146 const widths = [
1147 ...chips.map(c => c.glyph.length + 1 + c.name.length + (c.tail ? 1 + c.tail.length : 0)),
1148 ...(idleText ? [idleText.length] : []),
1149 ...(staleText ? [staleText.length] : []),
1150 ]
1151 const n =chipsThatFit(widths, room)
1152 const shownChips = chips.slice(0, n)
1153 const more = chips.length - shownChips.length
1154 const idleShown = idleText && n > chips.length
1155 const staleShown = staleText && n > chips.length + (idleText ? 1 : 0)
1156
1157 const fleetLine = others.length > 0 && (
1158 <Box flexDirection="row" paddingX={1}>
1159 <Text wrap="truncate-end">
1160 {shownChips.map((c, i) => (
1161 <Text key={c.key} wrap="truncate-end">
1162 {i > 0 && ' '}
1163 <Text color={c.color} wrap="truncate-end">
1164 {c.glyph}
1165 </Text>{' '}
1166 <Text color={c.color} wrap="truncate-end">
1167 {c.name}
1168 </Text>
1169 {c.tail && (
1170 <Text color={c.attention ? 'yellow' : undefined} dimColor={!c.attention} wrap="truncate-end">
1171 {' '}
1172 {c.tail}
1173 </Text>
1174 )}
1175 </Text>
1176 ))}
1177 {more > 0 && (
1178 <Text dimColor wrap="truncate-end">
1179 {' '}+{more} more
1180 </Text>
1181 )}
1182 {idleShown && (
1183 <Text dimColor wrap="truncate-end">
1184 {' '}
1185 {idleText}
1186 </Text>
1187 )}
1188 {staleShown && (
1189 <Text dimColor wrap="truncate-end">
1190 {' '}
1191 {staleText}
1192 </Text>
1193 )}
1194 </Text>
1195 </Box>
1196 )
1197
1198 return (
1199 <Box flexDirection="column">
1200 <Box flexDirection="row">types/index.d.ts 57 lines1export type FleetRole = 'master' | 'task-manager' | 'orchestrator' | 'worker'
2
3export type FleetActivity = 'working' | 'idle' | 'attention'
4
5export type FleetProgress = { done: number; total: number; blocked: number; source: 'plan' | 'tasks' }
6
7export type FleetSession = {
8 id: string
9 name: string
10 role: FleetRole
11 activity: FleetActivity
12 reason?: string
13 repo?: string
14 branch?: string
15 phase?: string
16 cwd: string
17 updatedAt: number
18 ended?: boolean
19 unmanaged?: boolean
20 progress?: FleetProgress
21 context?: number
22}
23
24export type FleetSelf = {
25 name?: string
26 role?: FleetRole
27 activity: FleetActivity
28 reason?: string
29}
30
31export type GateState = { mode: string; grants: { scope: string; minutesLeft: number }[]; raw: string }
32
33export type GateBlock = { cmd: string; at: number; releaseClass?: boolean }
34
35export type GateProfile = { profile: 'strict' | 'work' | 'off'; minutesLeft: number | null; note?: string }
36
37export type PlanTask = { id: string; text: string; mark: ' ' | '~' | 'x' | '!'; by?: string }
38
39export type PlanComponent = { id: string; name: string; needs: string[]; tasks: PlanTask[] }
40
41declare module 'claude-code' {
42 interface PluginState {
43 'bdb-aos-fleet': {
44 fleet: FleetSession[]
45 self: FleetSelf
46 frame: number
47 gate: GateState
48 gateMsg: string
49 blocks: GateBlock[]
50 profile: GateProfile | null
51 plan: PlanComponent[]
52 planFile: string
53 tab: 'fleet' | 'gate' | 'plan'
54 }
55 }
56}
57