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.
@@ -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}{" "}