tuiboard 0.12.0 → 0.13.1

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,74 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.13.1] - 2026-09-17
11
+
12
+ ### Fixed
13
+ - **Agents list stays in order when sessions move** (#52). A new session
14
+ appearing on top could show twice, leave blank rows, or draw rows in the
15
+ wrong order past the panel border; the lists now keep one stable row per
16
+ position. The cursor also stays on the session you selected while the list
17
+ reorders, instead of silently landing on another one (so Enter, `H` and `c`
18
+ act on the session you picked).
19
+ - **`--view=agents` / `--view=timeline` / `--view=board` start on their own
20
+ zone** (#54), so the cursor and keys work right away instead of acting on
21
+ the hidden planner.
22
+
23
+ ## [0.13.0] - 2026-09-17
24
+
25
+ tuiboard and [herdr](https://herdr.dev) now work as one: the Agents zone shows
26
+ herdr's live state for every session open there, speaks herdr's status
27
+ symbols, and `H` jumps to a session in herdr or resumes it in the right
28
+ workspace. Plus fixes found using it all day on Windows.
29
+
30
+ ### Added
31
+ - **Live agent state from herdr** (#37). When herdr is running, tuiboard polls
32
+ `herdr api snapshot` and links every pane running Claude Code, Codex,
33
+ OpenCode or Pi to its session — by the session id/file herdr reports (with
34
+ herdr's agent integrations installed), else by agent and directory. Those
35
+ sessions take herdr's state, including two new ones: **waiting for you** and
36
+ **done**; idle Codex/OpenCode/Pi sessions become visible, and long Claude
37
+ turns no longer show as stale (#22). Zoomed cards and the `o` detail show the
38
+ herdr workspace and tab.
39
+ - **herdr's status symbols** — `×` waiting, `◐` working, `✓` done, `○` idle,
40
+ `·` closed (tuiboard's own `△` for stale) — are the default for everyone, so
41
+ both tools read the same; `status_indicators: dots` keeps colored dots.
42
+ - **`H` opens sessions in herdr** (#38): focuses the pane a session is open
43
+ in, or resumes it in herdr — a new tab named after the session, in the
44
+ workspace that already holds that directory (else one named like the
45
+ folder, else the focused one). **Enter** on a session already open in herdr
46
+ focuses it instead of starting a second copy.
47
+ - **Omarchy bar widget in the repo** (#27): `omarchy-plugin/` — an
48
+ overdue/today badge and a Today/Tomorrow panel (done, undone, defer, open
49
+ tuiboard) built on `tuiboard summary` / `tuiboard task`, installed with
50
+ `omarchy-plugin/install.sh`. Repo only, not part of the npm package.
51
+
52
+ ### Changed
53
+ - **Agent sessions are sorted by most recent activity** (#43), newest first,
54
+ instead of by status first — a session used 40 seconds ago no longer sits
55
+ below ones idle in herdr for hours. Status only breaks ties; archived
56
+ sessions stay at the bottom.
57
+ - Launch steps for Enter and `H` run asynchronously: a slow start (cold
58
+ PowerShell, an agent booting in herdr) no longer freezes the UI.
59
+
60
+ ### Fixed
61
+ - **Quitting gives the terminal back** (#47). `q`, Ctrl+C and termination
62
+ signals exited without tearing down the renderer, leaving mouse tracking
63
+ and the alternate screen on — moving the mouse at the shell then printed
64
+ `51;7;45M…`. tuiboard now restores the terminal before exiting, and the
65
+ `tuiboard` launcher resets mouse/paste/focus reporting and the cursor after
66
+ the app ends, whatever way it ended.
67
+ - **`H` on Windows with Codex, OpenCode and Pi** (#41). npm-installed CLIs on
68
+ Windows are an extensionless sh shim next to `.cmd`/`.ps1`, which herdr
69
+ can't launch directly ("not a valid Win32 application"); on Windows the
70
+ resume command is typed into the new pane's shell instead.
71
+ - **Responsive layout follows the renderer's terminal size** (#45), the size
72
+ the frame is drawn at, instead of `process.stdout.columns`.
73
+
74
+ ### Known issues
75
+ - On Windows, herdr may not detect Pi sessions as agents (#49): they open
76
+ fine, but without herdr's live state.
77
+
10
78
  ## [0.12.0] - 2026-09-17
11
79
 
12
80
  The Agents zone stops being Claude-Code-only: it now lists **Codex, OpenCode
@@ -419,6 +487,8 @@ First public release on npm. This entry captures the full feature set at launch.
419
487
 
420
488
  Built with [OpenTUI](https://opentui.com) + SolidJS on Bun.
421
489
 
490
+ [0.13.1]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.13.1
491
+ [0.13.0]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.13.0
422
492
  [0.12.0]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.12.0
423
493
  [0.11.0]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.11.0
424
494
  [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.1",
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.
@@ -147,6 +148,11 @@ if (view === "planner") {
147
148
  store.setActiveZone("planner");
148
149
  store.setZoomed(true);
149
150
  }
151
+ // The single-zone views show one zone; keys must act on it. Without this the
152
+ // planner (the default landing zone above) stayed active behind them.
153
+ if (view === "agents" || view === "timeline" || view === "board") {
154
+ store.setActiveZone(view);
155
+ }
150
156
 
151
157
  // ─── App shell ──────────────────────────────────────────────────────────────
152
158
 
@@ -170,6 +176,13 @@ function App() {
170
176
 
171
177
  useKeyboard((key) => handleKey(store, key, plannerItems().length));
172
178
 
179
+ // Same width the frame is drawn at, updated on the renderer's resize events.
180
+ const dims = useTerminalDimensions();
181
+ createEffect(() => {
182
+ const { width } = dims();
183
+ if (width > 0) applyResponsiveLayout(width);
184
+ });
185
+
173
186
  return (
174
187
  <box
175
188
  style={{
@@ -226,4 +239,6 @@ if (process.env.TUIBOARD_READY_FLAG) {
226
239
  }
227
240
  }
228
241
 
229
- await render(() => <App />, { useMouse: true });
242
+ const renderer = await createCliRenderer({ useMouse: true });
243
+ registerRenderer(renderer);
244
+ 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
+ }