Agent session coordination in tmux: a dag CLI for session creation and recorded messaging, plus a live Claude Code agent tree

Local coordination for independent agent sessions in tmux.
Create and register sessions, send recorded messages, and follow their status from one terminal. Each session has a stable identity and inbox, so agents can find each other by name and preserve pending messages across reconnections.
The dag CLI works with agent processes launched in tmux. Its Codex adapter provides native message delivery and live runtime telemetry. dag view renders the shared card/tree dashboard in a terminal or a separate tmux pane. A Claude Code plugin adds a live view of the agents running inside a single session and a skill for using the CLI. The CLI can be used on its own.
$ dag sessions
SESSION STATUS QUEUED PANE ADAPTER ACTIVITY ROLE
builder busy 0 %1 tmux - implementation
reviewer ready 0 %2 codex running review
tester busy 2 %3 tmux - tests
dag coordinates tmux sessions. /dag controls the agent tree inside a Claude Code session.
Requires Node.js 20+ and tmux.
git clone https://github.com/Qifei-C/agent-dag.git
cd agent-dag
npm install
npm run install:cli
dag --help
The installer links ~/.local/bin/dag to this checkout without replacing an existing command. Keep the checkout in place. You can also run ./bin/dag directly or choose a bin directory with npm run install:cli -- /path/to/bin. If needed, add ~/.local/bin to your shell's PATH. Installing the Claude Code plugin alone does not install the CLI.
Create and register an independent agent session:
dag create reviewer --cwd /path/to/repo --role review -- claude
dag sessions
dag attach reviewer
The session starts detached. Without a command after --, it starts your shell. The launched process inherits dag on PATH, DAG_HOME, and a unique DAG_SESSION_ID. A registered caller becomes its parent; --parent none starts another root. The parent Claude nesting marker is cleared for the new, independent process. Other agent executables can also be passed after --. Command arguments are passed directly, without shell evaluation; use an explicit shell command only when you intend shell syntax. Include a starting task in the agent's command arguments, or use the inbox workflow below.
Register sessions that already exist:
dag register builder --role implementation # inside its tmux pane
dag register reviewer --target %3 --role review # explicit pane
Session names are unique across the registry. Registration is idempotent for the same name and pane. dag register NAME --target %PANE --replace moves an offline session to a new pane, preserving its ID and inbox. Readiness resets to unknown. Pane bindings and server sockets are checked before injection, including after tmux server restarts, so a recycled pane ID is not sufficient to receive input.
Remove registrations that are no longer needed:
dag unregister reviewer # name or session ID
dag unregister # current registered session
dag unregister --offline # all offline registrations
Unregistering releases the name and matching pane binding and removes its Codex telemetry. It leaves the agent process running and preserves message history; remaining children become roots. Registering the same name again creates a new identity with an empty inbox. --json returns an array of removed sessions. Closed sessions remain offline until explicitly unregistered, so they can still be reconnected with register --replace when their identity and inbox are needed.
Tested with Codex CLI 0.163.0. Requires native codex queue and App Server Unix socket support, plus the normal Codex sign-in/provider setup.
dag create coder --adapter codex --cwd /path/to/project --role implementation -- codex
dag send coder 'Inspect the current diff and report findings' --key review-42
dag view --pane
The adapter starts a private local App Server, creates the thread, persists its coordination context without inference, binds its exact ID, and opens the native Codex TUI. It preserves normal Codex approval and sandbox settings. Closing the session also shuts down its private server. A command named codex selects this adapter automatically; dag create coder --adapter codex uses the default Codex executable. Pass a quoted initial prompt after Codex options to start a task. The managed session stays bound to that thread. To work in another thread, create another DAG session rather than switching the managed TUI with /new or /fork.
Codex messages use the native queue, including while a turn is active. They do not use terminal key injection or require dag ready for each message. submitted means Codex accepted the message into its queue; the receiving agent still uses dag ack ID to acknowledge receipt. dag busy coder pauses DAG delivery, and dag ready coder resumes and drains it. An uncertain submission is never automatically retried or sent through a tmux fallback.
When an agent invokes dag through a Codex shell tool, its sandbox policy still applies to registry files and local sockets. Use normal Codex approvals for any required access; the adapter does not broaden execution permissions.
Common interactive options such as -m, -c, --sandbox, and --add-dir pass through to Codex. Managed mode owns the App Server, directory and thread, so raw subcommands, --remote, profiles, and managed worktrees are rejected explicitly. Use --adapter tmux when you want to run those commands directly instead.
For an existing Codex pane, run these from a Codex shell tool:
dag register coder
dag bind coder
bind reads CODEX_THREAD_ID inside Codex. From another shell, supply the exact ID and, if needed, --codex-socket PATH and --codex-bin PATH. Binding checks the thread ID and working directory, and rejects an ID already bound elsewhere. DAG_CODEX_SOCKET overrides the default local control socket, and DAG_CODEX_BIN selects the executable for new managed sessions. A replaced offline pane loses its old thread binding before it can receive native messages.
The Codex plugin compatibility manifest packages the shared coordination skill without loading Claude's function hooks. To install it from this checkout:
codex plugin marketplace add /path/to/agent-dag
codex plugin add agent-dag@qifei-c
Installing the skill is optional for managed startup: the new thread also gets a reference to its coordination guide. CLI installation and dependencies are still required on the machine that runs the agents.
Messages are durable and queued by default:
dag send reviewer 'Please review the current diff' --from builder --key review-42
dag inbox reviewer --json
dag ack MESSAGE_ID --session reviewer
dag send builder 'Review finished; see commit abc123' --from reviewer --reply-to MESSAGE_ID
dag messages
Inside a registered agent session, sender and inbox identity are inferred; --from and --session are useful for operator commands. Without a registered sender, messages are recorded as from person. --key deduplicates retries for the same sender, receiver, and payload. --file PATH reads message text from a file (maximum 16 KiB). Inbox polling plus acknowledgement handles queued messages in an agent's current turn without writing to its terminal.
For terminal-based delivery through the tmux adapter:
dag ready reviewer
dag sessions --watch
Only mark a pane ready when its agent can accept input. Readiness is explicitly reported; this release does not detect busy agents, prompt boundaries, or approval dialogs automatically. One readiness declaration permits one injection, then returns to busy. dag busy NAME revokes readiness. Message text is sent as a single JSON-escaped line with a message ID and acknowledgement instruction. injected means the input was written; acknowledged confirms receipt, not task completion. Replies carry the actual result.
Interrupted sends remain sending, or become uncertain if a transport error was observed. These are never replayed automatically. Inspect the terminal and read the durable inbox entry, then acknowledge it before declaring readiness again. If an acknowledged message still needs action, handle its content in the current turn. Offline receivers retain their queued messages.
Session availability is independent of readiness and observed runtime activity: available permits normal delivery, cooldown records a temporary limit, and blocked requires operator intervention. A paused session keeps its identity, inbox, and already delivered messages. New messages stay queued. dag send returns the receiver's current receiverAvailability, including on an idempotent retry, and prints the reason when delivery is paused. dag sessions shows an AVAILABILITY column; the dashboard shows the reason and queued count.
dag pause reviewer --reason 'Usage limit reached'
dag pause reviewer --reason 'Waiting for quota reset' --until 2026-10-20T01:00:00-04:00
dag sessions --json
dag inbox reviewer --json
dag resume reviewer
Names can be omitted inside the registered session. --until requires a future ISO timestamp with a timezone and records an expected reset time; it does not schedule automatic recovery. ready, bind, idle telemetry, and an elapsed reset time cannot clear an availability hold. resume clears it explicitly and drains only queued messages, respecting existing readiness: a busy Codex session still needs ready; a tmux session permits one injection only when already ready. sending or uncertain messages must be inspected and acknowledged before resuming. Submitted, injected, acknowledged messages are never replayed.
Managed Codex sessions detect structured root-thread error and failed turn/completed events. Recognized codes are rateLimitExceeded (cooldown), usageLimitExceeded, sessionBudgetExceeded, and contextWindowExceeded (blocked). Existing external Codex bindings are observed while the dashboard is open. A recognized rejection during the read before queue invocation leaves the message queued; a failure after invoking the queue remains uncertain. Unknown error text is never classified as a limit, and child-thread failures do not pause the registered root. An automatic observation cannot replace a manual hold.
For Claude, load this plugin (installed normally, or with claude --plugin-dir /path/to/agent-dag). Its command hooks bind the native session on SessionStart and report structured StopFailure.error values: rate_limit becomes cooldown; billing, authentication, and account restrictions become blocked. See the Claude hooks reference. Ordinary tmux sessions without these hooks use manual pause. SDK hosts can forward a single JSON rate_limit_event to node /path/to/agent-dag/cli/claude-hook.mjs on stdin, with the target's DAG/tmux environment. Only rate_limit_info.status: "rejected" pauses delivery; resetsAt, when present, is read as a Unix timestamp in seconds. Warnings do not pause delivery. The bridge checks session identity and ignores subagent events. Localized terminal prose such as “resets 1am” is not parsed as a timestamp.
Claude Code 2.1.234+ can automatically continue an interrupted task after a subscription usage reset in an open interactive session. This is conditional on its settings and session state; a long computer sleep can require Enter, and a new prompt ends the current wait. See the Claude interactive-mode reference. DAG leaves this native continuation alone. Codex queue acceptance likewise is not a guarantee that a task has run or will automatically resume after a usage reset. In either client, keep accepted messages in their original state, inspect the inbox after recovery, and use dag resume to release new DAG messages.
dag sessions renders the parent/child session tree with delivery status, liveness, queued counts, pane IDs, adapters, runtime activity, and roles. STATUS is delivery readiness; ACTIVITY is observed Codex runtime state. --watch refreshes it every two seconds; --json is available on data commands. The plugin's session-coordination skill documents the agent workflow. To load the skill from a checkout, launch Claude with that checkout, for example dag create reviewer -- claude --plugin-dir /path/to/agent-dag. Codex model, turn, tool, token and child-agent events are collected for managed sessions even when the dashboard is closed. Historical turn/tool counts are not backfilled; token updates are cumulative snapshots and cached tokens are not added twice. Existing bound Codex threads are observed while the dashboard is open, using a thread/resume subscription without configuration overrides. Claude's internal tool/token telemetry remains in its embedded pane. The shared dashboard shows its registered session and DAG messages. Task scheduling remains manual.
dag view # interactive card/tree dashboard
dag view --pane # open a separate tmux pane
dag view --once # print a snapshot
dag view --once --json # read the graph as JSON
In the dashboard, v switches density, m toggles messages, t toggles tool details, arrow keys scroll, and q exits. Native Codex permissions and questions remain in its own TUI; the observer does not approve requests. Offline panes and unavailable telemetry are marked explicitly.
State lives in ~/.local/state/agent-dag/state.json. All participating sessions must use the same DAG_HOME (or --home DIR). Writes are atomic and serialized across CLI processes. State files are private to the local user and contain full message text. Use DAG_TMUX_SOCKET or --socket PATH for creation/registration on another local tmux server; each registered session keeps its resolved socket. The registry needs no shared coordinator daemon. Managed Codex sessions own private App Servers on local Unix sockets. Server logs are private files under DAG_HOME/codex-SESSION_ID.log. If a process dies holding the registry lock, dead owners are recovered. A crash between creating the lock directory and writing its owner can leave an empty DAG_HOME/lock; remove that empty directory with rmdir if no CLI operation is running.
Core CLI commands:
| Command | Purpose |
|---|---|
dag create NAME -- COMMAND | Create and register a detached tmux session. |
dag register NAME | Register the current tmux pane. |
dag unregister [NAME] [--offline] | Remove a registration, or clean up all offline records. |
dag bind [NAME] | Bind a registered pane to its exact Codex thread. |
dag send NAME TEXT | Queue a message for a registered session. |
dag inbox [NAME] | Read messages for the current or named session. |
dag ack ID | Confirm receipt in the current session. |
dag ready [NAME] | Permit one queued or future terminal injection. |
dag busy [NAME] | Queue messages without injecting terminal input. |
dag pause [NAME] [--reason TEXT] [--until ISO_TIME] | Hold new delivery and record the reason or expected reset. |
dag resume [NAME] | Clear an availability hold while preserving readiness. |
dag messages | Show the complete message history. |
dag sessions --watch | Follow session status and queued counts. |
dag view [--pane] | Open the shared card/tree dashboard. |
dag attach NAME | Focus a registered session's pane. |
Run dag --help for command arguments. For an agent workflow, see the session-coordination skill.
The plugin connects agent detail cards with tree branches in a docked pane. Follow the parent/child rails to see who spawned whom, then read each card for status, model, elapsed time, turns, total and input/output tokens, tool counts, current tool, recent tools, and the latest message. Long task names and metadata wrap onto additional lines. The pane uses Claude Code's renderer in supported terminals, including tmux.
╭─ ◐ main ─────────────────────────────────────────────────────────────╮
│ running · ? · 32s · 1 turn │
│ tokens 0 · in 0 · out 0 · 0 tools │
│ msg 0 sent / 1 received │
│ ← review session coordination: "Found two delivery edge cases; │
│ checking recovery." │
╰──────────────────────────────────────────────────────────────────────╯
├─╭─ ◐ fork: review session coordination ──────────────────────────────╮
│ │ running · sonnet · 31s · 1 turn │
│ │ tokens 8.0k · in 7.2k · out 800 · 3 tools │
│ │ now ▸ Bash │
│ │ recent Read → Grep → Read │
│ │ msg 1 sent / 0 received │
│ │ → main: "Found two delivery edge cases; checking recovery." │
│ ╰────────────────────────────────────────────────────────────────────╯
│ └─╭─ ◌ Explore: 检查消息队列与重试逻辑 👩💻 ───────────────────────────╮
│ │ pending · haiku · 31s · 0 turns │
│ │ tokens 0 · in 0 · out 0 · 0 tools │
│ ╰──────────────────────────────────────────────────────────────────╯
└─╭─ ✗ run integration tests ──────────────────────────────────────────╮
│ failed · sonnet · 12s · 0 turns │
│ tokens 2.2k · in 2.0k · out 200 · 0 tools │
╰────────────────────────────────────────────────────────────────────╯
/dag, which seats it at any width./dag opens or closes the pane. /dag inspect prints the current layout into the transcript. /dag reset clears it.v switches between detailed cards and a compact tree with one row per agent. m toggles inline messages and the messages section; t toggles current/recent tool details. Arrow keys scroll./config)The tokens column counts every input token (uncached, cache reads and cache writes) plus output, since Claude Code agents run mostly on cached input.
| Option | Values | Default |
|---|---|---|
| Glyph set | unicode, ascii | unicode |
| Open on first agent | on, off | on |
| Default density | detailed, compact | detailed |
Pick ascii if your font lacks ✓ ✗ ⊘ ◐. ASCII mode also applies to card borders, branches, and message/tool arrows. Saved tree and cards preferences are still read as compact and detailed respectively; an existing session keeps its selected density across hot reloads.
At the prompt of a Claude Code terminal session:
/plugin install agent-dag --marketplace qifei-c/agent-dag
Answer y to add the marketplace, then pick a scope (user scope installs it for every session).
From a shell:
claude plugin marketplace add qifei-c/agent-dag
claude plugin install agent-dag@qifei-c
To run a checkout without installing: claude --plugin-dir /path/to/agent-dag.
npm run check
npm test
claude plugin validate .
claude plugin test .
CLI tests use real processes, temporary isolated tmux servers and a local Codex protocol fixture; they never send input to existing sessions or invoke a real model. Run them in an environment that permits local Unix sockets and pseudo-terminals. npm install builds the shared renderer; after changing the layout, run npm run build (also run automatically before CLI tests).
hooks/model.ts is the pure reducer, hooks/draw.ts the pure layout, hooks/register.tsx the only file that talks to the engine. cli/ is the standalone companion; it does not run in the plugin's restricted hook runtime. cli/layout-entry.ts bundles the same pure renderer for dag view.
Protocol references: Codex App Server and plugin packaging.
hooks/register.tsx 195 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { DagState, GlyphSet, View } from '../types'
5import { MAIN, apply, freshState, isActive, loopId } from './model'
6import type { DagEvent, ListedAgent } from './model'
7import { cardRows, messageRows, plainText, summary, treeRows } from './draw'
8import type { Row, Segment } from './draw'
9
10export const PANE = 'agent-dag'
11const TITLE = 'Agents'
12const TICK_MS = 1000
13const RECONCILE_EVERY = 5
14const DETAILS_MIN_COLUMNS = 36
15
16export const dag = atom({ plugin: 'agent-dag', key: 'dag' } as const, freshState(0, 'cards'), { shape: 'v1' })
17
18type Options = { glyphs: GlyphSet; autoOpen: boolean; defaultView: View }
19
20const readOptions = (raw: Readonly<Record<string, unknown>>): Options => ({
21 glyphs: raw.glyphs === 'ascii' ? 'ascii' : 'unicode',
22 autoOpen: raw.autoOpen !== false,
23 // Preserve saved tree/cards preferences while exposing density in the UI.
24 defaultView: raw.defaultView === 'tree' || raw.defaultView === 'compact' ? 'tree' : 'cards',
25})
26
27type Dollar = Parameters<typeof update>[0] & { ui: { open: (pane: { id: string; title?: string }) => Promise<unknown> } }
28
29/** Folds one event into the shared graph state. */
30async function dispatch($: Parameters<typeof update>[0], ev: DagEvent): Promise<void> {
31 await update($, dag, s => apply(s, ev))
32}
33
34/** Opens the pane: always when the person asked, else once per session unless they closed it. */
35async function openPane($: Dollar, options: Options, asked: boolean): Promise<void> {
36 const s = await read($, dag)
37 if (!asked && (!options.autoOpen || s.isClosedByPerson || s.hasOpened)) return
38 await dispatch($, { kind: 'set-flags', hasOpened: true, isClosedByPerson: asked ? false : s.isClosedByPerson })
39 await $.ui.open({ id: PANE, title: TITLE })
40}
41
42export const register: Register = (on, rawOptions) => {
43 const options = readOptions(rawOptions)
44
45 on('session.start', async ($, e, next) => {
46 const now = await $.clock.now()
47 // A hot reload keeps the graph; a fresh session starts clean in the configured view.
48 await update($, dag, s => (s.order.length > 1 ? s : freshState(now, options.defaultView)))
49 await $.command.register({ name: 'dag', description: 'Toggle the Agents pane; `inspect` prints the tree, `reset` clears it', argumentHint: '[inspect|reset]' })
50
51 let ticks = 0
52 $.clock.every(TICK_MS, () => {
53 void (async () => {
54 const s = await read($, dag)
55 if (s.order.length <= 1) return
56 const at = await $.clock.now()
57 ticks += 1
58 if (isActive(s)) await dispatch($, { kind: 'tick', at })
59 if (ticks % RECONCILE_EVERY === 0) {
60 try {
61 const agents = (await $.agent.list()) as ListedAgent[]
62 await dispatch($, { kind: 'reconcile', agents, at })
63 } catch { /* next tick */ }
64 }
65 })()
66 })
67
68 return next(e)
69 })
70
71 on('session.end', async ($, e, next) => {
72 if (e.reason === 'clear') await dispatch($, { kind: 'reset', at: await $.clock.now() })
73 return next(e)
74 })
75
76 on('agent.spawn', async ($, e, next) => {
77 const started = await next(e)
78 if (started.agentId) {
79 await dispatch($, {
80 kind: 'spawn', agentId: started.agentId, parentId: loopId(e.parentAgentId),
81 label: e.description || e.name || e.subagentType, type: e.subagentType,
82 isTeammate: e.isTeammate === true, name: e.name, teammateId: started.teammateId,
83 workflowRunId: e.workflow?.runId, model: started.model, at: await $.clock.now(),
84 })
85 await openPane($, options, false)
86 }
87 return started
88 }).catch(($, e, next) => next(e))
89
90 on('turn.start', async ($, e, next) => {
91 await dispatch($, { kind: 'turn-start', agentId: e.agentId, at: await $.clock.now() })
92 return next(e)
93 })
94
95 on('tool.call', async ($, e, next) => {
96 await dispatch($, { kind: 'tool-start', agentId: e.agentId, tool: String(e.tool) })
97 try {
98 return await next(e)
99 } finally {
100 await dispatch($, { kind: 'tool-end', agentId: e.agentId, tool: String(e.tool) })
101 }
102 }).catch(($, e, next) => next(e))
103
104 on('turn.complete', async ($, e, next) => {
105 await dispatch($, { kind: 'turn-end', agentId: e.agentId, reason: e.reason, usage: e.usage, at: await $.clock.now() })
106 return next(e)
107 })
108
109 on('session.send', async ($, e, next) => {
110 const sent = await next(e)
111 if (sent.isDelivered) {
112 await dispatch($, { kind: 'message', from: loopId(e.agentId), to: e.to, text: e.text, at: await $.clock.now() })
113 }
114 return sent
115 }).catch(($, e, next) => next(e))
116
117 on('ui.close', { id: PANE }, async ($, e, next) => {
118 if (e.origin.kind === 'person') await dispatch($, { kind: 'set-flags', isClosedByPerson: true })
119 return next(e)
120 }).catch(($, e, next) => next(e))
121
122 on('command.run', { command: 'dag' }, async ($, e) => {
123 const arg = (e.args ?? '').trim().toLowerCase()
124 const now = await $.clock.now()
125 if (arg === 'inspect') {
126 const s = await read($, dag)
127 const o = { columns: 80, glyphs: options.glyphs, now }
128 const msgs = messageRows(s, o)
129 const rows = [...(s.view === 'cards' ? cardRows(s, o) : treeRows(s, o)), ...(msgs.length ? [[{ text: '' }], ...msgs] : [])]
130 return { text: '```\n' + plainText(rows) + '\n```' }
131 }
132 if (arg === 'reset') {
133 await dispatch($, { kind: 'reset', at: now })
134 return { text: 'Agent graph reset.' }
135 }
136 if (arg !== '') return { text: 'usage: /dag [inspect|reset]' }
137 // Open means drawn: an unasked open below the engine's width floor waits
138 // undrawn, and the person's /dag must then seat it, not close it.
139 const isOpen = (await $.ui.panes()).some(p => p.id === PANE && p.isPlaced && p.isShown)
140 if (isOpen) {
141 // A close the person asked for: no auto-reopen until they ask again.
142 await dispatch($, { kind: 'set-flags', isClosedByPerson: true })
143 await $.ui.close({ id: PANE })
144 return { text: 'Agents pane closed.' }
145 }
146 await openPane($, options, true)
147 return { text: 'Agents pane opened.' }
148 })
149
150 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
151 const { Box, Text, Button } = $.ui.resolve(e)
152 const s = await read($, dag)
153 const columns = Number.isFinite(e.props.bodyColumns) && e.props.bodyColumns > 0 ? Math.floor(e.props.bodyColumns) : 60
154 const now = await $.clock.now()
155 const o = { columns, glyphs: options.glyphs, now }
156
157 const seg = (x: Segment, i: number) => (
158 <Text key={`s${i}`} color={x.color} dimColor={x.dim} weight={x.bold ? 'semibold' : undefined}>{x.text}</Text>
159 )
160 const line = (r: Row, i: number) => <Text key={`r${i}`} wrap="truncate-end">{r.map(seg)}</Text>
161
162 try {
163 const hasAgents = s.order.length > 1
164 const body = !hasAgents
165 ? [<Text key="empty" dimColor>No agents yet. The pane fills when the session spawns one.</Text>]
166 : s.view === 'cards' && columns >= DETAILS_MIN_COLUMNS
167 ? cardRows(s, o).map(line)
168 : treeRows(s, o).map(line)
169 const msgs = hasAgents ? messageRows(s, o) : []
170 const canDetails = columns >= DETAILS_MIN_COLUMNS
171 return (
172 <Box flexDirection="column">
173 <Box flexDirection="column">{body}</Box>
174 {msgs.length > 0 && <Box flexDirection="column" marginTop={1}>{msgs.map(line)}</Box>}
175 <Box marginTop={1} justifyContent="space-between">
176 <Box gap={1}>
177 {canDetails && (
178 <Button key="view" hotkey="v" plain label={s.view === 'cards' ? 'compact' : 'detailed'}
179 onPress={() => update($, dag, st => apply(st, { kind: 'set-flags', view: st.view === 'cards' ? 'tree' : 'cards' }))} />
180 )}
181 <Button key="messages" hotkey="m" plain label="messages"
182 onPress={() => update($, dag, st => apply(st, { kind: 'set-flags', showMessages: !st.showMessages }))} />
183 <Button key="tools" hotkey="t" plain label="tools"
184 onPress={() => update($, dag, st => apply(st, { kind: 'set-flags', showTools: !st.showTools }))} />
185 </Box>
186 <Text dimColor>{summary(s, options.glyphs)}</Text>
187 </Box>
188 </Box>
189 )
190 } catch (err) {
191 return <Text dimColor>agent-dag: draw failed ({err instanceof Error ? err.message : String(err)})</Text>
192 }
193 })
194}
195hooks/model.ts 217 lines1import type { AgentNode, AgentStatus, DagState, MessageEdge, View } from '../types'
2
3export const MAIN = 'main'
4const RECENT_TOOLS = 6
5const LAST_TEXT = 60
6const ENDED: ReadonlySet<AgentStatus> = new Set(['completed', 'failed', 'killed'])
7
8export type Usage = {
9 model?: string
10 input_tokens: number
11 output_tokens: number
12 cache_read_input_tokens: number
13 cache_creation_input_tokens: number
14}
15
16export type ListedAgent = {
17 id: string
18 status: AgentStatus
19 name?: string
20 teammateId?: string
21 description?: string
22 type?: string
23 parentId?: string
24}
25
26export type DagEvent =
27 | { kind: 'spawn'; agentId: string; parentId: string; label: string; type: string;
28 isTeammate: boolean; name?: string; teammateId?: string; workflowRunId?: string;
29 model?: string; at: number }
30 | { kind: 'turn-start'; agentId: string | undefined; at: number }
31 | { kind: 'tool-start'; agentId: string | undefined; tool: string }
32 | { kind: 'tool-end'; agentId: string | undefined; tool: string }
33 | { kind: 'turn-end'; agentId: string | undefined; reason: 'answer' | 'aborted' | 'error' | 'refusal';
34 usage?: Usage; at: number }
35 | { kind: 'message'; from: string; to: string; text: string; at: number }
36 | { kind: 'reconcile'; agents: ListedAgent[]; at: number }
37 | { kind: 'tick'; at: number }
38 | { kind: 'reset'; at: number }
39 | { kind: 'set-flags'; view?: View; showMessages?: boolean; showTools?: boolean;
40 isClosedByPerson?: boolean; hasOpened?: boolean }
41
42export const loopId = (agentId: string | undefined): string => agentId ?? MAIN
43
44const mainNode = (at: number): AgentNode => ({
45 id: MAIN, label: 'main', type: 'session', isTeammate: false, status: 'idle',
46 startedAt: at, turns: 0, inputTokens: 0, outputTokens: 0, toolCount: 0, recentTools: [],
47})
48
49export const freshState = (now: number, view: View): DagState => ({
50 nodes: { [MAIN]: mainNode(now) },
51 order: [MAIN],
52 messages: [],
53 view,
54 showMessages: true,
55 showTools: true,
56 isClosedByPerson: false,
57 hasOpened: false,
58 tick: 0,
59})
60
61export const isActive = (s: DagState): boolean =>
62 Object.values(s.nodes).some(n => n.status === 'running' || n.status === 'pending')
63
64const nodesInOrder = (s: DagState): AgentNode[] =>
65 s.order.flatMap(k => (s.nodes[k] ? [s.nodes[k] as AgentNode] : []))
66
67export const children = (s: DagState, id: string): AgentNode[] =>
68 nodesInOrder(s).filter(n => n.id !== MAIN && (n.parentId ?? MAIN) === id)
69
70export const resolveAddress = (s: DagState, to: string): string => {
71 if (s.nodes[to]) return to
72 const hit = nodesInOrder(s).find(n => n.name === to || n.teammateId === to)
73 return hit ? hit.id : to
74}
75
76export const shortModel = (id: string | undefined): string => {
77 if (!id) return ''
78 for (const family of ['fable', 'opus', 'sonnet', 'haiku']) {
79 if (id.includes(family)) return family
80 }
81 const last = id.split('/').pop() ?? id
82 if (last.startsWith('gpt-')) return last.slice(4, 12)
83 return last.slice(0, 8)
84}
85
86/** The type of a node created before its spawn was seen; reconcile fills it in. */
87const PLACEHOLDER_TYPE = 'agent'
88
89const placeholder = (id: string, at: number, parentId = MAIN, label = 'agent', type = PLACEHOLDER_TYPE): AgentNode => ({
90 id, parentId, label, type, isTeammate: false, status: 'pending', startedAt: at,
91 turns: 0, inputTokens: 0, outputTokens: 0, toolCount: 0, recentTools: [],
92})
93
94/** Returns state with `id` present (created as a placeholder when absent). */
95const ensure = (s: DagState, id: string, at: number, parentId = MAIN, label?: string, type?: string): DagState => {
96 if (s.nodes[id]) return s
97 const withParent = parentId === MAIN || s.nodes[parentId] ? s : ensure(s, parentId, at)
98 return {
99 ...withParent,
100 nodes: { ...withParent.nodes, [id]: placeholder(id, at, parentId, label, type) },
101 order: [...withParent.order, id],
102 }
103}
104
105const patch = (s: DagState, id: string, fn: (n: AgentNode) => AgentNode): DagState => {
106 const n = s.nodes[id]
107 return n ? { ...s, nodes: { ...s.nodes, [id]: fn(n) } } : s
108}
109
110const endStatus = (n: AgentNode, reason: 'answer' | 'aborted' | 'error' | 'refusal'): AgentStatus => {
111 if (reason === 'aborted') return 'killed'
112 if (reason === 'error' || reason === 'refusal') return 'failed'
113 return n.id === MAIN || n.isTeammate ? 'idle' : 'completed'
114}
115
116export const apply = (s: DagState, ev: DagEvent): DagState => {
117 switch (ev.kind) {
118 case 'spawn': {
119 const base = ensure(ensure(s, ev.parentId, ev.at), ev.agentId, ev.at, ev.parentId)
120 return patch(base, ev.agentId, n => {
121 // The spawn's bookkeeping can land after the agent's first turn.start:
122 // a loop already running keeps its status, tool, clock and turn count.
123 const isLive = n.status === 'running' || n.status === 'waiting'
124 return {
125 ...n, parentId: ev.parentId, label: ev.label, type: ev.type, isTeammate: ev.isTeammate,
126 name: ev.name ?? n.name, teammateId: ev.teammateId ?? n.teammateId,
127 workflowRunId: ev.workflowRunId ?? n.workflowRunId, model: ev.model ?? n.model,
128 status: isLive ? n.status : 'pending',
129 startedAt: isLive ? n.startedAt : ev.at,
130 endedAt: undefined,
131 currentTool: isLive ? n.currentTool : undefined,
132 }
133 })
134 }
135 case 'turn-start': {
136 const id = loopId(ev.agentId)
137 return patch(ensure(s, id, ev.at), id, n => ({ ...n, status: 'running', turns: n.turns + 1, endedAt: undefined }))
138 }
139 // Tool events never create a node: the engine's own forks (compaction,
140 // memory) carry agent ids no list names and would become phantom rows.
141 case 'tool-start':
142 return patch(s, loopId(ev.agentId), n => ({ ...n, currentTool: ev.tool }))
143 case 'tool-end': {
144 const id = loopId(ev.agentId)
145 return patch(s, id, n => ({
146 ...n,
147 toolCount: n.toolCount + 1,
148 recentTools: [...n.recentTools, ev.tool].slice(-RECENT_TOOLS),
149 currentTool: n.currentTool === ev.tool ? undefined : n.currentTool,
150 }))
151 }
152 case 'turn-end': {
153 const id = loopId(ev.agentId)
154 return patch(ensure(s, id, ev.at), id, n => ({
155 ...n,
156 model: ev.usage?.model ?? n.model,
157 status: endStatus(n, ev.reason),
158 endedAt: ev.at,
159 currentTool: undefined,
160 // input_tokens alone is only the uncached part of the prompt.
161 inputTokens: n.inputTokens
162 + (ev.usage?.input_tokens ?? 0)
163 + (ev.usage?.cache_read_input_tokens ?? 0)
164 + (ev.usage?.cache_creation_input_tokens ?? 0),
165 outputTokens: n.outputTokens + (ev.usage?.output_tokens ?? 0),
166 }))
167 }
168 case 'message': {
169 const to = resolveAddress(s, ev.to)
170 const text = ev.text.replace(/\s+/g, ' ').trim().slice(0, LAST_TEXT)
171 const i = s.messages.findIndex(m => m.from === ev.from && m.to === to)
172 const prev = i < 0 ? undefined : s.messages[i]
173 const edge: MessageEdge = prev
174 ? { ...prev, count: prev.count + 1, lastAt: ev.at, lastText: text }
175 : { from: ev.from, to, count: 1, lastAt: ev.at, lastText: text }
176 const messages = prev ? s.messages.map((m, k) => (k === i ? edge : m)) : [...s.messages, edge]
177 return { ...s, messages }
178 }
179 case 'reconcile': {
180 let out = s
181 for (const a of ev.agents) {
182 // The engine keeps finished agents listed for a while: never create a
183 // node for one (a /dag reset or /clear would be undone), only update.
184 if (!out.nodes[a.id] && ENDED.has(a.status)) continue
185 out = ensure(out, a.id, ev.at, a.parentId ?? MAIN, a.description || a.name || a.type, a.type)
186 out = patch(out, a.id, n => {
187 const isPlaceholder = n.type === PLACEHOLDER_TYPE
188 const settles = a.status !== 'running' && a.status !== 'pending' && !ENDED.has(n.status)
189 const wakes = a.status === 'running' && (n.status === 'pending' || n.status === 'waiting')
190 const takes = settles || wakes
191 return {
192 ...n,
193 label: isPlaceholder ? a.description || a.name || a.type || n.label : n.label,
194 type: isPlaceholder && a.type ? a.type : n.type,
195 parentId: isPlaceholder && a.parentId ? a.parentId : n.parentId,
196 name: n.name ?? a.name,
197 teammateId: n.teammateId ?? a.teammateId,
198 status: takes ? a.status : n.status,
199 endedAt: takes && ENDED.has(a.status) ? ev.at : n.endedAt,
200 }
201 })
202 }
203 return out
204 }
205 case 'tick':
206 return { ...s, tick: s.tick + 1 }
207 case 'reset':
208 return { ...freshState(ev.at, s.view), showMessages: s.showMessages, showTools: s.showTools,
209 isClosedByPerson: s.isClosedByPerson, hasOpened: s.hasOpened }
210 case 'set-flags': {
211 const { kind: _k, ...flags } = ev
212 const defined = Object.fromEntries(Object.entries(flags).filter(([, v]) => v !== undefined))
213 return { ...s, ...defined }
214 }
215 }
216}
217hooks/draw.ts 276 lines1import type { AgentNode, AgentStatus, DagState, GlyphSet } from '../types'
2import { MAIN, children, shortModel } from './model'
3import { GLYPHS, STATUS_COLOR, STATUS_WORD, fit, fmtElapsed, fmtTokens, padLeft, textWidth, wrapText } from './format'
4
5export type Segment = { text: string; color?: string; dim?: boolean; bold?: boolean }
6export type Row = Segment[]
7export type LayoutOptions = { columns: number; glyphs: GlyphSet; now: number }
8export type Card = {
9 id: string; depth: number; status: AgentStatus; rows: Row[]
10 prefix: string; continuation: string; width: number
11}
12
13const MIN_LABEL = 12
14const MAX_MESSAGES = 12
15const NAME_WIDTH = 18
16const BARE_TYPES = new Set(['general-purpose', 'teammate', 'claude', 'session', 'agent'])
17
18export const visibleColumns = (width: number) => ({
19 status: width >= 40,
20 tokens: width >= 48,
21 model: width >= 60,
22 tool: width >= 72,
23})
24
25const elapsedOf = (n: AgentNode, now: number): number =>
26 (n.status === 'running' || n.status === 'pending' || n.endedAt === undefined ? now : n.endedAt) - n.startedAt
27
28const labelOf = (n: AgentNode): string =>
29 BARE_TYPES.has(n.type) || n.label === n.type ? n.label : `${n.type}: ${n.label}`
30
31const ellipsisOf = (glyphs: GlyphSet) => (glyphs === 'ascii' ? '~' : '…')
32
33/** A drawable item: a node, or a synthetic workflow group. */
34type Item = {
35 id: string
36 label: string
37 status: AgentStatus
38 node?: AgentNode
39 depth: number
40 isLast: boolean
41 trail: boolean[] // for each ancestor depth, whether a pipe continues
42 kids: Item[]
43 members?: AgentNode[] // set on a workflow group item
44}
45
46const groupStatus = (nodes: AgentNode[]): AgentStatus => {
47 if (nodes.some(n => n.status === 'running')) return 'running'
48 if (nodes.some(n => n.status === 'failed')) return 'failed'
49 if (nodes.some(n => n.status === 'pending' || n.status === 'waiting')) return 'pending'
50 if (nodes.every(n => n.status === 'completed' || n.status === 'killed')) return 'completed'
51 return 'idle'
52}
53
54/** Children of `id` as items, with workflow agents folded into group items. */
55const itemsUnder = (s: DagState, id: string, depth: number, trail: boolean[]): Item[] => {
56 const kids = children(s, id)
57 const out: Item[] = []
58 const seenRuns = new Set<string>()
59 for (const k of kids) {
60 if (k.workflowRunId) {
61 if (seenRuns.has(k.workflowRunId)) continue
62 seenRuns.add(k.workflowRunId)
63 const members = kids.filter(m => m.workflowRunId === k.workflowRunId)
64 out.push({ id: `wf:${k.workflowRunId}`, label: `workflow ${k.workflowRunId}`, status: groupStatus(members),
65 depth, isLast: false, trail, kids: [], members })
66 } else {
67 out.push({ id: k.id, label: labelOf(k), status: k.status, node: k, depth, isLast: false, trail, kids: [] })
68 }
69 }
70 out.forEach((it, i) => {
71 it.isLast = i === out.length - 1
72 const nextTrail = [...trail, !it.isLast]
73 const members = it.members
74 it.kids = members
75 ? members.map((m, j) => ({ id: m.id, label: labelOf(m), status: m.status, node: m, depth: depth + 1,
76 isLast: j === members.length - 1, trail: nextTrail,
77 kids: itemsUnder(s, m.id, depth + 2, [...nextTrail, j !== members.length - 1]) }))
78 : itemsUnder(s, it.id, depth + 1, nextTrail)
79 })
80 return out
81}
82
83const flatten = (items: Item[]): Item[] => items.flatMap(it => [it, ...flatten(it.kids)])
84
85/** The main loop's item with the whole tree beneath it. */
86const rootItem = (s: DagState): Item => {
87 const main = s.nodes[MAIN]
88 const root: Item = { id: MAIN, label: main ? labelOf(main) : 'main', status: main?.status ?? 'idle', node: main, depth: 0, isLast: true, trail: [], kids: [] }
89 root.kids = itemsUnder(s, MAIN, 1, [true])
90 return root
91}
92
93const connector = (it: Item, g: typeof GLYPHS.unicode): string => {
94 if (it.depth === 0) return ''
95 const lead = it.trail.slice(1).map(more => (more ? g.pipe : g.blank)).join('')
96 return lead + (it.isLast ? g.elbow : g.tee)
97}
98
99const statusSeg = (st: AgentStatus, text: string): Segment => ({ text, ...STATUS_COLOR[st] })
100
101export const treeRows = (s: DagState, o: LayoutOptions): Row[] => {
102 const g = GLYPHS[o.glyphs]
103 const root = rootItem(s)
104 const items = flatten([root])
105 const cols = visibleColumns(o.columns)
106 const ell = ellipsisOf(o.glyphs)
107
108 // Fixed widths of the right-hand columns (including their leading space).
109 const wStatus = cols.status ? 8 : 0 // ' running'
110 const wElapsed = 7 // ' 1h03m ' style, right aligned in 6 + space
111 const wTokens = cols.tokens ? 7 : 0 // ' 12.4k'
112 const wModel = cols.model ? 8 : 0 // ' sonnet '
113 const wTool = cols.tool ? 10 : 0 // ' ToolSearc'
114 const right = wStatus + wElapsed + wTokens + wModel + wTool
115
116 return items.map(it => {
117 const conn = connector(it, g)
118 const glyph = g.status[it.status]
119 const head = [...conn].length + [...glyph].length + 1
120 const labelWidth = Math.max(MIN_LABEL, o.columns - head - right)
121 const room = o.columns - head - labelWidth // what is left for the right columns
122 const n = it.node
123 const row: Row = [
124 { text: conn, dim: true },
125 statusSeg(it.status, glyph),
126 { text: ' ' + fit(it.label, labelWidth, ell), bold: it.depth === 0 },
127 ]
128 let used = 0
129 const push = (w: number, text: string, extra: Partial<Segment> = {}) => {
130 if (w === 0 || used + w > room) return
131 used += w
132 row.push({ text: padLeft(text, w), ...extra })
133 }
134 if (cols.status) push(wStatus, STATUS_WORD[it.status], STATUS_COLOR[it.status])
135 push(wElapsed, n ? fmtElapsed(elapsedOf(n, o.now)) : '', { dim: true })
136 if (cols.tokens) push(wTokens, n ? fmtTokens(n.inputTokens + n.outputTokens) : '', { dim: true })
137 if (cols.model) push(wModel, n ? fit(shortModel(n.model), wModel - 1, ell).trimEnd() : '', { dim: true })
138 if (cols.tool) push(wTool, n?.currentTool ? fit(n.currentTool, wTool - 1, ell).trimEnd() : '', { color: 'claude' })
139 return row
140 })
141}
142
143export const cards = (s: DagState, o: LayoutOptions): Card[] => {
144 const g = GLYPHS[o.glyphs]
145 const dot = o.glyphs === 'ascii' ? ' - ' : ' · '
146 const ell = ellipsisOf(o.glyphs)
147 const root = rootItem(s)
148 // Keep room for detail fields even when the graph is very deeply nested.
149 const gutterLimit = Math.max(0, Math.floor((o.columns - 26) / 2) * 2)
150 const gutter = (prefix: string) => textWidth(prefix) <= gutterLimit ? prefix
151 : gutterLimit >= 4 ? ell + ' ' + prefix.slice(-(gutterLimit - 2)) : ''
152 return flatten([root]).map(it => {
153 const prefix = gutter(connector(it, g))
154 const continuation = gutter(it.depth === 0 ? ''
155 : it.trail.slice(1).map(more => more ? g.pipe : g.blank).join('') + (it.isLast ? g.blank : g.pipe))
156 const width = o.columns - textWidth(prefix)
157 const inner = width - 4
158 const n = it.node
159 const title = wrapText(it.label, inner - 3, 3, ell)
160 const rows: Row[] = [[statusSeg(it.status, g.status[it.status]), { text: ' ' + (title[0] ?? ''), bold: true }]]
161 for (const line of title.slice(1)) rows.push([{ text: ' ' + line, bold: true }])
162 // Pack whole fields onto successive lines instead of truncating the entire
163 // metadata row, which used to hide token, turn and tool counts.
164 const fields = (parts: string[]) => {
165 let line = ''
166 for (const part of parts) {
167 const next = line ? line + dot + part : part
168 if (line && textWidth(next) > inner) {
169 rows.push([{ text: line, dim: true }])
170 line = part
171 } else line = next
172 }
173 if (line) rows.push(...wrapText(line, inner, 2, ell).map(text => [{ text, dim: true }]))
174 }
175 if (n) {
176 fields([
177 STATUS_WORD[n.status],
178 shortModel(n.model) || '?',
179 fmtElapsed(elapsedOf(n, o.now)),
180 `${n.turns} turn${n.turns === 1 ? '' : 's'}`,
181 ])
182 if (n.deliveryNotice) rows.push(...wrapText(n.deliveryNotice, inner, 4, ell)
183 .map(text => [{ text, color: 'warning' }]))
184 fields([
185 `tokens ${fmtTokens(n.inputTokens + n.outputTokens)}`,
186 `in ${fmtTokens(n.inputTokens)}`, `out ${fmtTokens(n.outputTokens)}`,
187 `${n.toolCount} tool${n.toolCount === 1 ? '' : 's'}`,
188 ])
189 if (s.showTools) {
190 // The current tool has its own row and cannot be pushed off by history.
191 if (n.currentTool) rows.push(...wrapText(`now ${g.chip} ${n.currentTool}`, inner, 2, ell)
192 .map(text => [{ text, color: 'claude' }]))
193 if (n.recentTools.length) rows.push(...wrapText(`recent ${n.recentTools.join(` ${g.arrow} `)}`, inner, 2, ell)
194 .map(text => [{ text, dim: true }]))
195 }
196 if (s.showMessages) {
197 const messages = s.messages.filter(m => m.from === n.id || m.to === n.id)
198 if (messages.length) {
199 const latest = messages.reduce((a, b) => a.lastAt >= b.lastAt ? a : b)
200 const sent = messages.filter(m => m.from === n.id).reduce((sum, m) => sum + m.count, 0)
201 const received = messages.filter(m => m.to === n.id).reduce((sum, m) => sum + m.count, 0)
202 const outgoing = latest.from === n.id
203 const peer = outgoing ? latest.to : latest.from
204 fields([`msg ${sent} sent / ${received} received`])
205 const direction = outgoing ? g.arrow : o.glyphs === 'ascii' ? '<-' : '←'
206 const quote = `${direction} ${s.nodes[peer]?.label ?? peer}: "${latest.lastText}"`
207 rows.push(...wrapText(quote, inner, 2, ell).map(text => [{ text, dim: true }]))
208 }
209 }
210 } else if (it.members) {
211 fields([
212 `${it.members.length} agents`,
213 `${it.members.filter(m => m.status === 'running').length} running`,
214 `${it.members.filter(m => m.status === 'failed').length} failed`,
215 `tokens ${fmtTokens(it.members.reduce((sum, m) => sum + m.inputTokens + m.outputTokens, 0))}`,
216 ])
217 }
218 return { id: it.id, depth: it.depth, status: it.status, rows, prefix, continuation, width }
219 })
220}
221
222/** Bordered detail cards connected by the same branches as the compact tree. */
223export const cardRows = (s: DagState, o: LayoutOptions): Row[] => {
224 const ascii = o.glyphs === 'ascii'
225 const b = ascii ? { tl: '+', tr: '+', bl: '+', br: '+', h: '-', v: '|' }
226 : { tl: '╭', tr: '╮', bl: '╰', br: '╯', h: '─', v: '│' }
227 const out: Row[] = []
228 for (const card of cards(s, o)) {
229 const border = (text: string): Segment => statusSeg(card.status, text)
230 const title = card.rows[0]!
231 const titleWidth = textWidth(title.map(seg => seg.text).join(''))
232 out.push([{ text: card.prefix, dim: true }, border(b.tl + b.h + ' '), ...title,
233 border(' ' + b.h.repeat(Math.max(0, card.width - titleWidth - 5)) + b.tr)])
234 for (const row of card.rows.slice(1)) {
235 const size = textWidth(row.map(seg => seg.text).join(''))
236 out.push([{ text: card.continuation, dim: true }, border(b.v + ' '), ...row,
237 { text: ' '.repeat(Math.max(0, card.width - 4 - size)) }, border(' ' + b.v)])
238 }
239 out.push([{ text: card.continuation, dim: true }, border(b.bl + b.h.repeat(Math.max(0, card.width - 2)) + b.br)])
240 }
241 return out
242}
243
244export const messageRows = (s: DagState, o: LayoutOptions): Row[] => {
245 if (!s.showMessages || s.messages.length === 0) return []
246 const g = GLYPHS[o.glyphs]
247 const ell = ellipsisOf(o.glyphs)
248 // Bare labels (no type prefix) so a sender and a receiver read as names.
249 const nameOf = (id: string) => fit(s.nodes[id]?.label ?? id, NAME_WIDTH, ell).trimEnd()
250 const hasQuote = o.columns >= 60
251 // Two names, an arrow and spaces at most; narrower when the pane is.
252 const pairWidth = Math.max(10, Math.min(2 * NAME_WIDTH + 5, o.columns - 4 - (hasQuote ? 20 : 0)))
253 const rows: Row[] = [[{ text: 'messages', dim: true, bold: true }]]
254 const sorted = [...s.messages].sort((a, b) => b.lastAt - a.lastAt).slice(0, MAX_MESSAGES)
255 for (const m of sorted) {
256 const pair = ` ${nameOf(m.from)} ${g.arrow} ${nameOf(m.to)}`
257 const row: Row = [{ text: fit(pair, pairWidth, ell) }, { text: padLeft(String(m.count), 4), dim: true }]
258 if (hasQuote) {
259 const room = o.columns - pairWidth - 4 - 3 // space, two quotes
260 if (room > 3) row.push({ text: ` "${fit(m.lastText, room, ell).trimEnd()}"`, dim: true })
261 }
262 rows.push(row)
263 }
264 return rows
265}
266
267export const summary = (s: DagState, glyphs: GlyphSet): string => {
268 const agents = s.order.filter(id => id !== MAIN)
269 const running = agents.filter(id => s.nodes[id]?.status === 'running').length
270 const sep = glyphs === 'ascii' ? ' - ' : ' · '
271 return `${agents.length} agent${agents.length === 1 ? '' : 's'}${sep}${running} running`
272}
273
274export const plainText = (rows: Row[]): string =>
275 rows.map(r => r.map(seg => seg.text).join('').trimEnd()).join('\n')
276hooks/format.ts 118 lines1import type { AgentStatus, GlyphSet } from '../types'
2
3export type Glyphs = {
4 status: Record<AgentStatus, string>
5 tee: string
6 elbow: string
7 pipe: string
8 blank: string
9 arrow: string
10 chip: string
11 group: string
12}
13
14export const GLYPHS: Record<GlyphSet, Glyphs> = {
15 unicode: {
16 status: { pending: '◌', running: '◐', waiting: '○', idle: '●', completed: '✓', failed: '✗', killed: '⊘' },
17 tee: '├─', elbow: '└─', pipe: '│ ', blank: ' ', arrow: '→', chip: '▸', group: '▸',
18 },
19 ascii: {
20 status: { pending: '.', running: '>', waiting: 'o', idle: '*', completed: '+', failed: 'x', killed: '-' },
21 tee: '|-', elbow: '`-', pipe: '| ', blank: ' ', arrow: '->', chip: '>', group: '>',
22 },
23}
24
25export const STATUS_WORD: Record<AgentStatus, string> = {
26 pending: 'pending', running: 'running', waiting: 'waiting', idle: 'idle',
27 completed: 'done', failed: 'failed', killed: 'killed',
28}
29
30export const STATUS_COLOR: Record<AgentStatus, { color: string; dim?: boolean }> = {
31 pending: { color: 'subtle' },
32 running: { color: 'claude' },
33 waiting: { color: 'warning' },
34 idle: { color: 'subtle', dim: true },
35 completed: { color: 'success' },
36 failed: { color: 'error' },
37 killed: { color: 'error', dim: true },
38}
39
40export const fmtElapsed = (ms: number): string => {
41 const s = Math.max(0, Math.floor(ms / 1000))
42 if (s < 60) return `${s}s`
43 const m = Math.floor(s / 60)
44 if (m < 60) return `${m}m${String(s % 60).padStart(2, '0')}s`
45 const h = Math.floor(m / 60)
46 return `${h}h${String(m % 60).padStart(2, '0')}m`
47}
48
49export const fmtTokens = (n: number): string => {
50 if (n < 1000) return String(n)
51 if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k`
52 return `${(n / 1_000_000).toFixed(1)}M`
53}
54
55const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' })
56const graphemes = (text: string): string[] => Array.from(segmenter.segment(text), s => s.segment)
57
58const cellWidth = (text: string): number => {
59 if (/^[\p{Mark}\p{Cf}]+$/u.test(text)) return 0
60 if (text.includes('\ufe0f') || /\p{Regional_Indicator}/u.test(text)) return 2
61 return [...text].some(ch => {
62 const cp = ch.codePointAt(0)!
63 return cp >= 0x1100 && (
64 cp <= 0x115f || cp === 0x2329 || cp === 0x232a ||
65 (cp >= 0x2e80 && cp <= 0xa4cf && cp !== 0x303f) ||
66 (cp >= 0xac00 && cp <= 0xd7a3) || (cp >= 0xf900 && cp <= 0xfaff) ||
67 (cp >= 0xfe10 && cp <= 0xfe19) || (cp >= 0xfe30 && cp <= 0xfe6f) ||
68 (cp >= 0xff00 && cp <= 0xff60) || (cp >= 0xffe0 && cp <= 0xffe6) ||
69 (cp >= 0x1f000 && cp <= 0x1faff) || (cp >= 0x20000 && cp <= 0x3fffd)
70 )
71 }) ? 2 : 1
72}
73
74/** Terminal cells, keeping combining accents and joined emoji together. */
75export const textWidth = (text: string): number => graphemes(text).reduce((n, ch) => n + cellWidth(ch), 0)
76
77const takeCells = (text: string, width: number): string => {
78 let out = '', used = 0
79 for (const ch of graphemes(text)) {
80 const size = cellWidth(ch)
81 if (used + size > width) break
82 out += ch
83 used += size
84 }
85 return out
86}
87
88export const fit = (text: string, width: number, ellipsis = '…'): string => {
89 if (width <= 0) return ''
90 const size = textWidth(text)
91 if (size <= width) return text + ' '.repeat(width - size)
92 const clipped = takeCells(text, Math.max(0, width - textWidth(ellipsis))) + ellipsis
93 return clipped + ' '.repeat(Math.max(0, width - textWidth(clipped)))
94}
95
96export const padLeft = (text: string, width: number): string =>
97 textWidth(text) >= width ? text : ' '.repeat(width - textWidth(text)) + text
98
99/** Word-wrap short detail fields; only the final permitted line is truncated. */
100export const wrapText = (text: string, width: number, maxLines = 2, ellipsis = '…'): string[] => {
101 let rest = text.replace(/\s+/g, ' ').trim()
102 if (width <= 0 || !rest || maxLines <= 0) return []
103 const rows: string[] = []
104 while (rest && rows.length < maxLines) {
105 if (textWidth(rest) <= width || rows.length === maxLines - 1) {
106 rows.push(fit(rest, width, ellipsis).trimEnd())
107 break
108 }
109 let head = takeCells(rest, width)
110 if (!head) { rows.push(fit(rest, width, ellipsis).trimEnd()); break }
111 const space = head.lastIndexOf(' ')
112 if (space > 0 && textWidth(head.slice(0, space)) >= width / 2) head = head.slice(0, space)
113 rows.push(head.trimEnd())
114 rest = rest.slice(head.length).trimStart()
115 }
116 return rows
117}
118types/index.d.ts 58 lines1export type AgentStatus =
2 | 'pending' | 'running' | 'waiting' | 'idle'
3 | 'completed' | 'failed' | 'killed'
4
5// Persisted names retained for hot reloads: tree = compact, cards = detailed.
6export type View = 'tree' | 'cards'
7export type GlyphSet = 'unicode' | 'ascii'
8
9export type AgentNode = {
10 id: string
11 parentId?: string
12 label: string
13 type: string
14 name?: string
15 teammateId?: string
16 isTeammate: boolean
17 workflowRunId?: string
18 model?: string
19 status: AgentStatus
20 startedAt: number
21 endedAt?: number
22 turns: number
23 inputTokens: number
24 outputTokens: number
25 toolCount: number
26 currentTool?: string
27 recentTools: string[]
28 deliveryNotice?: string
29}
30
31export type MessageEdge = {
32 from: string
33 to: string
34 count: number
35 lastAt: number
36 lastText: string
37}
38
39export type DagState = {
40 nodes: Record<string, AgentNode>
41 order: string[]
42 messages: MessageEdge[]
43 view: View
44 showMessages: boolean
45 showTools: boolean
46 isClosedByPerson: boolean
47 hasOpened: boolean
48 tick: number
49}
50
51declare module 'claude-code' {
52 interface PluginState {
53 // Shaped: the atom carries a shape tag, so a reload whose code names a
54 // new tag starts fresh instead of reading an old layout.
55 'agent-dag': { dag: Shaped<DagState> }
56 }
57}
58