SLOPSHOPPER

agent-dag

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

paneguardcommandtimeragents
v0.3.0MITupdated 2026-10-20Qifei-C/agent-dag
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-dag
│ ┃ Agents ✕ › fix the failing auth test and add an audit log call │ ┃ No agents yet. The pane fills when the │ ┃ session spawns one. ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ v: compact m: messages t: tools0 agents · 0 ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /dag │ ⎿ agent-dag: Agents pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Agents
No agents yet. The pane fills when the session spawns one. v: compact m: messages t: tools 0 agents · 0 running
README

agent-dag

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.

Install the CLI

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 sessions

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.

Codex adapter

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.

Send and receive messages

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.

Usage limits and paused delivery

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.

Session overview and storage

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.

Command reference

Core CLI commands:

CommandPurpose
dag create NAME -- COMMANDCreate and register a detached tmux session.
dag register NAMERegister 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 TEXTQueue a message for a registered session.
dag inbox [NAME]Read messages for the current or named session.
dag ack IDConfirm 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 messagesShow the complete message history.
dag sessions --watchFollow session status and queued counts.
dag view [--pane]Open the shared card/tree dashboard.
dag attach NAMEFocus a registered session's pane.

Run dag --help for command arguments. For an agent workflow, see the session-coordination skill.

Claude Code plugin

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                          │
  ╰────────────────────────────────────────────────────────────────────╯

Use the pane

  • The pane opens by itself the first time the session spawns an agent. The engine only seats an unasked pane on a wide terminal (144 columns, or 110 once you have opened it by hand); narrower than that it waits until you run /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.
  • With the pane focused (click it, or ctrl+x then tab): 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.
  • Below 36 body columns, the pane automatically uses the compact layout. It restores the selected density when widened. Deep branches shorten their ancestor gutter to preserve room for card content. Chinese text and joined emoji are measured in terminal cells so card borders stay aligned.

Options (/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.

OptionValuesDefault
Glyph setunicode, asciiunicode
Open on first agenton, offon
Default densitydetailed, compactdetailed

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.

Install the plugin

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.

Develop

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.

Source 5 files
hooks/register.tsx 195 lines
1import { 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}
195
hooks/model.ts 217 lines
1import 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}
217
hooks/draw.ts 276 lines
1import 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')
276
hooks/format.ts 118 lines
1import 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}
118
types/index.d.ts 58 lines
1export 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