tuiboard 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -59,6 +59,10 @@ archive_column: Archive
59
59
  # PowerShell (pwsh, else powershell); elsewhere your $SHELL. Force one with:
60
60
  # resume_shell: auto # bash | zsh | fish | nu | pwsh | powershell | cmd
61
61
  #
62
+ # Agent status symbols, the same as herdr's: × waiting for you, ◐ working,
63
+ # ✓ done, ○ idle, · closed (△ = turn stopped updating). Or colored dots:
64
+ # status_indicators: symbols # symbols (default) | dots
65
+ #
62
66
  # Enter not doing what you expect? From the directory you run tuiboard in (in a
63
67
  # checkout of the repo): `bun run agents:open <session-id-prefix> --dry-run`
64
68
  # prints the detected terminal, shell and exact command; the `o` detail of a
package/CHANGELOG.md CHANGED
@@ -7,6 +7,61 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.13.0] - 2026-09-17
11
+
12
+ tuiboard and [herdr](https://herdr.dev) now work as one: the Agents zone shows
13
+ herdr's live state for every session open there, speaks herdr's status
14
+ symbols, and `H` jumps to a session in herdr or resumes it in the right
15
+ workspace. Plus fixes found using it all day on Windows.
16
+
17
+ ### Added
18
+ - **Live agent state from herdr** (#37). When herdr is running, tuiboard polls
19
+ `herdr api snapshot` and links every pane running Claude Code, Codex,
20
+ OpenCode or Pi to its session — by the session id/file herdr reports (with
21
+ herdr's agent integrations installed), else by agent and directory. Those
22
+ sessions take herdr's state, including two new ones: **waiting for you** and
23
+ **done**; idle Codex/OpenCode/Pi sessions become visible, and long Claude
24
+ turns no longer show as stale (#22). Zoomed cards and the `o` detail show the
25
+ herdr workspace and tab.
26
+ - **herdr's status symbols** — `×` waiting, `◐` working, `✓` done, `○` idle,
27
+ `·` closed (tuiboard's own `△` for stale) — are the default for everyone, so
28
+ both tools read the same; `status_indicators: dots` keeps colored dots.
29
+ - **`H` opens sessions in herdr** (#38): focuses the pane a session is open
30
+ in, or resumes it in herdr — a new tab named after the session, in the
31
+ workspace that already holds that directory (else one named like the
32
+ folder, else the focused one). **Enter** on a session already open in herdr
33
+ focuses it instead of starting a second copy.
34
+ - **Omarchy bar widget in the repo** (#27): `omarchy-plugin/` — an
35
+ overdue/today badge and a Today/Tomorrow panel (done, undone, defer, open
36
+ tuiboard) built on `tuiboard summary` / `tuiboard task`, installed with
37
+ `omarchy-plugin/install.sh`. Repo only, not part of the npm package.
38
+
39
+ ### Changed
40
+ - **Agent sessions are sorted by most recent activity** (#43), newest first,
41
+ instead of by status first — a session used 40 seconds ago no longer sits
42
+ below ones idle in herdr for hours. Status only breaks ties; archived
43
+ sessions stay at the bottom.
44
+ - Launch steps for Enter and `H` run asynchronously: a slow start (cold
45
+ PowerShell, an agent booting in herdr) no longer freezes the UI.
46
+
47
+ ### Fixed
48
+ - **Quitting gives the terminal back** (#47). `q`, Ctrl+C and termination
49
+ signals exited without tearing down the renderer, leaving mouse tracking
50
+ and the alternate screen on — moving the mouse at the shell then printed
51
+ `51;7;45M…`. tuiboard now restores the terminal before exiting, and the
52
+ `tuiboard` launcher resets mouse/paste/focus reporting and the cursor after
53
+ the app ends, whatever way it ended.
54
+ - **`H` on Windows with Codex, OpenCode and Pi** (#41). npm-installed CLIs on
55
+ Windows are an extensionless sh shim next to `.cmd`/`.ps1`, which herdr
56
+ can't launch directly ("not a valid Win32 application"); on Windows the
57
+ resume command is typed into the new pane's shell instead.
58
+ - **Responsive layout follows the renderer's terminal size** (#45), the size
59
+ the frame is drawn at, instead of `process.stdout.columns`.
60
+
61
+ ### Known issues
62
+ - On Windows, herdr may not detect Pi sessions as agents (#49): they open
63
+ fine, but without herdr's live state.
64
+
10
65
  ## [0.12.0] - 2026-09-17
11
66
 
12
67
  The Agents zone stops being Claude-Code-only: it now lists **Codex, OpenCode
@@ -419,6 +474,7 @@ First public release on npm. This entry captures the full feature set at launch.
419
474
 
420
475
  Built with [OpenTUI](https://opentui.com) + SolidJS on Bun.
421
476
 
477
+ [0.13.0]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.13.0
422
478
  [0.12.0]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.12.0
423
479
  [0.11.0]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.11.0
424
480
  [0.10.0]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.10.0
package/README.md CHANGED
@@ -85,6 +85,20 @@ record of running processes, so their sessions show as busy while a turn is in
85
85
  progress (stale if a turn stops updating for 30 minutes), never as
86
86
  idle-but-open.
87
87
 
88
+ Each session's state uses the same symbols as [herdr](https://herdr.dev):
89
+ `×` waiting for you, `◐` working, `✓` done, `○` idle, `·` closed, plus
90
+ tuiboard's own `△` for a turn that stopped updating (`status_indicators: dots`
91
+ switches to colored dots). **With herdr running**, tuiboard reads its live
92
+ state for every session open in a herdr pane — so "waiting for you", "done"
93
+ and "idle" show up for every agent, and a long Claude turn no longer turns
94
+ stale — and the zoomed cards say where each one lives (`herdr blits · tab 3`).
95
+ herdr reports the exact session when its agent integration is installed
96
+ (`herdr integration install claude|codex|opencode|pi`); otherwise tuiboard
97
+ matches by agent and directory. `H` jumps to a session in herdr — or resumes
98
+ it there, in a new tab of the workspace that holds its project — and Enter on
99
+ a session that's already open in herdr takes you to it instead of starting a
100
+ second copy.
101
+
88
102
  Every session carries a colored two-letter harness badge — `cc` Claude Code,
89
103
  `cx` Codex, `oc` OpenCode, `pi` Pi — plus the model it ran on. The dashboard strip keeps
90
104
  one line per session (on a narrow row the model gives way first, then the
@@ -448,9 +462,10 @@ session (until the next terminal resize).
448
462
  | Key | Action |
449
463
  |---|---|
450
464
  | `j` / `k` | Move the cursor down / up the session list |
451
- | `Enter` | Open (resume) the selected session in a new tab/window of your terminal — tmux, herdr, WezTerm, Windows Terminal, Ghostty, or the OS default; falls back to copying the command (`resume_terminal` to force one) |
465
+ | `Enter` | Go to the session if it's open in herdr; otherwise open (resume) it in a new tab/window of your terminal — tmux, herdr, WezTerm, Windows Terminal, Ghostty, or the OS default; falls back to copying the command (`resume_terminal` to force one) |
452
466
  | `c` | Copy a one-paste `cd … && <resume>` command (e.g. `claude --resume <id>`) for the selected session — drop it into any tab/pane to land in the right dir and resume (no WezTerm needed; format is `copy_resume_command`) |
453
467
  | `o` | Session detail (harness, model, cwd, branch, last prompts, resume command, result of the last `Enter`) |
468
+ | `H` | herdr: go to the session's pane, or resume it in a new tab of its project's workspace (herdr must be running) |
454
469
  | `f` | Filter by harness: all → `cc` Claude Code → `cx` Codex → `oc` OpenCode → `pi` Pi (shown in the panel title; outside the Agents zone `f` is the board filter) |
455
470
 
456
471
  ### Task actions (work in board, planner, AND timeline zones)
@@ -554,10 +569,48 @@ An ambiguous match is refused rather than guessed at.
554
569
  Exit 3 is the mtime watermark: a write is refused rather than allowed to
555
570
  clobber an edit made in the TUI or another editor in the meantime.
556
571
 
572
+ ## Omarchy bar widget
573
+
574
+ On [Omarchy](https://omarchy.org), `omarchy-plugin/` ships a bar widget built
575
+ on `tuiboard summary` and `tuiboard task`: an overdue/today badge in the bar,
576
+ and a panel with the Today/Tomorrow planner where a task can be marked
577
+ done/undone or deferred to tomorrow without leaving the bar. Right-click the
578
+ badge to force a refresh, middle-click to open tuiboard itself.
579
+
580
+ **Requires** `tuiboard` on `PATH` (see [Install](#install) above) and Omarchy
581
+ with its shell plugin support.
582
+
583
+ **Install** (from a checkout of this repo):
584
+
585
+ ```bash
586
+ git clone https://github.com/NazzarenoGiannelli/tuiboard.git
587
+ cd tuiboard
588
+ ./omarchy-plugin/install.sh
589
+ ```
590
+
591
+ This symlinks `omarchy-plugin/` into `~/.config/omarchy/plugins/nazz.tuiboard`
592
+ and enables it in the bar's right section. `omarchy plugin add <git-url>`
593
+ isn't used here — it clones a git repo and expects `manifest.json` at its
594
+ root, which doesn't fit a widget living inside this monorepo, and there's no
595
+ separate Omarchy plugin marketplace to publish to at the time of writing. The
596
+ symlink means `omarchy plugin update` doesn't apply; update by pulling this
597
+ repo instead (`git pull`, then `omarchy-shell shell rescanPlugins` if the bar
598
+ doesn't pick it up on its own).
599
+
600
+ Refresh interval, the `tuiboard summary`/`tuiboard task` commands, the open
601
+ command, and the completion sound are all configurable from Omarchy's own
602
+ plugin settings (`manifest.json`'s schema) — no config file to hand-edit.
603
+
604
+ Uninstall: `omarchy plugin remove nazz.tuiboard`.
605
+
557
606
  ## Status
558
607
 
559
608
  See [CHANGELOG.md](CHANGELOG.md) for the full release history.
560
609
 
610
+ - **v0.13** — tuiboard + herdr: live agent state from herdr (waiting for you,
611
+ working, done, idle) with herdr's status symbols, `H` to jump to a session
612
+ in herdr or resume it in its project's workspace, sessions sorted by most
613
+ recent activity, and the terminal properly restored on quit.
561
614
  - **v0.12** — the Agents zone goes multi-agent: Codex, OpenCode and Pi sessions
562
615
  next to Claude Code, with a colored harness badge, the model, a harness
563
616
  filter (`f`) and two-line cards when zoomed. Enter reopens a session in the
package/bin/tuiboard.ts CHANGED
@@ -85,8 +85,17 @@ const child = spawn(
85
85
  env: { ...process.env, TUIBOARD_SPLASH_DONE: "1", TUIBOARD_READY_FLAG: readyFlag },
86
86
  },
87
87
  );
88
+ // Safety net: whatever way the app ended (crash, kill), give the shell a sane
89
+ // terminal back — mouse reporting off (else moving the mouse prints
90
+ // `51;7;45M…`), focus/paste reporting off, cursor visible. Harmless when the
91
+ // app already restored everything.
92
+ const RESET_TERMINAL =
93
+ "\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1015l" + // mouse
94
+ "\x1b[?1004l\x1b[?2004l" + // focus events, bracketed paste
95
+ "\x1b[?25h"; // cursor
88
96
  child.on("exit", (code, signal) => {
89
97
  stopSplash();
98
+ try { process.stdout.write(RESET_TERMINAL); } catch { /* ignore */ }
90
99
  process.exit(code ?? (signal ? 1 : 0));
91
100
  });
92
101
  child.on("error", (err) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tuiboard",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Terminal kanban for markdown task boards, with optional Today/Tomorrow planner, 24h agenda + calendar overlay, and a live view of your coding-agent sessions (Claude Code, Codex, OpenCode, Pi). Use only the panels you want.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,37 @@
1
+ /**
2
+ * The one way out of the TUI. Destroying the OpenTUI renderer is what gives
3
+ * the terminal back — mouse tracking off, main screen, cooked input, cursor —
4
+ * and a bare `process.exit` skips it (OpenTUI only cleans up on `beforeExit`
5
+ * and its own signal/Ctrl+C handling), which left shells printing mouse
6
+ * reports like `51;7;45M` after quitting.
7
+ */
8
+
9
+ import type { CliRenderer } from "@opentui/core";
10
+
11
+ let renderer: CliRenderer | undefined;
12
+ let quitting = false;
13
+
14
+ export function registerRenderer(r: CliRenderer): void {
15
+ renderer = r;
16
+ }
17
+
18
+ /** Restore the terminal, run `cleanup`, exit. Safe to call more than once. */
19
+ export async function quitApp(cleanup: () => Promise<void>, code = 0): Promise<never> {
20
+ if (!quitting) {
21
+ quitting = true;
22
+ try {
23
+ renderer?.destroy();
24
+ // A destroy requested mid-frame completes when that frame ends.
25
+ await new Promise((r) => setTimeout(r, 50));
26
+ } catch {
27
+ // Never let a teardown error keep the process alive.
28
+ }
29
+ try {
30
+ await cleanup();
31
+ } catch {
32
+ // Same.
33
+ }
34
+ process.exit(code);
35
+ }
36
+ return new Promise<never>(() => {});
37
+ }
package/src/app.tsx CHANGED
@@ -21,8 +21,11 @@ import { appendFileSync, mkdirSync } from "node:fs";
21
21
  import { homedir } from "node:os";
22
22
  import { join } from "node:path";
23
23
 
24
- import { createMemo } from "solid-js";
25
- import { render, useKeyboard } from "@opentui/solid";
24
+ import { createEffect, createMemo } from "solid-js";
25
+ import { createCliRenderer } from "@opentui/core";
26
+ import { render, useKeyboard, useTerminalDimensions } from "@opentui/solid";
27
+
28
+ import { quitApp, registerRenderer } from "~/app-exit";
26
29
 
27
30
  import { parseArgs, type ViewKind } from "~/cli/args";
28
31
  import { loadConfig } from "~/config/loader";
@@ -84,12 +87,8 @@ if (needsOnboarding) {
84
87
  store.openBoardNew(true);
85
88
  }
86
89
 
87
- process.on("SIGINT", () => {
88
- store.dispose().finally(() => process.exit(0));
89
- });
90
- process.on("SIGTERM", () => {
91
- store.dispose().finally(() => process.exit(0));
92
- });
90
+ process.on("SIGINT", () => void quitApp(() => store.dispose()));
91
+ process.on("SIGTERM", () => void quitApp(() => store.dispose()));
93
92
 
94
93
  // ─── Responsive layout ──────────────────────────────────────────────────────
95
94
  // Auto-hide optional zones when the terminal isn't wide enough to host them
@@ -102,8 +101,7 @@ process.on("SIGTERM", () => {
102
101
  // This only reports what FITS. The store combines it with each zone's enabled
103
102
  // flag and the user's desired visibility, so F1/F2/F3 toggles persist across
104
103
  // resizes and a disabled/hidden zone is never force-shown.
105
- function applyResponsiveLayout(): void {
106
- const width = process.stdout.columns ?? 200;
104
+ function applyResponsiveLayout(width: number): void {
107
105
  // Report which zones FIT at this width. The store ANDs this with each zone's
108
106
  // enabled flag and the user's desired visibility, so a disabled or
109
107
  // intentionally-hidden zone is never force-shown just because there's room.
@@ -122,8 +120,11 @@ function applyResponsiveLayout(): void {
122
120
  { narrow: width < 100 },
123
121
  );
124
122
  }
125
- applyResponsiveLayout();
126
- process.stdout.on("resize", applyResponsiveLayout);
123
+ // Initial guess before the renderer exists; from then on the layout follows
124
+ // OpenTUI's own dimensions (see App). `process.stdout.columns` can lag behind
125
+ // the real size — e.g. a Windows Terminal tab that starts at a provisional
126
+ // size — which left zones overlapping until a manual resize.
127
+ applyResponsiveLayout(process.stdout.columns ?? 200);
127
128
 
128
129
  // Land on the Today/Tomorrow panel by default — for a daily-planning tool the
129
130
  // first question is "what's on my plate today", and that panel answers it.
@@ -170,6 +171,13 @@ function App() {
170
171
 
171
172
  useKeyboard((key) => handleKey(store, key, plannerItems().length));
172
173
 
174
+ // Same width the frame is drawn at, updated on the renderer's resize events.
175
+ const dims = useTerminalDimensions();
176
+ createEffect(() => {
177
+ const { width } = dims();
178
+ if (width > 0) applyResponsiveLayout(width);
179
+ });
180
+
173
181
  return (
174
182
  <box
175
183
  style={{
@@ -226,4 +234,6 @@ if (process.env.TUIBOARD_READY_FLAG) {
226
234
  }
227
235
  }
228
236
 
229
- await render(() => <App />, { useMouse: true });
237
+ const renderer = await createCliRenderer({ useMouse: true });
238
+ registerRenderer(renderer);
239
+ await render(() => <App />, renderer);
@@ -59,6 +59,11 @@ export interface Config {
59
59
  * Windows, `$SHELL` elsewhere). Ignored when `resumeCommand` is set.
60
60
  */
61
61
  resumeShell: "auto" | Shell;
62
+ /**
63
+ * Agent status glyphs (config `status_indicators`): `symbols` (default —
64
+ * the same × ◐ ✓ ○ · herdr uses) or `dots` (● ○ ·).
65
+ */
66
+ statusIndicators: "dots" | "symbols";
62
67
  /**
63
68
  * Template for the shell command copied to the clipboard by `c` in the agents
64
69
  * zone — one paste that `cd`s into the session's directory and resumes it.
@@ -141,6 +146,7 @@ export const DEFAULT_CONFIG: Omit<Config, "root" | "loaded" | "boards"> = {
141
146
  archiveColumn: "Archive",
142
147
  resumeTerminal: "auto",
143
148
  resumeShell: "auto",
149
+ statusIndicators: "symbols",
144
150
  copyResumeCommand: DEFAULT_COPY_RESUME_COMMAND,
145
151
  zones: { planner: "on", agenda: "on", agents: "on" },
146
152
  };
@@ -216,6 +222,7 @@ interface RawConfig {
216
222
  resume_command: string[];
217
223
  resume_terminal: string;
218
224
  resume_shell: string;
225
+ status_indicators: string;
219
226
  copy_resume_command: string;
220
227
  calendars: {
221
228
  google?: {
@@ -367,6 +374,7 @@ function normalize(raw: Partial<RawConfig>, root: string, loaded: boolean): Conf
367
374
  resumeShell: (SHELLS as readonly string[]).includes(raw.resume_shell ?? "")
368
375
  ? (raw.resume_shell as Shell)
369
376
  : "auto",
377
+ statusIndicators: raw.status_indicators === "dots" ? "dots" : "symbols",
370
378
  copyResumeCommand:
371
379
  typeof raw.copy_resume_command === "string" &&
372
380
  raw.copy_resume_command.trim().length > 0
@@ -13,6 +13,7 @@
13
13
  * shares the same input contract.
14
14
  */
15
15
 
16
+ import { quitApp } from "~/app-exit";
16
17
  import { isHiddenColumn } from "~/config/loader";
17
18
  import {
18
19
  LAUNCHER_NAME,
@@ -22,7 +23,9 @@ import {
22
23
  runLaunchPlan,
23
24
  systemLaunchEnv,
24
25
  } from "~/input/open-session";
26
+ import { planHerdrFocus, planHerdrResume } from "~/input/herdr-open";
25
27
  import { HARNESS, type AgentSession } from "~/store/agents";
28
+ import { herdrBin, herdrPlace } from "~/store/herdr";
26
29
  import { googleTokenCanWrite } from "~/store/calendar";
27
30
  import { isTask } from "~/parser/markdown";
28
31
  import {
@@ -135,7 +138,7 @@ export function handleKey(
135
138
 
136
139
  // Quit
137
140
  if (key.name === "q" || (key.ctrl && key.name === "c")) {
138
- store.dispose().finally(() => process.exit(0));
141
+ void quitApp(() => store.dispose());
139
142
  return;
140
143
  }
141
144
 
@@ -498,10 +501,16 @@ function handleAgentsZone(store: TuiStore, key: KeyEvent): void {
498
501
  store.setCursor(0, Math.min(sessions.length - 1, ui.row + 1));
499
502
  } else if (key.name === "k" || key.name === "up") {
500
503
  store.setCursor(0, Math.max(0, ui.row - 1));
504
+ } else if (key.name === "H" || (key.name === "h" && key.shift)) {
505
+ // Jump to the session in herdr, or resume it there.
506
+ const target = sessions[ui.row];
507
+ if (target) void openInHerdr(store, target);
501
508
  } else if (key.name === "enter" || key.name === "return") {
502
- // Open (resume) the selected session in a new terminal tab/window.
509
+ // Open (resume) the selected session in a new terminal tab/window —
510
+ // or, when it's already open in herdr, go there instead of a second copy.
503
511
  const target = sessions[ui.row];
504
- if (target) void openSession(store, target);
512
+ if (target?.herdr) void openInHerdr(store, target);
513
+ else if (target) void openSession(store, target);
505
514
  } else if (key.name === "c") {
506
515
  // Copy a one-paste "cd + resume" command for the selected session, so you
507
516
  // can drop it into any tab/pane anywhere and land in the right directory
@@ -959,6 +968,46 @@ async function openSession(store: TuiStore, session: AgentSession): Promise<void
959
968
  }
960
969
  }
961
970
 
971
+ /**
972
+ * `H` in the Agents zone: focus the herdr pane the session is open in, or
973
+ * resume it in herdr — a new tab in the workspace that holds its project,
974
+ * started with `herdr agent start` (see herdr-open.ts).
975
+ */
976
+ async function openInHerdr(store: TuiStore, session: AgentSession): Promise<void> {
977
+ const bin = herdrBin();
978
+ const snap = store.agents.herdr();
979
+ const { sessionId } = session;
980
+ if (!bin || !snap) {
981
+ const text = bin ? "herdr isn't running" : "herdr isn't installed (https://herdr.dev)";
982
+ store.setLastLaunch(sessionId, false, text);
983
+ store.flashBanner("warn", text);
984
+ return;
985
+ }
986
+ let steps;
987
+ let done: string;
988
+ if (session.herdr) {
989
+ steps = planHerdrFocus(bin, session);
990
+ done = `↗ ${herdrPlace(session.herdr)}`;
991
+ } else {
992
+ const plan = planHerdrResume(bin, session, snap);
993
+ steps = plan.steps;
994
+ done =
995
+ plan.where.kind === "new"
996
+ ? `↗ Resumed in a new herdr workspace "${plan.where.label}"`
997
+ : `↗ Resumed in herdr ${plan.where.label}`;
998
+ store.flashBanner("info", `Starting ${HARNESS[session.provider].name} in herdr…`);
999
+ }
1000
+ try {
1001
+ await runLaunchPlan(steps);
1002
+ store.setLastLaunch(sessionId, true, `${done}\n${describePlan(steps)}`);
1003
+ store.flashBanner("info", `${done} (${sessionId.slice(0, 8)})`);
1004
+ } catch (e) {
1005
+ const text = `herdr: ${e instanceof Error ? e.message : String(e)}`;
1006
+ store.setLastLaunch(sessionId, false, text);
1007
+ store.flashBanner("error", text);
1008
+ }
1009
+ }
1010
+
962
1011
  /**
963
1012
  * Cross-platform clipboard copy. Picks the host's native cli tool:
964
1013
  * Windows → clip
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Open agent sessions inside herdr (the `H` key, and Enter for sessions
3
+ * already open there): focus the pane a session lives in, or resume it in a
4
+ * new tab of the workspace that holds its project, started through
5
+ * `herdr agent start` so herdr tracks it as an agent from the first second.
6
+ *
7
+ * Planning is pure; the steps run through `runLaunchPlan`.
8
+ */
9
+
10
+ import { basename } from "node:path";
11
+
12
+ import type { LaunchStep } from "~/input/open-session";
13
+ import type { AgentSession } from "~/store/agents";
14
+ import { HERDR_AGENT, samePath, type HerdrSnapshot } from "~/store/herdr";
15
+
16
+ /** Focus the herdr pane a session is open in. */
17
+ export function planHerdrFocus(bin: string, session: AgentSession): LaunchStep[] {
18
+ if (!session.herdr) throw new Error("session isn't open in herdr");
19
+ return [{ cmd: bin, args: ["agent", "focus", session.herdr.paneId] }];
20
+ }
21
+
22
+ export type WorkspaceChoice =
23
+ | { kind: "existing"; workspaceId: string; label: string; why: "cwd" | "name" | "focused" }
24
+ | { kind: "new"; label: string };
25
+
26
+ /**
27
+ * Where a session should open: the workspace already holding a pane in its
28
+ * directory, else one named like the directory, else the focused one — or a
29
+ * new workspace named after the directory when herdr has none.
30
+ */
31
+ export function chooseWorkspace(snap: HerdrSnapshot, cwd: string): WorkspaceChoice {
32
+ const label = (id: string) => snap.workspaces.get(id)?.label ?? id;
33
+ const byCwd = snap.panes.find((p) => p.workspaceId && samePath(p.cwd, cwd));
34
+ if (byCwd) return { kind: "existing", workspaceId: byCwd.workspaceId, label: label(byCwd.workspaceId), why: "cwd" };
35
+ const folder = basename(cwd.replaceAll("\\", "/")).toLowerCase();
36
+ for (const [id, w] of snap.workspaces) {
37
+ if (folder && w.label.toLowerCase() === folder) return { kind: "existing", workspaceId: id, label: w.label, why: "name" };
38
+ }
39
+ const focused = snap.focusedWorkspaceId ?? [...snap.workspaces.keys()][0];
40
+ if (focused) return { kind: "existing", workspaceId: focused, label: label(focused), why: "focused" };
41
+ return { kind: "new", label: basename(cwd.replaceAll("\\", "/")) || "agents" };
42
+ }
43
+
44
+ /** Pane id from a `tab create` / `workspace create` response. */
45
+ function rootPaneId(out: string): string | undefined {
46
+ try {
47
+ const r = JSON.parse(out)?.result;
48
+ return r?.root_pane?.pane_id ?? r?.pane?.pane_id ?? r?.tab?.root_pane?.pane_id;
49
+ } catch {
50
+ return undefined;
51
+ }
52
+ }
53
+
54
+ /** Tab titles: short, single line. */
55
+ function tabLabel(name: string): string {
56
+ const line = name.split("\n")[0]!.trim();
57
+ return line.length > 32 ? `${line.slice(0, 31).trimEnd()}…` : line || "agent";
58
+ }
59
+
60
+ /**
61
+ * herdr agent names: lowercase letter first, then `[a-z0-9_-]`, ≤ 32 chars.
62
+ * Title slug + short session id, so two sessions with the same title differ.
63
+ */
64
+ export function herdrAgentName(title: string, sessionId: string, fallback: string): string {
65
+ const id = sessionId.toLowerCase().replace(/[^a-z0-9]/g, "").slice(-6);
66
+ const slug = title
67
+ .split("\n")[0]!
68
+ .toLowerCase()
69
+ .normalize("NFKD")
70
+ .replace(/[^a-z0-9]+/g, "-")
71
+ .replace(/^[^a-z]+/, "")
72
+ .replace(/-+$/, "")
73
+ .slice(0, 32 - id.length - 1)
74
+ .replace(/-+$/, "");
75
+ return `${slug || fallback}-${id}`.slice(0, 32);
76
+ }
77
+
78
+ /**
79
+ * Resume a session in herdr: new tab (or workspace) in the session's
80
+ * directory, then start the agent there.
81
+ *
82
+ * POSIX: `herdr agent start --kind <agent> -- <resume args>`, so herdr tracks
83
+ * it from the first second. Windows: herdr launches the agent's executable
84
+ * directly, which fails for npm-installed CLIs (codex, opencode, pi are an
85
+ * extensionless sh shim next to `.cmd`/`.ps1` — "not a valid Win32
86
+ * application"), so the resume command is typed into the pane's shell
87
+ * instead (`herdr pane run`), which resolves it through PATHEXT; herdr still
88
+ * detects the agent from the running process.
89
+ */
90
+ export function planHerdrResume(
91
+ bin: string,
92
+ session: AgentSession,
93
+ snap: HerdrSnapshot,
94
+ platform: NodeJS.Platform = process.platform,
95
+ ): { steps: LaunchStep[]; where: WorkspaceChoice } {
96
+ const where = chooseWorkspace(snap, session.cwd);
97
+ const label = tabLabel(session.displayName);
98
+ const create: LaunchStep =
99
+ where.kind === "existing"
100
+ ? {
101
+ cmd: bin,
102
+ args: ["tab", "create", "--workspace", where.workspaceId, "--cwd", session.cwd, "--label", label, "--focus"],
103
+ captureId: rootPaneId,
104
+ undo: { cmd: bin, args: ["pane", "close", "{id}"] },
105
+ }
106
+ : {
107
+ cmd: bin,
108
+ args: ["workspace", "create", "--cwd", session.cwd, "--label", where.label, "--focus"],
109
+ captureId: rootPaneId,
110
+ undo: { cmd: bin, args: ["pane", "close", "{id}"] },
111
+ };
112
+ if (platform === "win32") {
113
+ return {
114
+ steps: [create, { cmd: bin, args: ["pane", "run", "{id}", session.resumeCommand] }],
115
+ where,
116
+ };
117
+ }
118
+ const [, ...agentArgs] = session.resumeArgv;
119
+ const start: LaunchStep = {
120
+ cmd: bin,
121
+ args: [
122
+ "agent", "start", herdrAgentName(session.displayName, session.sessionId, HERDR_AGENT[session.provider]),
123
+ "--kind", HERDR_AGENT[session.provider],
124
+ "--pane", "{id}",
125
+ "--timeout", "30000",
126
+ "--", ...agentArgs,
127
+ ],
128
+ // herdr waits for the agent to be ready (up to the --timeout above).
129
+ timeoutMs: 40_000,
130
+ };
131
+ return { steps: [create, start], where };
132
+ }
@@ -179,6 +179,8 @@ export interface LaunchStep {
179
179
  * replaced with it.
180
180
  */
181
181
  captureId?: (stdout: string) => string | undefined;
182
+ /** Best-effort rollback (with `{id}`) if a later step fails, e.g. close the tab this step opened. */
183
+ undo?: LaunchStep;
182
184
  }
183
185
 
184
186
  export interface LaunchTarget {
@@ -365,39 +367,86 @@ export function describePlan(steps: LaunchStep[]): string {
365
367
 
366
368
  /**
367
369
  * Run a plan. Throws with a readable message on the first failing step.
368
- * Short IPC steps run synchronously (their output feeds the next step);
369
- * GUI launches are detached so tuiboard doesn't wait on the new window.
370
+ * Short IPC steps are awaited (their output feeds the next step) without
371
+ * blocking the UI; GUI launches are detached so tuiboard doesn't wait on the
372
+ * new window.
370
373
  */
371
374
  export async function runLaunchPlan(steps: LaunchStep[]): Promise<void> {
372
- const { spawn, spawnSync } = await import("node:child_process");
375
+ const undos: LaunchStep[] = [];
373
376
  let id: string | undefined;
374
- for (const step of steps) {
375
- const args = step.args.map((a) => (id !== undefined ? a.replaceAll("{id}", id) : a));
376
- const exe = Bun.which(step.cmd) ?? step.cmd;
377
- if (step.detached) {
378
- await new Promise<void>((resolve, reject) => {
379
- const child = spawn(exe, args, { detached: true, stdio: "ignore", windowsHide: true });
380
- child.once("error", (e) => reject(new Error(`${step.cmd}: ${e.message}`)));
381
- child.once("spawn", () => {
382
- child.unref();
383
- resolve();
384
- });
385
- });
386
- continue;
387
- }
388
- const res = spawnSync(exe, args, {
389
- input: step.input,
390
- encoding: "utf8",
391
- windowsHide: true,
392
- timeout: step.timeoutMs ?? 10_000,
393
- });
394
- if (res.error) throw new Error(`${step.cmd}: ${res.error.message}`);
395
- if (res.status !== 0) {
396
- throw new Error(`${step.cmd}: ${(res.stderr || "").trim() || `exit ${res.status}`}`);
377
+ try {
378
+ for (const step of steps) {
379
+ id = await runStep(step, id);
380
+ if (step.undo) undos.unshift(step.undo);
397
381
  }
398
- if (step.captureId) {
399
- id = step.captureId(res.stdout ?? "");
400
- if (!id) throw new Error(`${step.cmd}: unexpected output`);
382
+ } catch (e) {
383
+ for (const undo of undos) {
384
+ await runStep(undo, id).catch(() => undefined);
401
385
  }
386
+ throw e;
387
+ }
388
+ }
389
+
390
+ /** CLI error text, unwrapping JSON errors like herdr's `{"error":{"message":…}}`. */
391
+ function errorText(out: string): string {
392
+ const text = out.trim();
393
+ try {
394
+ const msg = JSON.parse(text)?.error?.message;
395
+ if (typeof msg === "string") return msg;
396
+ } catch {
397
+ // not JSON
398
+ }
399
+ return text;
400
+ }
401
+
402
+ async function runStep(step: LaunchStep, id: string | undefined): Promise<string | undefined> {
403
+ const { spawn } = await import("node:child_process");
404
+ const args = step.args.map((a) => (id !== undefined ? a.replaceAll("{id}", id) : a));
405
+ const exe = Bun.which(step.cmd) ?? step.cmd;
406
+ if (step.detached) {
407
+ await new Promise<void>((resolve, reject) => {
408
+ const child = spawn(exe, args, { detached: true, stdio: "ignore", windowsHide: true });
409
+ child.once("error", (e) => reject(new Error(`${step.cmd}: ${e.message}`)));
410
+ child.once("spawn", () => {
411
+ child.unref();
412
+ resolve();
413
+ });
414
+ });
415
+ return id;
416
+ }
417
+ const { status, stdout, stderr } = await new Promise<{
418
+ status: number | null;
419
+ stdout: string;
420
+ stderr: string;
421
+ }>((resolve, reject) => {
422
+ const child = spawn(exe, args, { windowsHide: true });
423
+ let out = "";
424
+ let err = "";
425
+ child.stdout?.on("data", (d) => (out += d));
426
+ child.stderr?.on("data", (d) => (err += d));
427
+ const timeoutMs = step.timeoutMs ?? 10_000;
428
+ const timer = setTimeout(() => {
429
+ child.kill();
430
+ reject(new Error(`${step.cmd}: timed out after ${Math.round(timeoutMs / 1000)}s`));
431
+ }, timeoutMs);
432
+ child.once("error", (e) => {
433
+ clearTimeout(timer);
434
+ reject(new Error(`${step.cmd}: ${e.message}`));
435
+ });
436
+ child.once("close", (code) => {
437
+ clearTimeout(timer);
438
+ resolve({ status: code, stdout: out, stderr: err });
439
+ });
440
+ if (step.input !== undefined) child.stdin?.end(step.input);
441
+ else child.stdin?.end();
442
+ });
443
+ if (status !== 0) {
444
+ throw new Error(`${step.cmd}: ${errorText(stderr) || errorText(stdout) || `exit ${status}`}`);
445
+ }
446
+ if (step.captureId) {
447
+ const captured = step.captureId(stdout);
448
+ if (!captured) throw new Error(`${step.cmd}: unexpected output`);
449
+ return captured;
402
450
  }
451
+ return id;
403
452
  }
@@ -333,6 +333,7 @@ function buildSession(
333
333
  gitBranch: parsed.gitBranch,
334
334
  model: parsed.model,
335
335
  resumeCommand: `claude --resume ${jsonl.sessionId}`,
336
+ resumeArgv: ["claude", "--resume", jsonl.sessionId],
336
337
  };
337
338
  }
338
339
 
@@ -389,6 +389,7 @@ export function createCodexAdapter(codexHome = defaultCodexHome()): AgentAdapter
389
389
  gitBranch: parsed.gitBranch ?? row?.git_branch ?? undefined,
390
390
  model: parsed.model ?? row?.model ?? undefined,
391
391
  resumeCommand: `codex resume ${f.threadId}`,
392
+ resumeArgv: ["codex", "resume", f.threadId],
392
393
  });
393
394
  }
394
395
  // Drop cache entries for files that disappeared (archived / deleted).
@@ -178,6 +178,7 @@ export function readOpenCodeSessions(dbPath: string, now: number): AgentSession[
178
178
  // Assistant messages carry `modelID`; user messages `model.modelID`.
179
179
  model: lastData?.modelID ?? lastData?.model?.modelID,
180
180
  resumeCommand: `opencode --session ${s.id}`,
181
+ resumeArgv: ["opencode", "--session", s.id],
181
182
  } satisfies AgentSession;
182
183
  });
183
184
  } finally {
@@ -238,6 +238,7 @@ export function createPiAdapter(sessionsDir = defaultPiSessionsDir()): AgentAdap
238
238
  lastAssistant: p.lastAssistant,
239
239
  model: p.model,
240
240
  resumeCommand: `pi --session ${sessionId}`,
241
+ resumeArgv: ["pi", "--session", sessionId],
241
242
  };
242
243
  });
243
244
  const live = new Set(files.map((f) => f.path));
@@ -13,6 +13,13 @@ import { resolve, sep } from "node:path";
13
13
  import chokidar from "chokidar";
14
14
  import { createSignal } from "solid-js";
15
15
 
16
+ import {
17
+ linkHerdrSessions,
18
+ type HerdrLink,
19
+ type HerdrSnapshot,
20
+ type HerdrSource,
21
+ } from "~/store/herdr";
22
+
16
23
  /** Threshold: session untouched longer than this is "archived" (won't show in compact list). */
17
24
  export const DORMANT_AFTER_MS = 7 * 86_400 * 1000;
18
25
 
@@ -30,7 +37,11 @@ export const HARNESS: Record<AgentProvider, { code: string; name: string }> = {
30
37
  export type AgentsFilter = "all" | AgentProvider;
31
38
 
32
39
  export type AgentStatus =
40
+ /** Waiting for you (permission/question) — known only via herdr. */
41
+ | "live-blocked"
33
42
  | "live-busy"
43
+ /** Finished its turn, not looked at yet — known only via herdr. */
44
+ | "live-done"
34
45
  | "live-idle"
35
46
  /** Looks busy, but stopped updating — the process likely crashed. */
36
47
  | "stale"
@@ -58,6 +69,10 @@ export interface AgentSession {
58
69
  model?: string;
59
70
  /** Shell command that resumes this session when run from `cwd`. */
60
71
  resumeCommand: string;
72
+ /** Same, as argv (`[binary, ...args]`) — for launchers that start the agent directly. */
73
+ resumeArgv: string[];
74
+ /** Set when the session is open in a herdr pane. */
75
+ herdr?: HerdrLink;
61
76
  }
62
77
 
63
78
  /** One agent CLI's session source. */
@@ -109,18 +124,32 @@ export function formatAge(ts: number, now: number): string {
109
124
  }
110
125
 
111
126
  const STATUS_RANK: Record<AgentStatus, number> = {
112
- "live-busy": 0,
113
- "live-idle": 1,
114
- "stale": 2,
115
- "dormant": 3,
116
- "archived": 4,
127
+ "live-blocked": 0,
128
+ "live-busy": 1,
129
+ "live-done": 2,
130
+ "live-idle": 3,
131
+ "stale": 4,
132
+ "dormant": 5,
133
+ "archived": 6,
117
134
  };
118
135
 
136
+ /** Is the agent process running (as far as we can tell)? */
137
+ export function isLive(status: AgentStatus): boolean {
138
+ return status.startsWith("live-");
139
+ }
140
+
141
+ /**
142
+ * Most recently active first; status only breaks ties. Archived sessions
143
+ * always go last — the dashboard strip hides them and relies on that to keep
144
+ * cursor indices aligned with the full list.
145
+ */
119
146
  export function sortSessions(arr: AgentSession[]): AgentSession[] {
120
147
  return arr.slice().sort((a, b) => {
121
- const r = STATUS_RANK[a.status] - STATUS_RANK[b.status];
122
- if (r !== 0) return r;
123
- return b.lastActivityMs - a.lastActivityMs;
148
+ const archived = Number(a.status === "archived") - Number(b.status === "archived");
149
+ if (archived !== 0) return archived;
150
+ const recency = b.lastActivityMs - a.lastActivityMs;
151
+ if (recency !== 0) return recency;
152
+ return STATUS_RANK[a.status] - STATUS_RANK[b.status];
124
153
  });
125
154
  }
126
155
 
@@ -128,6 +157,8 @@ export function sortSessions(arr: AgentSession[]): AgentSession[] {
128
157
 
129
158
  export interface AgentsStore {
130
159
  sessions: () => AgentSession[];
160
+ /** Latest herdr snapshot (undefined without herdr). */
161
+ herdr: () => HerdrSnapshot | undefined;
131
162
  refresh: () => void;
132
163
  dispose: () => Promise<void>;
133
164
  }
@@ -148,7 +179,10 @@ const MISSING_PATH_POLL_MS = 5000;
148
179
  */
149
180
  export function createAgentsStore(
150
181
  adapters: AgentAdapter[],
151
- { missingPathPollMs = MISSING_PATH_POLL_MS } = {},
182
+ {
183
+ missingPathPollMs = MISSING_PATH_POLL_MS,
184
+ herdr,
185
+ }: { missingPathPollMs?: number; herdr?: HerdrSource } = {},
152
186
  ): AgentsStore {
153
187
  const [sessions, setSessions] = createSignal<AgentSession[]>([]);
154
188
  const byAdapter = new Map<AgentAdapter, AgentSession[]>();
@@ -162,8 +196,10 @@ export function createAgentsStore(
162
196
  }
163
197
 
164
198
  function publish(): void {
165
- setSessions(sortSessions([...byAdapter.values()].flat()));
199
+ const all = [...byAdapter.values()].flat();
200
+ setSessions(sortSessions(linkHerdrSessions(all, herdr?.snapshot())));
166
201
  }
202
+ herdr?.onChange(publish);
167
203
 
168
204
  function refresh(): void {
169
205
  const now = Date.now();
@@ -231,8 +267,9 @@ export function createAgentsStore(
231
267
  async function dispose() {
232
268
  if (debounceTimer) clearTimeout(debounceTimer);
233
269
  clearInterval(missingPoll);
270
+ herdr?.dispose();
234
271
  await watcher.close();
235
272
  }
236
273
 
237
- return { sessions, refresh, dispose };
274
+ return { sessions, herdr: () => herdr?.snapshot(), refresh, dispose };
238
275
  }
@@ -0,0 +1,272 @@
1
+ /**
2
+ * herdr (terminal workspace manager for coding agents) link.
3
+ *
4
+ * `herdr api snapshot` lists every pane with the agent running in it, that
5
+ * agent's state and — when herdr's integration for that agent is installed —
6
+ * the agent session it's on (`{kind: "id"}` for Claude/OpenCode, `{kind:
7
+ * "path"}` for Pi). tuiboard polls it and links panes to its own sessions, so
8
+ * a session open in herdr shows herdr's live state (including "idle" and
9
+ * "waiting for you", which the on-disk formats can't tell) and where it lives.
10
+ *
11
+ * Optional by design: no `herdr` on PATH, no server, or an unexpected answer
12
+ * all just mean "no links".
13
+ */
14
+
15
+ import type { AgentProvider, AgentSession, AgentStatus } from "~/store/agents";
16
+
17
+ export type HerdrAgentStatus = "idle" | "working" | "blocked" | "done" | "unknown";
18
+
19
+ export interface HerdrPane {
20
+ paneId: string;
21
+ tabId: string;
22
+ workspaceId: string;
23
+ agent?: string;
24
+ status: HerdrAgentStatus;
25
+ cwd: string;
26
+ session?: { kind: "id" | "path"; value: string };
27
+ }
28
+
29
+ export interface HerdrSnapshot {
30
+ panes: HerdrPane[];
31
+ focusedWorkspaceId?: string;
32
+ tabs: Map<string, { label: string; number: number }>;
33
+ workspaces: Map<string, { label: string; number: number }>;
34
+ }
35
+
36
+ /** The `herdr` binary to drive: the one herdr itself advertises, else PATH. */
37
+ export function herdrBin(env: Record<string, string | undefined> = process.env): string | undefined {
38
+ return env.HERDR_BIN_PATH || Bun.which("herdr") || undefined;
39
+ }
40
+
41
+ /** Where a session is open in herdr. */
42
+ export interface HerdrLink {
43
+ paneId: string;
44
+ tabId: string;
45
+ workspaceId: string;
46
+ workspaceLabel: string;
47
+ tabLabel: string;
48
+ tabNumber: number;
49
+ status: HerdrAgentStatus;
50
+ /** How the pane was matched; `cwd` = no session identity from herdr. */
51
+ matchedBy: "id" | "path" | "cwd";
52
+ /** herdr's name for the agent (for `herdr integration install <name>`). */
53
+ agent: string;
54
+ }
55
+
56
+ /** `herdr blits · tab 3` */
57
+ export function herdrPlace(l: HerdrLink): string {
58
+ return `herdr ${l.workspaceLabel || l.workspaceId} · tab ${l.tabNumber || l.tabId}`;
59
+ }
60
+
61
+ /** herdr agent name ↔ tuiboard provider. */
62
+ export const HERDR_AGENT: Record<AgentProvider, string> = {
63
+ "claude-code": "claude",
64
+ codex: "codex",
65
+ opencode: "opencode",
66
+ pi: "pi",
67
+ };
68
+ const PROVIDER_OF: Record<string, AgentProvider> = Object.fromEntries(
69
+ Object.entries(HERDR_AGENT).map(([p, a]) => [a, p as AgentProvider]),
70
+ );
71
+
72
+ const STATUSES = new Set<HerdrAgentStatus>(["idle", "working", "blocked", "done", "unknown"]);
73
+
74
+ /** Parse `herdr api snapshot` output. Returns undefined if it isn't one. */
75
+ export function parseHerdrSnapshot(raw: string): HerdrSnapshot | undefined {
76
+ let data: any;
77
+ try {
78
+ data = JSON.parse(raw);
79
+ } catch {
80
+ return undefined;
81
+ }
82
+ const snap = data?.result?.snapshot ?? data?.snapshot;
83
+ if (!snap || !Array.isArray(snap.panes)) return undefined;
84
+ const panes: HerdrPane[] = [];
85
+ for (const p of snap.panes) {
86
+ if (typeof p?.pane_id !== "string") continue;
87
+ const s = p.agent_session;
88
+ panes.push({
89
+ paneId: p.pane_id,
90
+ tabId: String(p.tab_id ?? ""),
91
+ workspaceId: String(p.workspace_id ?? ""),
92
+ agent: typeof p.agent === "string" ? p.agent : undefined,
93
+ status: STATUSES.has(p.agent_status) ? p.agent_status : "unknown",
94
+ cwd: String(p.cwd ?? ""),
95
+ session:
96
+ s && (s.kind === "id" || s.kind === "path") && typeof s.value === "string"
97
+ ? { kind: s.kind, value: s.value }
98
+ : undefined,
99
+ });
100
+ }
101
+ const byId = (list: unknown, key: string) =>
102
+ new Map<string, { label: string; number: number }>(
103
+ (Array.isArray(list) ? list : [])
104
+ .filter((x: any) => typeof x?.[key] === "string")
105
+ .map((x: any) => [x[key], { label: String(x.label ?? ""), number: Number(x.number ?? 0) }]),
106
+ );
107
+ return {
108
+ panes,
109
+ focusedWorkspaceId: typeof snap.focused_workspace_id === "string" ? snap.focused_workspace_id : undefined,
110
+ tabs: byId(snap.tabs, "tab_id"),
111
+ workspaces: byId(snap.workspaces, "workspace_id"),
112
+ };
113
+ }
114
+
115
+ /** Compare paths across the formats agents store (`C:/x`, `C:\x`, lossy case). */
116
+ export function samePath(a: string, b: string): boolean {
117
+ const norm = (p: string) => {
118
+ let s = p.replaceAll("\\", "/").replace(/\/+$/, "");
119
+ if (/^[A-Za-z]:/.test(s)) s = s.toLowerCase();
120
+ return s;
121
+ };
122
+ if (!a || !b) return false;
123
+ const x = norm(a);
124
+ const y = norm(b);
125
+ return x === y || x.toLowerCase() === y.toLowerCase();
126
+ }
127
+
128
+ const STATUS_FROM_HERDR: Partial<Record<HerdrAgentStatus, AgentStatus>> = {
129
+ working: "live-busy",
130
+ blocked: "live-blocked",
131
+ done: "live-done",
132
+ idle: "live-idle",
133
+ };
134
+
135
+ /**
136
+ * Attach herdr links to sessions and let herdr's state win where it knows
137
+ * it. Panes reporting a session id/path are matched first; panes without one
138
+ * fall back to "most recent session of that agent in that directory".
139
+ */
140
+ export function linkHerdrSessions(
141
+ sessions: AgentSession[],
142
+ snap: HerdrSnapshot | undefined,
143
+ ): AgentSession[] {
144
+ if (!snap || snap.panes.length === 0) return sessions;
145
+ const links = new Map<AgentSession, HerdrLink>();
146
+ const link = (s: AgentSession, p: HerdrPane, matchedBy: HerdrLink["matchedBy"]) => {
147
+ const tab = snap.tabs.get(p.tabId);
148
+ links.set(s, {
149
+ paneId: p.paneId,
150
+ tabId: p.tabId,
151
+ workspaceId: p.workspaceId,
152
+ workspaceLabel: snap.workspaces.get(p.workspaceId)?.label ?? "",
153
+ tabLabel: tab?.label ?? "",
154
+ tabNumber: tab?.number ?? 0,
155
+ status: p.status,
156
+ matchedBy,
157
+ agent: p.agent ?? "",
158
+ });
159
+ };
160
+ const agentPanes = snap.panes.filter((p) => p.agent && PROVIDER_OF[p.agent]);
161
+
162
+ for (const p of agentPanes) {
163
+ if (!p.session) continue;
164
+ const provider = PROVIDER_OF[p.agent!];
165
+ const { kind, value } = p.session;
166
+ const s = sessions.find(
167
+ (x) =>
168
+ x.provider === provider &&
169
+ !links.has(x) &&
170
+ (kind === "id"
171
+ ? x.sessionId === value
172
+ : samePath(x.sourcePath, value) || value.includes(x.sessionId)),
173
+ );
174
+ if (s) link(s, p, kind);
175
+ }
176
+ for (const p of agentPanes) {
177
+ if (p.session) continue;
178
+ const provider = PROVIDER_OF[p.agent!];
179
+ const s = sessions
180
+ .filter((x) => x.provider === provider && !links.has(x) && samePath(x.cwd, p.cwd))
181
+ .sort((a, b) => b.lastActivityMs - a.lastActivityMs)[0];
182
+ if (s) link(s, p, "cwd");
183
+ }
184
+
185
+ if (links.size === 0) return sessions;
186
+ return sessions.map((s) => {
187
+ const l = links.get(s);
188
+ if (!l) return s;
189
+ return { ...s, herdr: l, status: STATUS_FROM_HERDR[l.status] ?? s.status };
190
+ });
191
+ }
192
+
193
+ /** Agent status glyph style — the same two herdr offers. */
194
+ export type IndicatorStyle = "dots" | "symbols";
195
+
196
+ /** One synchronous snapshot, for CLI/dev scripts. */
197
+ export function readHerdrSnapshotOnce(bin = Bun.which("herdr") ?? undefined): HerdrSnapshot | undefined {
198
+ if (!bin) return undefined;
199
+ try {
200
+ const res = Bun.spawnSync([bin, "api", "snapshot"], { stdout: "pipe", stderr: "ignore", timeout: 5000 });
201
+ return res.exitCode === 0 ? parseHerdrSnapshot(res.stdout.toString()) : undefined;
202
+ } catch {
203
+ return undefined;
204
+ }
205
+ }
206
+
207
+ // ─── Polling source ─────────────────────────────────────────────────────────
208
+
209
+ export interface HerdrSource {
210
+ snapshot: () => HerdrSnapshot | undefined;
211
+ onChange: (cb: () => void) => void;
212
+ dispose: () => void;
213
+ }
214
+
215
+ export const HERDR_POLL_MS = 2000;
216
+
217
+ /**
218
+ * Poll `herdr api snapshot` in the background. Inert when herdr isn't on
219
+ * PATH (or `bin: null`). A failing call clears the snapshot (server gone)
220
+ * without noise.
221
+ */
222
+ export function createHerdrSource(
223
+ { pollMs = HERDR_POLL_MS, bin = Bun.which("herdr") }: { pollMs?: number; bin?: string | null } = {},
224
+ ): HerdrSource {
225
+ let current: HerdrSnapshot | undefined;
226
+ let lastRaw = "";
227
+ const listeners: (() => void)[] = [];
228
+ if (!bin) return { snapshot: () => undefined, onChange: () => {}, dispose: () => {} };
229
+
230
+ let inFlight = false;
231
+ let disposed = false;
232
+ const poll = async () => {
233
+ if (inFlight || disposed) return;
234
+ inFlight = true;
235
+ try {
236
+ const proc = Bun.spawn([bin, "api", "snapshot"], {
237
+ stdout: "pipe",
238
+ stderr: "ignore",
239
+ windowsHide: true,
240
+ });
241
+ const timer = setTimeout(() => proc.kill(), 5000);
242
+ const out = await new Response(proc.stdout).text();
243
+ clearTimeout(timer);
244
+ const code = await proc.exited;
245
+ const snap = code === 0 ? parseHerdrSnapshot(out) : undefined;
246
+ // Only the parts we use decide whether anything changed.
247
+ const key = snap
248
+ ? JSON.stringify([snap.panes, [...snap.tabs], [...snap.workspaces]])
249
+ : "";
250
+ if (key !== lastRaw && !disposed) {
251
+ lastRaw = key;
252
+ current = snap;
253
+ for (const cb of listeners) cb();
254
+ }
255
+ } catch {
256
+ // herdr vanished mid-call — try again next tick
257
+ } finally {
258
+ inFlight = false;
259
+ }
260
+ };
261
+ void poll();
262
+ const timer = setInterval(poll, pollMs);
263
+ timer.unref?.();
264
+ return {
265
+ snapshot: () => current,
266
+ onChange: (cb) => listeners.push(cb),
267
+ dispose: () => {
268
+ disposed = true;
269
+ clearInterval(timer);
270
+ },
271
+ };
272
+ }
@@ -38,6 +38,7 @@ import {
38
38
  type AgentsStore,
39
39
  } from "./agents";
40
40
  import { AGENT_ADAPTERS } from "./agent-adapters";
41
+ import { createHerdrSource } from "./herdr";
41
42
  import {
42
43
  createCalendarStore,
43
44
  createGoogleEvent,
@@ -380,7 +381,7 @@ export function createTuiStore({ config }: CreateStoreOptions) {
380
381
  // the agents zone is disabled we skip the watcher entirely (no session reads
381
382
  // at all) and hand back an inert stub.
382
383
  const agentsStore: AgentsStore = enabledZones.agents
383
- ? createAgentsStore(AGENT_ADAPTERS)
384
+ ? createAgentsStore(AGENT_ADAPTERS, { herdr: createHerdrSource() })
384
385
  : noopAgentsStore();
385
386
  // Calendar feeds (read-only) merged into the Agenda zone. Skipped entirely
386
387
  // (no network) when the agenda zone is disabled.
@@ -1732,6 +1733,7 @@ export function createTuiStore({ config }: CreateStoreOptions) {
1732
1733
  activeBoard,
1733
1734
  agents: agentsStore,
1734
1735
  agentSessions,
1736
+ agentIndicators: config.statusIndicators,
1735
1737
  cycleAgentsFilter,
1736
1738
  setLastLaunch,
1737
1739
  calendar: calendarStore,
@@ -1819,7 +1821,7 @@ export type TuiStore = ReturnType<typeof createTuiStore>;
1819
1821
  /** Inert agents store for when the agents zone is disabled — no chokidar
1820
1822
  * watcher, no `~/.claude` reads at all. */
1821
1823
  function noopAgentsStore(): AgentsStore {
1822
- return { sessions: () => [], refresh: () => {}, dispose: async () => {} };
1824
+ return { sessions: () => [], herdr: () => undefined, refresh: () => {}, dispose: async () => {} };
1823
1825
  }
1824
1826
 
1825
1827
  /** Inert calendar store for when the agenda zone is disabled — no fetching. */
@@ -16,6 +16,7 @@ import { homedir } from "node:os";
16
16
 
17
17
  import { layoutCardDetails, layoutCardName, layoutLine } from "~/ui/agent-line";
18
18
  import { T } from "~/ui/glyphs";
19
+ import { herdrPlace, type IndicatorStyle } from "~/store/herdr";
19
20
  import {
20
21
  HARNESS,
21
22
  cwdShort,
@@ -27,21 +28,42 @@ import {
27
28
  } from "~/store/agents";
28
29
 
29
30
  const STATUS_COLOR: Record<AgentStatus, string> = {
30
- "live-busy": T.today, // bright accent for actively-running
31
- "live-idle": T.scheduled, // warm but quieter
32
- "stale": T.bannerWarn,
33
- "dormant": T.textDim,
34
- "archived": T.textDone,
31
+ "live-blocked": T.overdue, // needs you
32
+ "live-busy": T.today, // bright accent for actively-running
33
+ "live-done": T.done, // finished, not looked at yet
34
+ "live-idle": T.scheduled, // warm but quieter
35
+ "stale": T.bannerWarn,
36
+ "dormant": T.textDim,
37
+ "archived": T.textDone,
35
38
  };
36
39
 
37
- const STATUS_GLYPH: Record<AgentStatus, string> = {
38
- "live-busy": "●",
39
- "live-idle": "○",
40
- "stale": "△",
41
- "dormant": "·",
42
- "archived": "·",
40
+ /**
41
+ * herdr's two `status_indicators` styles, copied as-is so the same state reads
42
+ * the same in both tools. `stale` is tuiboard's own (no herdr equivalent).
43
+ */
44
+ const STATUS_GLYPH: Record<IndicatorStyle, Record<AgentStatus, string>> = {
45
+ dots: {
46
+ "live-blocked": "●",
47
+ "live-busy": "●",
48
+ "live-done": "●",
49
+ "live-idle": "○",
50
+ "stale": "△",
51
+ "dormant": "·",
52
+ "archived": "·",
53
+ },
54
+ symbols: {
55
+ "live-blocked": "×",
56
+ "live-busy": "◐",
57
+ "live-done": "✓",
58
+ "live-idle": "○",
59
+ "stale": "△",
60
+ "dormant": "·",
61
+ "archived": "·",
62
+ },
43
63
  };
44
64
 
65
+
66
+
45
67
  /** One hue per harness so the badge reads before the letters do. */
46
68
  export const HARNESS_COLOR: Record<AgentProvider, string> = {
47
69
  "claude-code": T.warm, // orange
@@ -66,6 +88,8 @@ interface AgentRowProps {
66
88
  nameMaxChars?: number;
67
89
  /** `line` (default) for the dashboard strip, `card` for the fullscreen view. */
68
90
  variant?: "line" | "card";
91
+ /** Status glyph style (default symbols, as in herdr). */
92
+ indicators?: IndicatorStyle;
69
93
  onClick?: () => void;
70
94
  }
71
95
 
@@ -106,7 +130,15 @@ export function AgentRow(props: AgentRowProps) {
106
130
  );
107
131
  const cardName = createMemo(() => layoutCardName(displayName(), width()));
108
132
  const details = createMemo(() =>
109
- layoutCardDetails([model(), props.session.gitBranch, cardCwd(props.session.cwd)], width()),
133
+ layoutCardDetails(
134
+ [
135
+ model(),
136
+ props.session.herdr && herdrPlace(props.session.herdr),
137
+ props.session.gitBranch,
138
+ cardCwd(props.session.cwd),
139
+ ],
140
+ width(),
141
+ ),
110
142
  );
111
143
 
112
144
  // cursor · status glyph · harness badge — shared by both variants.
@@ -115,7 +147,7 @@ export function AgentRow(props: AgentRowProps) {
115
147
  {props.cursor ? "▶ " : " "}
116
148
  </span>,
117
149
  <span style={{ fg: STATUS_COLOR[props.session.status] }}>
118
- {STATUS_GLYPH[props.session.status]}{" "}
150
+ {STATUS_GLYPH[props.indicators ?? "symbols"][props.session.status]}{" "}
119
151
  </span>,
120
152
  <span style={{ fg: HARNESS_COLOR[props.session.provider] }}>
121
153
  {HARNESS[props.session.provider].code}{" "}
@@ -100,6 +100,7 @@ export function AgentsBar(props: AgentsBarProps) {
100
100
  {(session, i) => (
101
101
  <box id={agentRowId(i())}>
102
102
  <AgentRow
103
+ indicators={props.store.agentIndicators}
103
104
  session={session}
104
105
  cursor={isActive() && i() === agentRow()}
105
106
  nameMaxChars={48}
package/src/ui/Modal.tsx CHANGED
@@ -24,6 +24,7 @@ import { AGENDA_WIDTH } from "~/ui/layout";
24
24
  import { formatHm } from "~/store/timeline";
25
25
  import { HARNESS } from "~/store/agents";
26
26
  import { HARNESS_COLOR } from "~/ui/AgentRow";
27
+ import { herdrPlace } from "~/store/herdr";
27
28
  import type { TuiStore } from "~/store/index";
28
29
  import type { PriorityLevel, TimeBlock } from "~/types";
29
30
 
@@ -936,6 +937,27 @@ function AgentDetailModal(props: { store: TuiStore; modal: Extract<NonNullable<T
936
937
  <span style={{ fg: T.textDim }}>status </span>
937
938
  <span style={{ fg: T.text }}>{s().status}</span>
938
939
  </text>
940
+ <Show when={s().herdr}>
941
+ {(h: () => NonNullable<ReturnType<typeof s>["herdr"]>) => (
942
+ <>
943
+ <text>
944
+ <span style={{ fg: T.textDim }}>herdr </span>
945
+ <span style={{ fg: T.accent }}>{herdrPlace(h())}</span>
946
+ <span style={{ fg: T.textDim }}>
947
+ {" "}pane {h().paneId} · {h().status} · matched by {h().matchedBy}
948
+ </span>
949
+ </text>
950
+ <Show when={h().matchedBy === "cwd"}>
951
+ <text wrapMode="word">
952
+ <span style={{ fg: T.textDim }}>
953
+ {" "}herdr doesn't report which {h().agent} session this pane is on — run
954
+ `herdr integration install {h().agent}` for an exact match.
955
+ </span>
956
+ </text>
957
+ </Show>
958
+ </>
959
+ )}
960
+ </Show>
939
961
  <text>
940
962
  <span style={{ fg: T.textDim }}>cwd </span>
941
963
  <span style={{ fg: T.text }}>{s().cwd}</span>
@@ -1126,7 +1148,8 @@ const HELP_SECTIONS: HelpSection[] = [
1126
1148
  emoji: "🤖",
1127
1149
  title: "Agents zone",
1128
1150
  rows: [
1129
- ["Enter", "Open (resume) the selected session in a new terminal tab/window"],
1151
+ ["Enter", "Open (resume) the selected session in a new terminal tab/window — or go to it if it's open in herdr"],
1152
+ ["H", "herdr: go to the session, or resume it in a new tab of its project's workspace"],
1130
1153
  ["c", "Copy a 'cd + resume' command for the session"],
1131
1154
  ["o", "Session detail (harness, model, cwd, branch, last prompts, resume cmd)"],
1132
1155
  ["f", "Filter by harness: all → cc Claude Code → cx Codex → oc OpenCode → pi Pi"],
@@ -93,6 +93,7 @@ export function AgentsOnly(props: { store: TuiStore }) {
93
93
  cursor={isActive() && i() === agentRow()}
94
94
  nameMaxChars={120}
95
95
  variant="card"
96
+ indicators={props.store.agentIndicators}
96
97
  onClick={() => {
97
98
  props.store.setActiveZone("agents");
98
99
  props.store.setCursor(0, i());