SLOPSHOPPER

subagents

Live pane of running subagents: their model, tool calls and streamed text, with a one-line band when the pane is hidden

newpanebandguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · subagents
│ ┃ Subagents ✕ › fix the failing auth test and add an audit log call │ ┃ Subagents: 0 running, 0 done │ ┃ 0: All ⏺ Read(src/auth.ts) │ ┃ No subagents yet. They show here once Claude ⎿ Read 6 lines │ ┃ starts one. ⏺ Update(src/auth.ts) │ ┃ 0-9, a-z tabs · ctrl+x x or Esc closes ⎿ 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 │ │ › /agents-pane │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Subagents
Subagents: 0 running, 0 done 0: All No subagents yet. They show here once Claude starts one. 0-9, a-z tabs · ctrl+x x or Esc closes
README

te6-in/dotfiles

An opinionated macOS web dev environment, powered by a custom Claude Code config.

Claude Code config

CLAUDE.md with habits Claude doesn't form on its own

Covers how the agent investigates, edits, and asks.

  • Pipe expensive command output to /tmp once and grep locally.
  • Programmatic shell edits like sed -i require a clean git state and a dry-run first.
  • Lean on AskUserQuestion at any hint of ambiguity, not free-form chat questions.
  • Korean in a tool-call parameter goes in as literal UTF-8, never as \uXXXX escapes.
  • ... and more

Hooks that catch agent footguns

  • rm and rmdir are turned back with a pointer to trash, so deletes stay recoverable.
  • Lockfile edits are blocked.

MCP servers, ready out of the box

Every project picks up Chrome DevTools for Agents and SEED Design Figma/Docs integration.

One workflow across Linear, Slack, and Notion

Decisions, state changes, and findings move out of the private chat onto surfaces the team actually checks.

  • Sessions anchor to a Linear issue and drive its status (Todo → In Progress → In Review → Done). No GitHub integration does this — the agent does, at the first code edit and then off PR events, and it asks first on any issue with sub-issues, where the status is a signal other people read.
  • /comment posts decisions back to the issue thread.
  • /notion promotes durable findings to a default Notion page.
  • Slack self-DMs are pre-approved for quick notes.

Codex config

packages/codex/ supplies Codex copies of the harness-specific skills in packages/claude/ and harness-specific instructions. Stow it into the home directory alongside agents. Its global AGENTS.md links to packages/codex/.codex/AGENTS.md, which includes the full text of the shared rules and all Codex-specific instructions. Codex loads this body at startup without asking the model to read another instruction file. It does not discover the Markdown rules directory or filter it by paths itself.

After editing the rule or Codex instruction sources, run python3 scripts/sync-codex-instructions.py to regenerate AGENTS.md; --check detects a stale snapshot. No developer_instructions loader is needed. Existing Codex model and app preferences are preserved.

claude-recall and claude-recap live under .codex/skills/ for Codex-only discovery: the former searches local Claude sessions and returns resume commands, while the latter reconstructs a known Claude session's dialogue into the current Codex conversation. The Claude originals remain separate. After an app restart, Slack self-DM approval and prompting for other destinations were verified. GitHub human-approval routing remains unregistered. The adapter uses direnv per command when project environment is needed and the existing notify CLI for explicit notification requests.

One set of instructions, two agents

Anything that holds regardless of which agent is running — skills and standing instructions alike — lives once in packages/agents/ and is written harness-neutral: it says "ask the user", not the name of one harness's prompt tool. The package projects that single copy into each agent's expected path with symlinks, so both read the same file and an edit lands everywhere at once:

packages/agents/
├─ .agents/skills/<name>/        # the actual files
├─ .agents/rules/<name>.md       #  ″
├─ .claude/skills/<name>         # → ../../.agents/skills/<name>
├─ .claude/rules/<name>.md       # → ../../.agents/rules/<name>.md
├─ .gemini/config/skills/<name>  # → ../../../.agents/skills/<name>
└─ .gemini/config/rules/<name>.md

One frontmatter serves both, because each ignores the other's keys. Claude Code scopes a rule with paths: and loads it unconditionally when that key is absent; agy needs an explicit trigger: and scopes with glob::

---
description: JavaScript, TypeScript, and React code style conventions.
paths: ["**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs,vue,svelte,astro,mdx}"]
trigger: glob
glob: "**/*.ts, **/*.tsx, **/*.mts, **/*.cts, **/*.js, **/*.jsx, **/*.mjs, **/*.cjs, **/*.vue, **/*.svelte, **/*.astro, **/*.mdx"
---

Write a description that says what the file governs, not what it says, so it survives edits to the body. For trigger: model_decision the description is the only thing the model sees up front, so there it has to carry the "when does this apply" on its own.

Both agents also honor disable-model-invocation, so a /-only skill stays /-only in both.

context: fork is worth setting on a neutral skill even though only Claude Code reads it. A read-heavy, report-shaped one — catchup, review-comments — spends its whole budget on raw material the caller never needs (a branch diff, an unfiltered dump of every PR comment), and forking keeps all of it out of the calling conversation. agy ignores the key and runs the skill inline as it always did, so the body has to hold up either way: it says the skill may run in a fork, and a step that wanted to ask the user makes the call itself and reports which way it went, since a fork has nobody to ask.

Harness-specific originals stay in packages/claude/, with Codex equivalents in packages/codex/. The Claude session skills have Codex-only copies named claude-recall and claude-recap. Both harnesses have adapters for asking-the-user, notifications, local-references, notion, and model-name. These translate shared intent into the tools and settings each host provides. Claude imports its five adapters from CLAUDE.md; Codex includes their complete bodies and its shell/workflow instructions in the generated global AGENTS.md. The neutral rules must not also be imported from CLAUDE.md, or they load twice there.

Gotchas worth remembering, all found the hard way:

  • agy needs an absolute path. Its skills.json documents ~/-relative entries, but the CLI rejects them (path is not absolute). Linking into ~/.gemini/config/, its global discovery directory, sidesteps the config file entirely and ranks higher in its precedence order.
  • agy parses frontmatter as strict YAML. An unquoted description containing : is invalid YAML; Claude Code accepts it anyway, so a broken file can sit there for months looking fine. Use a >- block for anything long.
  • glob is singular, and its value is one comma-separated string. globs: ["*.ts"] fails to unmarshal and silently discards the entire rule, trigger and all. Brace expansion doesn't survive the comma split either — write **/*.ts, **/*.tsx, not **/*.{ts,tsx}. Quote the value, since a bare * opens a YAML alias.
  • trigger accepts exactly always_on, model_decision, manual, glob. Anything else, including a missing frontmatter block, drops the rule without a word.
  • agy truncates a rule body at 24,000 characters — not the 12,000 its public docs claim.

Self-contained, and MECE

Every file here that instructs an agent — a SKILL.md, a rule, CLAUDE.md itself — holds both properties at once, and the obvious fix for either one breaks the other.

Self-contained means whoever acts on a section can act from that section. The tell that it isn't is a cross-reference by section name: "run Whose issue is it first", "per When the flow runs late above". Each one is a jump taken mid-task, with the work already in hand.

MECE means every rule written down in exactly one place, and every case the file claims to cover reachable from somewhere. Duplication is the more expensive half — two copies drift, nothing marks which one is current, and whoever finds the stale copy has no way to tell. A gap at least announces itself when somebody hits it.

The trap is that inlining a rule to remove a cross-reference duplicates it, and adding a pointer to remove a duplicate breaks self-containedness. Neither is the fix. A rule referenced from several places is a rule living in the wrong place — move it to where it is used and the references go away with it. linear-workflow kept its late-arrival rule in the issue-identification section and pointed at it three times from the lifecycle section; moving the rule into the lifecycle retired all three pointers and left one statement. Where no single home exists, keep the one statement and point at it by what it does rather than by its heading — "the sub-issue check below" survives a rename, and tells the reader whether they need to go at all.

Both properties break through ordinary editing rather than through bad writing: a rule appended under the nearest heading instead of its own, a section that quietly grew a second copy of something two headings up. So read the file back for both after editing it.

Third-party skills stay on npx skills

npx skills owns ~/.agents/skills/ for skills pulled from other people's repos, and materializes them there as real directories. Stow puts its symlinks in the same directory, which is fine — the two only collide if the same skill name is managed by both. Don't let that happen.

Don't hand your own skills to npx skills either: it copies from the source instead of linking, and skills update ignores sourceType: local, so every edit would need a re-install to take effect.

Its lockfile is stowed out of packages/agents/.agents/.skill-lock.json, so this repo carries the manifest — a Brewfile for skills, listing every source repo and which skills came from it. npx skills add -g writes through the symlink, so it stays current on its own. There's no matching bundle install, though: the CLI's experimental_install only replays a project's skills-lock.json into ./.agents/skills/ and never reads the global one, hence the loop in step 5 of the bootstrap.

Quick access to iOS/Android targets

Fish abbreviations expand inline to the real xcrun / adb command, so the URL stays editable. Swap it for any deep link like myapp://route.

  • simsaf <NAME> opens a portless route in iOS Simulator Safari.
  • andshell <NAME> opens one in WebView Shell, on whichever device adb is attached to. and, not em, because the URL names no host — emulator and physical device take the same command. Only WebView Shell itself is emulator-flavored: system images ship it, retail devices generally don't.
  • The *u variants take a literal URL instead, for a server portless isn't fronting.
  • Each one pipes the URL through showurl on the way in, so the address that actually got assembled is on screen — a launcher opens the app without a word otherwise, and a deep link built wrong looks identical to one built right. showurl prints on stderr precisely because the launcher is reading it off stdout.

Deep links into a host app, from the shell and the agent alike

Opening a dev server inside an app — its web view, its native renderer — means percent-encoding the whole dev URL into a single query parameter of a private scheme. Three things about that resist being written inline, and each one fails silently: a partly encoded URL still parses, so the app opens a blank screen rather than complaining; the scheme is internal and shouldn't reach a public commit; and simctl openurl booted refuses outright once two simulators are booted, as adb does with two devices.

deeplink holds all three, and both callers go through it — the simw/andw/qrw abbreviations in a gitignored abbreviations.local.fish, and the open-deeplink skill the agent loads. The schemes live in ~/.config/deeplink/deeplinks.local.json, gitignored beside a committed deeplinks.json.example, so adding a view is a config edit rather than a code change. It looks no address up — --url is required, and resolving a portless route stays with the caller: plurl in the abbreviations, and for the agent whatever local-dev-server.md says. The script is therefore free of any assumption about how dev servers are addressed here, while the scheme, the encoding, and the several-devices-attached case still exist exactly once.

--qr hands the link to showqr, which picks its rendering off the caller — half-block glyphs on a terminal, a PNG opened in a viewer everywhere else. An agent's shell strips the ESC byte out of every colour code, which unpicks the light and dark modules a text QR is made of, and deeplink runs the same check to keep the bold link plain there rather than leaving [1m behind. showqr encodes any string, so it stands on its own outside a deep link, and it replaced the fish function of the same name so that one rendering serves both callers.

One URL per app, no port numbers

portless fronts every dev server with a named host under a fixed .test domain, so the address survives restarts and reaches phones and emulators unchanged. PORTLESS_TLD picks the domain, in a gitignored portless.local.fish.

  • <app>.<name>.test per project, <branch>.<app>.<name>.test per git worktree.
  • The simulator abbreviations resolve that URL through plurl, so no host or port is ever typed — not localhost, not Android's 10.0.2.2, not a LAN or tailnet IP for a physical device.
  • Claude Code is told to read the URL off portless list instead of guessing localhost:3000.

plurl with no name resolves the route serving the current directory. portless get needs a name and has no cwd fallback, and its inferProjectName (portless.json → package.json → git root → directory name) is internal, so the obvious move is to reimplement that chain — which drifts from portless the moment it changes, and still can't separate two worktrees of one package, since the hostname prefix comes off the branch rather than the directory. Instead plurl reads the pid portless list already prints for every live route and matches $PWD against that process's cwd, deepest enclosing directory first. That is measurement, not inference: it cannot answer wrongly, only fail to answer, and then it falls back to listing the routes for you to name. Alias routes carry no pid and never match, which is correct — a static route has no project directory.

Like showqr, it replaced the fish function of the same name and lives in bin as POSIX sh, so the abbreviations and the agent's tool shell reach one implementation by name. A fish function is invisible to every other shell, and documenting a fish -c plurl workaround only spreads the shell's name into instructions that have nothing to do with it.

portless binds loopback only. Its --lan flag opens 0.0.0.0 but hard-forces the TLD to .local, discarding the issued domain — so portless-lan-forward installs a root daemon that relays 0.0.0.0:80 to 127.0.0.1:80 instead, leaving the TLD alone. It knows nothing about the machine's IP, so it ports to a new machine as-is. docs/portless-drop-lan-forwarder.md retires it if portless ever decouples the two.

portless-proxy-service installs the other half, the root daemon that holds port 80. It replaces portless service install, which pins the plist to the versioned Cellar path of the node it ran under — one brew upgrade node later the daemon cannot exec, an unprivileged proxy takes over on a high loopback port, and every device URL breaks while the machine keeps working. This one execs the portless launcher instead, so no version appears in the plist. portless-proxy-service status names that failure when it happens.

Bootstrap

Install with Homebrew and GNU Stow.

# 1. Install Homebrew
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"

# 2. Clone
git clone <this-repo> ~/Projects/dotfiles
cd ~/Projects/dotfiles

# 3. Install everything from Brewfile
brew bundle install

# 4. Stow every package into $HOME. Run stow from packages/, where .stowrc
#    sets the target to ~ and ignores .DS_Store.
cd packages && stow */

# Or selectively
stow fish git starship

# 5. Reinstall third-party skills from the committed lockfile
jq -r '.skills | to_entries | group_by(.value.source)[] | "\(.[0].value.source) \(map(.key) | join(" "))"' \
  ~/.agents/.skill-lock.json | while read -r src skills; do npx skills add "$src" -g -y --skill $skills; done

# 6. Install the portless root daemons — proxy on port 80, plus the LAN relay in front
#    of it. Needs portless.local.fish in place first, for $PORTLESS_TLD.
sudo portless-proxy-service install
sudo portless-lan-forward install

# 7. (Optional) Enable brew autoupdate
brew autoupdate start --upgrade --immediate --cleanup --sudo

Nothing that identifies the work

This repo is public. Nothing committed may reveal the employer, the people there, or what the work is: no company or product names, no colleagues' names, no internal hostnames, Slack channels or their IDs, no issue keys, and no decisions, specs, or numbers lifted from real work. Paths go through ~ or $HOME, never a literal username.

A value the setup genuinely needs, like the GitHub orgs that count as work repos, goes in an environment variable set from a gitignored per-machine file, and the committed file refers to the variable. Anything that only illustrates a point uses made-up values instead — Acme-Corp, ABC-1234, example.invalid.

The usual leak is a skill or rule written from a real session: the case that motivated it comes along as its example, with the real channel, the real colleague, and the real numbers still in it. Swap those for invented ones before committing, and when a diff adds prose, read it for this as well as for correctness.

Per-machine overrides

Anything matching *.local.* or *.secret.* is gitignored, so machine-specific values live next to their checked-in counterpart.

packages/fish/.config/fish/conf.d/
├─ linear.fish.example   # checked in template
└─ linear.local.fish     # ignored, real values

When .local naming doesn't fit, fold / unfold

When the filename itself is meaningful (e.g. Claude Code's commands/X.md, where the filename is the slash command), the per-machine file has to live outside the repo, directly under ~/.... But if stow has folded that directory into a single symlink back to the repo, new files inside it will land in the repo instead. Unfold once first.

rm ~/<path> && mkdir ~/<path>
cd ~/Projects/dotfiles/packages && stow -R <package>

Uninstall

cd ~/Projects/dotfiles/packages
stow -D <package>   # one package
stow -D */          # everything

Notes

  • ~/.config/karabiner must be symlinked as a whole directory (Docs).
  • ~/.config/openlogi must be symlinked as a whole directory too. The OpenLogi GUI saves config.toml by renaming a temp file over it, which would replace a file-level symlink with a plain file.
Source 4 files
hooks/register.tsx 260 lines
1import { atom, read, update } from 'claude-code';
2import type { Register, Timer } from 'claude-code';
3import type { AgentLogLine, TrackedAgent } from '../types';
4import {
5  LOG_LINES,
6  TICK_MS,
7  agentDone,
8  agentHeading,
9  applyListed,
10  hydrate,
11  isIdle,
12  isSettled,
13  noteSpawn,
14  planTick,
15  requestPaneCheck,
16  setPaneShown,
17  snapshot,
18  stepChunk,
19  stepEnded,
20  stepStarted,
21  toolEnded,
22  toolStarted,
23  track,
24} from './agent-board';
25
26const AGENTS_PANE = 'agents';
27const AGENTS_TITLE = 'Subagents';
28
29const board = atom({ plugin: 'subagents', key: 'board' } as const, {
30  agents: [],
31  at: 0,
32  isBatchClosed: false,
33  isPaneShown: false,
34});
35const tab = atom({ plugin: 'subagents', key: 'tab' } as const, 'all');
36
37const STATUS_COLOR: Readonly<Record<string, string>> = {
38  pending: 'inactive',
39  running: 'warning',
40  waiting: 'warning',
41  idle: 'inactive',
42  completed: 'success',
43  failed: 'error',
44  killed: 'error',
45};
46
47/** 0 is All; agents 1-9 take digits, 10-35 lowercase letters (no letter is bound inside a pane). */
48const TAB_KEYS = [...'123456789abcdefghijklmnopqrstuvwxyz'];
49
50const LINE_MARK = { run: '·', ok: '✓', err: '✗' } as const satisfies Record<NonNullable<AgentLogLine['state']>, string>;
51
52let ticker: Timer | undefined;
53let isTicking = false;
54
55/**
56 * Starts the ticker that writes the board and opens/closes the pane. Set by
57 * `session.start`, the one hook whose `$` the ticker uses, since the engine
58 * refuses a module that passes `$` around.
59 */
60let wake: () => void = () => undefined;
61
62function safe(fn: () => void) {
63  try {
64    fn();
65  } catch {
66    // An observer never lets its own bookkeeping fail the call it watches.
67  }
68}
69
70function observe(fn: () => void) {
71  safe(fn);
72  safe(wake);
73}
74
75const lineText = (line: AgentLogLine) =>
76  line.kind === 'tool' ? `  ${LINE_MARK[line.state ?? 'run']} ${line.text}` : line.kind === 'text' ? `  » ${line.text}` : `  ${line.text}`;
77
78const elapsed = (agent: TrackedAgent, at: number) =>
79  `${Math.max(0, Math.round(((agent.endedAt ?? at) - agent.startedAt) / 1000))}s`;
80
81export const register: Register = (on) => {
82  on('session.start', async ($, e, next) => {
83    const tick = async () => {
84      if (isTicking) return;
85
86      isTicking = true;
87      try {
88        const plan = planTick();
89        if (plan.shouldOpen && !(await $.ui.panes()).some((pane) => pane.id === AGENTS_PANE)) {
90          const opened = await $.ui.open({ id: AGENTS_PANE, title: AGENTS_TITLE, closeOnEscape: true });
91          if (!opened.isPlaced) $.ui.toast('Subagents pane waits for a 144-column terminal. Run /agents-pane to show it now.');
92        }
93        if (plan.shouldSync) applyListed(await $.agent.list());
94        if (plan.shouldCheckPanes || plan.shouldOpen) {
95          setPaneShown((await $.ui.panes()).some((pane) => pane.id === AGENTS_PANE && pane.isPlaced && pane.isShown));
96        }
97        if (plan.shouldClose) await $.ui.close({ id: AGENTS_PANE });
98        if (plan.shouldFlush) await update($, board, () => snapshot());
99      } catch (error) {
100        $.ui.log(`subagents: tick failed: ${String(error)}`, { to: 'debug' });
101      } finally {
102        isTicking = false;
103      }
104      if (isIdle()) {
105        ticker?.cancel();
106        ticker = undefined;
107      }
108    };
109    wake = () => {
110      if (ticker === undefined) ticker = $.clock.every(TICK_MS, tick);
111    };
112
113    try {
114      await $.command.register({ name: 'agents-pane', description: 'Show the live subagents pane', immediate: true });
115      hydrate(await read($, board));
116      wake();
117    } catch (error) {
118      $.ui.log(`subagents: setup failed: ${String(error)}`, { to: 'debug' });
119    }
120    return next(e);
121  });
122
123  on('agent.spawn', async ($, e, next) => {
124    const spawned = await next(e);
125    const { agentId } = spawned;
126    if (agentId !== undefined) {
127      observe(() =>
128        noteSpawn(agentId, {
129          type: e.subagentType,
130          description: e.description,
131          model: spawned.model,
132          detail: `started${e.background ? ' in background' : ''}`,
133        }),
134      );
135    }
136    return spawned;
137  }).catch(($, e, next) => next(e));
138  on('tool.call', async ($, e, next) => {
139    const { agentId } = e;
140    if (agentId === undefined) return next(e);
141
142    observe(() => toolStarted(agentId, e, e.tool, e.tool_use_id));
143    const result = await next(e);
144    observe(() => toolEnded(agentId, e.tool_use_id, result));
145    return result;
146  }).catch(($, e, next) => next(e));
147  on('turn.step', async function* ($, e, next) {
148    const { agentId } = e;
149    if (agentId === undefined) return yield* next(e);
150
151    observe(() => stepStarted(agentId, e.model, e.effort));
152    try {
153      for await (const chunk of next(e)) {
154        observe(() => stepChunk(agentId, chunk));
155        yield chunk;
156      }
157    } finally {
158      observe(() => stepEnded(agentId));
159    }
160  });
161  on('turn.complete', ($, e, next) => {
162    const { agentId } = e;
163    if (agentId !== undefined) observe(() => agentDone(agentId, e));
164    return next(e);
165  }).catch(($, e, next) => next(e));
166  on('ui.close', { id: AGENTS_PANE }, ($, e, next) => {
167    observe(requestPaneCheck);
168    return next(e);
169  }).catch(($, e, next) => next(e));
170  on('command.run', { command: 'agents-pane' }, async ($) => {
171    const opened = await $.ui.open({ id: AGENTS_PANE, title: AGENTS_TITLE, focus: true, closeOnEscape: true });
172    observe(requestPaneCheck);
173    return opened.isPlaced ? {} : { text: `Subagents pane is waiting: ${opened.reason}` };
174  }).catch(() => ({ text: 'agents-pane: could not open the pane (see claude --debug)' }));
175  on('ui.render', { component: 'Pane', requestId: AGENTS_PANE }, async ($, e) => {
176    const { Box, Text, Button } = $.ui.resolve(e);
177    try {
178      const { agents: list, at } = await read($, board);
179      const picked = await read($, tab);
180
181      const selected = list.find((agent) => agent.id === picked);
182      const running = list.filter((agent) => !isSettled(agent.status)).length;
183      const perAgent =
184        selected !== undefined
185          ? LOG_LINES
186          : Math.max(2, Math.floor((Math.max(8, e.props.scroll.bodyRows) - 3) / Math.max(1, list.length)) - 1);
187
188      return (
189        <Box flexDirection="column">
190          <Text bold>{`${AGENTS_TITLE}: ${running} running, ${list.length - running} done`}</Text>
191          <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
192            <Button key="tab-all" label="All" hotkey="0" plain dimColor={selected !== undefined} onPress={() => update($, tab, () => 'all')} />
193            {list.slice(0, TAB_KEYS.length).map((agent, index) => (
194              <Button
195                key={`tab-${TAB_KEYS[index] ?? index}`}
196                label={agent.type}
197                hotkey={TAB_KEYS[index]}
198                plain
199                dimColor={selected?.id !== agent.id}
200                onPress={() => update($, tab, () => agent.id)}
201              />
202            ))}
203          </Box>
204          {list.length === 0 && <Text dimColor>No subagents yet. They show here once Claude starts one.</Text>}
205          {(selected === undefined ? list : [selected]).map((agent) => (
206            <Box flexDirection="column" marginTop={1}>
207              <Box flexDirection="row" columnGap={1}>
208                <Text color={STATUS_COLOR[agent.status] ?? 'text'}>●</Text>
209                <Text bold>{agentHeading(agent)}</Text>
210                <Text wrap="truncate-end">{agent.description}</Text>
211                <Text dimColor wrap="truncate-end">
212                  {`${agent.status} ${elapsed(agent, at)} · ${agent.tools} tools`}
213                </Text>
214              </Box>
215              {agent.lines.slice(-perAgent).map((line) => (
216                <Text
217                  wrap="truncate-end"
218                  dimColor={line.kind === 'note' || line.state === 'ok'}
219                  {...(line.state === 'err' && { color: 'error' })}
220                  {...(line.kind === 'text' && { italic: true })}
221                >
222                  {lineText(line)}
223                </Text>
224              ))}
225              {agent.live !== undefined && (
226                <Text wrap="truncate-start" color="claude">
227                  {`  ▸ ${agent.live}`}
228                </Text>
229              )}
230            </Box>
231          ))}
232          <Text dimColor wrap="truncate-end">
233            {`0-9, a-z tabs · ctrl+x x or Esc closes${running === 0 && list.length > 0 ? ' · closes itself 15s after the last agent ends' : ''}`}
234          </Text>
235        </Box>
236      );
237    } catch (error) {
238      return <Text color="error">{`subagents: ${String(error)}`}</Text>;
239    }
240  });
241  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
242    if (e.props.hasSurvey) return next(e);
243
244    try {
245      const shown = await read($, board);
246      if (shown.agents.length === 0 || shown.isBatchClosed === true || shown.isPaneShown === true) return next(e);
247
248      const running = shown.agents.filter((agent) => !isSettled(agent.status)).length;
249      const { Text } = $.ui.resolve(e);
250      return (
251        <Text wrap="truncate-end">
252          {`${AGENTS_TITLE}: ${running} running · ${shown.agents.length - running} done — /agents-pane`}
253        </Text>
254      );
255    } catch {
256      return next(e);
257    }
258  });
259};
260
hooks/agent-board.ts 279 lines
1import type { AgentInfo, ToolCallResult, TurnCompleteInput, TurnStepChunk } from 'claude-code';
2import type { AgentBoard, AgentLogLine, TrackedAgent } from '../types';
3import { oneLine, summarizeToolInput, tail } from './tool-input-summary';
4
5export const LOG_LINES = 30;
6export const TICK_MS = 300;
7const MAX_AGENTS = 40;
8const SYNC_TICKS = 7;
9const CLOSE_TICKS = Math.ceil(15_000 / TICK_MS);
10const STREAM_KEEP = 400;
11
12/**
13 * The subagents the pane shows, held in module memory: hooks mutate it with no
14 * `$` call, and the ticker `session.start` owns writes it to `$.state`.
15 */
16const agents = new Map<string, TrackedAgent>();
17const streams = new Map<string, { head: string; tail: string }>();
18
19const flags = {
20  isDirty: false,
21  wantsOpen: false,
22  isBatchClosed: false,
23  isPaneShown: false,
24  wantsPaneCheck: false,
25  closeIn: undefined as number | undefined,
26  ticks: 0,
27};
28
29export const isSettled = (status: string) =>
30  status === 'completed' || status === 'failed' || status === 'killed' || status === 'idle';
31
32const allSettled = () => [...agents.values()].every((agent) => isSettled(agent.status));
33
34function pushLine(agent: TrackedAgent, line: AgentLogLine) {
35  agent.lines.push(line);
36  if (agent.lines.length > LOG_LINES) agent.lines.splice(0, agent.lines.length - LOG_LINES);
37}
38
39export function snapshot(): AgentBoard {
40  return {
41    agents: [...agents.values()].map((agent) => ({ ...agent, lines: agent.lines.map((line) => ({ ...line })) })),
42    at: Date.now(),
43    isBatchClosed: flags.isBatchClosed,
44    isPaneShown: flags.isPaneShown,
45  };
46}
47
48export function hydrate(saved: AgentBoard) {
49  if (agents.size > 0) return;
50
51  for (const agent of saved.agents) agents.set(agent.id, { ...agent, lines: agent.lines.map((line) => ({ ...line })) });
52  flags.isBatchClosed = saved.isBatchClosed ?? false;
53  flags.isPaneShown = saved.isPaneShown ?? false;
54  flags.isDirty = saved.agents.length > 0;
55  flags.wantsPaneCheck = saved.agents.length > 0;
56}
57
58/**
59 * `claude-sonnet-5-20261001` → `sonnet-5`; Bedrock (`us.anthropic.…-v1:0`),
60 * Vertex (`…@20261001`) and gateway (`org/…`) spellings alike.
61 */
62export const shortModel = (model: string) =>
63  model
64    .slice(model.lastIndexOf('/') + 1)
65    .replace(/^([a-z]+\.)?anthropic\./, '')
66    .replace(/^claude-/, '')
67    .replace(/-v\d+:\d+$/, '')
68    .replace(/[-@]\d{8}$/, '');
69
70export const agentHeading = (agent: Pick<TrackedAgent, 'type' | 'model' | 'effort'>) =>
71  [agent.type, agent.model, agent.effort].filter((part) => part !== undefined && part !== '').join(' · ');
72
73export function requestPaneCheck() {
74  flags.wantsPaneCheck = true;
75}
76
77export function setPaneShown(isShown: boolean) {
78  if (flags.isPaneShown === isShown) return;
79
80  flags.isPaneShown = isShown;
81  flags.isDirty = true;
82}
83
84export function reset() {
85  agents.clear();
86  streams.clear();
87  Object.assign(flags, {
88    isDirty: false,
89    wantsOpen: false,
90    isBatchClosed: false,
91    isPaneShown: false,
92    wantsPaneCheck: false,
93    closeIn: undefined,
94    ticks: 0,
95  });
96}
97
98/** Starts tracking an agent; true when it is new, which asks for the pane. */
99export function track(id: string, type?: string, description?: string) {
100  const known = agents.get(id);
101  if (known !== undefined) {
102    if (type !== undefined && type !== '' && known.type === 'agent') known.type = type;
103    if (description !== undefined && description !== '' && known.description === '') known.description = description;
104    return false;
105  }
106
107  if (flags.isBatchClosed) {
108    for (const [key, agent] of agents) if (isSettled(agent.status)) agents.delete(key);
109    flags.isBatchClosed = false;
110  }
111
112  agents.set(id, {
113    id,
114    type: type !== undefined && type !== '' ? type : 'agent',
115    description: description ?? '',
116    status: 'running',
117    startedAt: Date.now(),
118    tools: 0,
119    lines: [],
120  });
121  for (const [key, agent] of agents) {
122    if (agents.size <= MAX_AGENTS) break;
123    if (isSettled(agent.status)) agents.delete(key);
124  }
125
126  flags.closeIn = undefined;
127  flags.wantsOpen = true;
128  flags.isDirty = true;
129  return true;
130}
131
132function revive(agent: TrackedAgent) {
133  if (!isSettled(agent.status)) return;
134
135  agent.status = 'running';
136  agent.endedAt = undefined;
137  flags.closeIn = undefined;
138}
139
140export function noteSpawn(agentId: string, spawn: { type: string; description: string; model: string; detail: string }) {
141  track(agentId, spawn.type, spawn.description);
142  const agent = agents.get(agentId);
143  if (agent === undefined) return;
144
145  if (agent.model === undefined && spawn.model !== '') agent.model = shortModel(spawn.model);
146  if (agent.lines.length === 0) pushLine(agent, { kind: 'note', text: spawn.detail });
147}
148
149/** A model request of the agent's loop: the model and effort it is about to be sent with. */
150export function stepStarted(agentId: string, model: string, effort: string | number | undefined) {
151  track(agentId);
152  const agent = agents.get(agentId);
153  if (agent === undefined) return;
154
155  agent.model = shortModel(model);
156  agent.effort = effort === undefined ? undefined : String(effort);
157  flags.isDirty = true;
158}
159
160export function toolStarted(agentId: string, input: Readonly<Record<string, unknown>>, tool: string, toolUseId: string) {
161  track(agentId);
162  const agent = agents.get(agentId);
163  if (agent === undefined) return;
164
165  revive(agent);
166  agent.tools += 1;
167  const summary = summarizeToolInput(input);
168  pushLine(agent, { id: toolUseId, kind: 'tool', state: 'run', text: summary === '' ? tool : `${tool}: ${summary}` });
169  flags.isDirty = true;
170}
171
172export function toolEnded(agentId: string, toolUseId: string, result: ToolCallResult) {
173  const line = agents.get(agentId)?.lines.find((one) => one.id === toolUseId);
174  if (line === undefined) return;
175
176  line.state = result.deny !== undefined || result.isError === true ? 'err' : 'ok';
177  if (result.deny !== undefined) line.text = `${line.text}  (denied)`;
178  flags.isDirty = true;
179}
180
181export function stepChunk(agentId: string, chunk: TurnStepChunk) {
182  const agent = agents.get(agentId);
183  if (agent === undefined) return;
184
185  const stream = streams.get(agentId) ?? { head: '', tail: '' };
186  if (chunk.kind === 'thinking' && stream.head === '') {
187    agent.live = 'thinking…';
188    flags.isDirty = true;
189    return;
190  }
191  if (chunk.kind !== 'text') return;
192
193  const next = { head: `${stream.head}${chunk.text}`.slice(0, STREAM_KEEP), tail: `${stream.tail}${chunk.text}`.slice(-STREAM_KEEP) };
194  streams.set(agentId, next);
195  agent.live = tail(next.tail, 160);
196  flags.isDirty = true;
197}
198
199export function stepEnded(agentId: string) {
200  const agent = agents.get(agentId);
201  const text = streams.get(agentId)?.head ?? '';
202  streams.delete(agentId);
203  if (agent === undefined) return;
204
205  agent.live = undefined;
206  if (text.trim() !== '') pushLine(agent, { kind: 'text', text: oneLine(text, 200) });
207  flags.isDirty = true;
208}
209
210export function agentDone(agentId: string, e: TurnCompleteInput) {
211  track(agentId);
212  const agent = agents.get(agentId);
213  if (agent === undefined) return;
214
215  agent.status = e.isAborted ? 'killed' : e.reason === 'answer' ? 'completed' : 'failed';
216  agent.endedAt = Date.now();
217  agent.live = undefined;
218  pushLine(agent, { kind: 'note', text: `${agent.status} after ${Math.round(e.durationMs / 100) / 10}s` });
219  flags.isDirty = true;
220}
221
222/** Refreshes type, description and status from `$.agent.list()`, and adopts running agents no hook saw start. */
223export function applyListed(listed: readonly AgentInfo[]) {
224  for (const info of listed) {
225    const agent = agents.get(info.id);
226    if (agent === undefined) {
227      if (info.status === 'running' || info.status === 'pending') track(info.id, info.type, info.description);
228      continue;
229    }
230
231    const before = `${agent.type}|${agent.description}|${agent.status}`;
232    if (agent.type === 'agent' && info.type !== '') agent.type = info.type;
233    if (agent.description === '' && info.description !== '') agent.description = info.description;
234    if (agent.status !== info.status && (isSettled(info.status) || !isSettled(agent.status))) {
235      agent.status = info.status;
236      if (isSettled(info.status)) agent.endedAt ??= Date.now();
237    }
238    if (`${agent.type}|${agent.description}|${agent.status}` !== before) flags.isDirty = true;
239  }
240}
241
242/** What one tick of the ticker owes: which effects to run, and whether it may stop. */
243export function planTick() {
244  flags.ticks += 1;
245
246  const shouldOpen = flags.wantsOpen;
247  flags.wantsOpen = false;
248
249  const shouldSync = !allSettled() && flags.ticks % SYNC_TICKS === 0;
250
251  const shouldCheckPanes =
252    flags.wantsPaneCheck || (agents.size > 0 && !flags.isBatchClosed && flags.ticks % SYNC_TICKS === 0);
253  flags.wantsPaneCheck = false;
254
255  let shouldClose = false;
256  if (agents.size === 0 || !allSettled() || flags.isBatchClosed) flags.closeIn = undefined;
257  else if (flags.closeIn === undefined) flags.closeIn = CLOSE_TICKS;
258  else if (--flags.closeIn <= 0) {
259    flags.closeIn = undefined;
260    flags.isBatchClosed = true;
261    flags.isPaneShown = false;
262    flags.isDirty = true;
263    shouldClose = true;
264  }
265
266  const shouldFlush = flags.isDirty;
267  flags.isDirty = false;
268
269  return { shouldOpen, shouldSync, shouldCheckPanes, shouldFlush, shouldClose };
270}
271
272export const isIdle = () =>
273  !flags.isDirty &&
274  !flags.wantsOpen &&
275  !flags.wantsPaneCheck &&
276  flags.closeIn === undefined &&
277  allSettled() &&
278  (agents.size === 0 || flags.isBatchClosed);
279
hooks/tool-input-summary.ts 44 lines
1const PRIMARY_KEYS = [
2  'command',
3  'file_path',
4  'notebook_path',
5  'pattern',
6  'path',
7  'url',
8  'query',
9  'skill',
10  'description',
11  'subject',
12  'prompt',
13];
14
15const ENVELOPE_KEYS = ['tool', 'tool_use_id', 'agentId'];
16
17export function oneLine(text: string, max: number) {
18  const flat = text.replace(/\s+/g, ' ').trim();
19
20  return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
21}
22
23export function tail(text: string, max: number) {
24  const flat = text.replace(/\s+/g, ' ').trim();
25
26  return flat.length > max ? `…${flat.slice(flat.length - max + 1)}` : flat;
27}
28
29/** One short line for a tool call's input: the Bash command, the file path, the pattern, ... */
30export function summarizeToolInput(input: Readonly<Record<string, unknown>>, max = 140) {
31  for (const key of PRIMARY_KEYS) {
32    const value = input[key];
33    if (typeof value !== 'string' || value.trim() === '') continue;
34
35    const where = input.path;
36    if (key === 'pattern' && typeof where === 'string' && where !== '') return oneLine(`${value} in ${where}`, max);
37
38    return oneLine(value, max);
39  }
40
41  const rest = Object.fromEntries(Object.entries(input).filter(([key]) => !ENVELOPE_KEYS.includes(key)));
42  return Object.keys(rest).length === 0 ? '' : oneLine(JSON.stringify(rest), max);
43}
44
types/index.d.ts 44 lines
1export type AgentLogLine = {
2  /** The tool_use_id of a tool line, so its result can mark it. */
3  id?: string;
4  kind: 'tool' | 'text' | 'note';
5  text: string;
6  state?: 'run' | 'ok' | 'err';
7};
8
9export type TrackedAgent = {
10  id: string;
11  type: string;
12  description: string;
13  status: string;
14  startedAt: number;
15  endedAt?: number;
16  tools: number;
17  /** Short model id, from the spawn's resolved model, then each model request. */
18  model?: string;
19  /** Effort of the agent's latest model request; absent for a model without effort. */
20  effort?: string;
21  /** Tail of the text the agent is streaming right now. */
22  live?: string;
23  lines: AgentLogLine[];
24};
25
26export type AgentBoard = {
27  agents: TrackedAgent[];
28  /** When the board was last written, for elapsed times of running agents. */
29  at: number;
30  /** The batch's pane was closed by the 15s timer; its agents are no longer "recent". */
31  isBatchClosed?: boolean;
32  /** The agents pane is placed and is the pane shown; the band stays out of its way. */
33  isPaneShown?: boolean;
34};
35
36declare module 'claude-code' {
37  interface PluginState {
38    subagents: {
39      board: AgentBoard;
40      tab: string;
41    };
42  }
43}
44