@bakers-build/addon-herdr 0.0.0-stage → 0.1.0-next.38

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.
@@ -0,0 +1,34 @@
1
+ export interface LaunchScript {
2
+ dir: string;
3
+ wrapPath: string;
4
+ commandPath: string;
5
+ startedPath: string;
6
+ exitPath: string;
7
+ typedCommand: string;
8
+ }
9
+ /**
10
+ * Write a run-scoped launch script so a fresh pane is typed only `sh '<file>'`.
11
+ *
12
+ * The assembled command (env prefixes + agent argv) lives in a `0600` file
13
+ * inside a fresh `0700` directory under the OS temp dir. The typed line stays
14
+ * well under the tty canonical cap (macOS 1024, Linux 4096). The wrapper
15
+ * deletes the command file as its first step so secrets never linger in the
16
+ * pane's shell history or on disk after launch.
17
+ */
18
+ export declare function writeLaunchScript(command: string): LaunchScript;
19
+ export interface LaunchWatchOptions {
20
+ /** Window after `started` in which an immediate agent exit fails the launch. */
21
+ timeoutMs?: number;
22
+ /** How long to wait for the wrapper to write `started`. Default 60s. */
23
+ startTimeoutMs?: number;
24
+ pollMs?: number;
25
+ sleep?: (ms: number) => Promise<void>;
26
+ readPane?: () => Promise<string>;
27
+ }
28
+ /**
29
+ * Poll the launch markers in two phases. Wait up to `startTimeoutMs` for
30
+ * `started` (shell start-up), then apply a short `timeoutMs` exit window from
31
+ * that moment. An immediate exit or a script that never starts fails the
32
+ * launch; an agent still running at the exit deadline is treated as started.
33
+ */
34
+ export declare function watchLaunch(script: LaunchScript, opts?: LaunchWatchOptions): Promise<void>;
@@ -0,0 +1,98 @@
1
+ import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync, } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { shellQuote } from '@bakers-build/core';
5
+ /**
6
+ * Write a run-scoped launch script so a fresh pane is typed only `sh '<file>'`.
7
+ *
8
+ * The assembled command (env prefixes + agent argv) lives in a `0600` file
9
+ * inside a fresh `0700` directory under the OS temp dir. The typed line stays
10
+ * well under the tty canonical cap (macOS 1024, Linux 4096). The wrapper
11
+ * deletes the command file as its first step so secrets never linger in the
12
+ * pane's shell history or on disk after launch.
13
+ */
14
+ export function writeLaunchScript(command) {
15
+ const dir = mkdtempSync(join(tmpdir(), 'bakers-run-'));
16
+ chmodSync(dir, 0o700);
17
+ try {
18
+ const commandPath = join(dir, 'command');
19
+ const wrapPath = join(dir, 'run.sh');
20
+ const startedPath = join(dir, 'started');
21
+ const exitPath = join(dir, 'exit');
22
+ writeFileSync(commandPath, command, { encoding: 'utf8', mode: 0o600 });
23
+ chmodSync(commandPath, 0o600);
24
+ const wrap = `#!/bin/sh
25
+ dir=${shellQuote(dir)}
26
+ cmd=$(cat -- "$dir/command")
27
+ rm -f -- "$dir/command" "$0"
28
+ printf started > "$dir/started"
29
+ trap : INT QUIT
30
+ ( eval "$cmd" )
31
+ code=$?
32
+ printf 'exit %s\\n' "$code" > "$dir/exit"
33
+ exit "$code"
34
+ `;
35
+ writeFileSync(wrapPath, wrap, { encoding: 'utf8', mode: 0o600 });
36
+ chmodSync(wrapPath, 0o600);
37
+ return {
38
+ dir,
39
+ wrapPath,
40
+ commandPath,
41
+ startedPath,
42
+ exitPath,
43
+ typedCommand: `sh ${shellQuote(wrapPath)}`,
44
+ };
45
+ }
46
+ catch (err) {
47
+ rmSync(dir, { recursive: true, force: true });
48
+ throw err;
49
+ }
50
+ }
51
+ const DEFAULT_TIMEOUT_MS = 4000;
52
+ const DEFAULT_START_TIMEOUT_MS = 60_000;
53
+ const DEFAULT_POLL_MS = 50;
54
+ /**
55
+ * Poll the launch markers in two phases. Wait up to `startTimeoutMs` for
56
+ * `started` (shell start-up), then apply a short `timeoutMs` exit window from
57
+ * that moment. An immediate exit or a script that never starts fails the
58
+ * launch; an agent still running at the exit deadline is treated as started.
59
+ */
60
+ export async function watchLaunch(script, opts = {}) {
61
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
62
+ const startTimeoutMs = opts.startTimeoutMs ?? DEFAULT_START_TIMEOUT_MS;
63
+ const pollMs = opts.pollMs ?? DEFAULT_POLL_MS;
64
+ const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
65
+ const startDeadline = Date.now() + startTimeoutMs;
66
+ let exitDeadline = 0;
67
+ let sawStarted = false;
68
+ for (;;) {
69
+ if (existsSync(script.exitPath)) {
70
+ const line = readFileSync(script.exitPath, 'utf8').trim();
71
+ if (line) {
72
+ throw new Error(await launchFailure(line, opts.readPane));
73
+ }
74
+ }
75
+ if (!sawStarted && existsSync(script.startedPath)) {
76
+ sawStarted = true;
77
+ exitDeadline = Date.now() + timeoutMs;
78
+ if (timeoutMs === 0)
79
+ return;
80
+ }
81
+ const now = Date.now();
82
+ if (sawStarted) {
83
+ if (now >= exitDeadline)
84
+ return;
85
+ }
86
+ else if (now >= startDeadline) {
87
+ throw new Error(await launchFailure('agent did not start', opts.readPane));
88
+ }
89
+ await sleep(pollMs);
90
+ }
91
+ }
92
+ async function launchFailure(reason, readPane) {
93
+ const tail = readPane ? await readPane().catch(() => '') : '';
94
+ const trimmed = tail.trim();
95
+ return trimmed
96
+ ? `Launch failed: ${reason}\n${trimmed}`
97
+ : `Launch failed: ${reason}`;
98
+ }
@@ -0,0 +1,45 @@
1
+ import { type PaneSpec } from './config.js';
2
+ export type HerdrExec = (args: string[], opts?: {
3
+ redactArgs?: (args: string[]) => string[];
4
+ }) => Promise<string>;
5
+ export declare function isRecord(value: unknown): value is Record<string, unknown>;
6
+ export declare function parseJson(output: string, command: string): unknown;
7
+ export declare function parsePaneSplit(output: string): string;
8
+ /**
9
+ * A pane layout: an optional command run in the root/main pane, plus optional
10
+ * split panes. Shared by the executor's per-run tab (`startRun`) and the
11
+ * workspace's initial open pane (`herdrWorkspace` `open`) — one code path, no
12
+ * duplication. The per-state `StateConfig` (executor) and the workspace `open`
13
+ * option both narrow to this shape (the executor adds `tabLabel`; there is only
14
+ * one open layout, not a per-state map).
15
+ */
16
+ export interface PaneLayout {
17
+ /** Command run in the initial root/main pane (typed verbatim, not templated). */
18
+ command?: string;
19
+ /** Extra split panes off the root; each command is `renderTemplate`'d. */
20
+ panes?: PaneSpec[];
21
+ }
22
+ /**
23
+ * Apply a {@link PaneLayout} to an existing root pane: run `command` in the root
24
+ * pane, then split each configured pane (with `--cwd cwd`) and run its
25
+ * `renderTemplate`'d command. The root command is typed VERBATIM (the caller
26
+ * pre-assembles it — e.g. the executor's env-prefixed agent command, BB-101/128);
27
+ * only split commands are templated from `vars`.
28
+ *
29
+ * `opts.redactCommand` supplies a log-safe rewrite of the root command (env
30
+ * VALUES masked, key names kept — BB-99): when it returns a different string a
31
+ * `redactArgs` scrubber is passed so a failed `pane run` exec-log never carries a
32
+ * secret, while the command herdr actually runs stays real. An empty layout
33
+ * (`{}`) issues no calls at all — the caller's unconfigured default is preserved.
34
+ *
35
+ * `opts.env` is forwarded to each split pane as `--env` args (BB_* only, see
36
+ * {@link envArgs}). It is deliberately per-CALLER rather than baked in: this
37
+ * primitive serves both the executor's agent tab and the workspace's own open
38
+ * pane, and only the former's panes are agent-owned (BB-64). The ROOT pane is
39
+ * not touched — it already exists when we get here, so its environment came from
40
+ * whoever created it. Omitted ⇒ no `--env` at all.
41
+ */
42
+ export declare function applyPaneLayout(execHerdr: HerdrExec, rootPaneId: string, layout: PaneLayout, cwd: string, vars: Record<string, string>, opts?: {
43
+ redactCommand?: (command: string) => string;
44
+ env?: Record<string, string | undefined>;
45
+ }): Promise<void>;
@@ -0,0 +1,78 @@
1
+ import { renderTemplate } from './config.js';
2
+ import { envArgs } from './panes.js';
3
+ export function isRecord(value) {
4
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
5
+ }
6
+ export function parseJson(output, command) {
7
+ try {
8
+ return JSON.parse(output);
9
+ }
10
+ catch {
11
+ throw new Error(`herdr ${command} returned invalid JSON`);
12
+ }
13
+ }
14
+ export function parsePaneSplit(output) {
15
+ const parsed = parseJson(output, 'pane split');
16
+ const paneId = isRecord(parsed)
17
+ ? parsed.result?.pane
18
+ ?.pane_id
19
+ : undefined;
20
+ if (typeof paneId !== 'string') {
21
+ throw new Error('herdr pane split returned invalid schema');
22
+ }
23
+ return paneId;
24
+ }
25
+ /**
26
+ * Apply a {@link PaneLayout} to an existing root pane: run `command` in the root
27
+ * pane, then split each configured pane (with `--cwd cwd`) and run its
28
+ * `renderTemplate`'d command. The root command is typed VERBATIM (the caller
29
+ * pre-assembles it — e.g. the executor's env-prefixed agent command, BB-101/128);
30
+ * only split commands are templated from `vars`.
31
+ *
32
+ * `opts.redactCommand` supplies a log-safe rewrite of the root command (env
33
+ * VALUES masked, key names kept — BB-99): when it returns a different string a
34
+ * `redactArgs` scrubber is passed so a failed `pane run` exec-log never carries a
35
+ * secret, while the command herdr actually runs stays real. An empty layout
36
+ * (`{}`) issues no calls at all — the caller's unconfigured default is preserved.
37
+ *
38
+ * `opts.env` is forwarded to each split pane as `--env` args (BB_* only, see
39
+ * {@link envArgs}). It is deliberately per-CALLER rather than baked in: this
40
+ * primitive serves both the executor's agent tab and the workspace's own open
41
+ * pane, and only the former's panes are agent-owned (BB-64). The ROOT pane is
42
+ * not touched — it already exists when we get here, so its environment came from
43
+ * whoever created it. Omitted ⇒ no `--env` at all.
44
+ */
45
+ export async function applyPaneLayout(execHerdr, rootPaneId, layout, cwd, vars, opts) {
46
+ if (layout.command) {
47
+ const command = layout.command;
48
+ const runArgs = ['pane', 'run', rootPaneId, command];
49
+ const redacted = opts?.redactCommand?.(command);
50
+ if (redacted !== undefined && redacted !== command) {
51
+ await execHerdr(runArgs, {
52
+ redactArgs: (args) => args.map((a) => (a === command ? redacted : a)),
53
+ });
54
+ }
55
+ else {
56
+ await execHerdr(runArgs);
57
+ }
58
+ }
59
+ for (const spec of layout.panes ?? []) {
60
+ const splitPaneId = parsePaneSplit(await execHerdr([
61
+ 'pane',
62
+ 'split',
63
+ rootPaneId,
64
+ '--direction',
65
+ spec.direction,
66
+ '--cwd',
67
+ cwd,
68
+ spec.focus ? '--focus' : '--no-focus',
69
+ ...envArgs(opts?.env ?? {}),
70
+ ]));
71
+ await execHerdr([
72
+ 'pane',
73
+ 'run',
74
+ splitPaneId,
75
+ renderTemplate(spec.command, vars),
76
+ ]);
77
+ }
78
+ }
@@ -0,0 +1,31 @@
1
+ /** A herdr pane and the tab it sits in. */
2
+ export interface HerdrPaneRef {
3
+ paneId: string;
4
+ tabId: string;
5
+ }
6
+ /** A herdr pane plus its label. */
7
+ export interface HerdrPaneInfo extends HerdrPaneRef {
8
+ label: string;
9
+ }
10
+ /**
11
+ * Marker exported into an AGENT pane's interactive shell, so a user's opt-in
12
+ * shell init can recognise an agent-run pane at startup and isolate its history
13
+ * (BB-64). It is a plain `BB_*` env entry on purpose: it rides {@link envArgs}
14
+ * like every other pane var instead of adding a second mechanism.
15
+ *
16
+ * AGENT panes only — the executor's per-run tab and its layout panes, plus the
17
+ * TUI-hosted agents. NOT the workspace's own open-layout pane: that one is the
18
+ * human's landing pane, and the opt-in unsets `HISTFILE`. See
19
+ * `docs/conductor/node.md` § Agent-pane shell marker.
20
+ */
21
+ export declare const AGENT_SHELL_ENV: Record<string, string>;
22
+ /** BB_* env vars as `--env KEY=VALUE` args (never dump the full environment). */
23
+ export declare function envArgs(env: Record<string, string | undefined>): string[];
24
+ /** `{ paneId, tabId }` from a `herdr tab create` envelope. */
25
+ export declare function parseTabCreate(output: string): HerdrPaneRef;
26
+ /** `{ paneId, tabId }` from a `result.pane` envelope (pane current / split). */
27
+ export declare function parsePaneInfo(output: string): HerdrPaneRef;
28
+ /** The id of the first tab whose label equals `label`, else null. */
29
+ export declare function findTabIdByLabel(output: string, label: string): string | null;
30
+ /** Panes in `tabId` as `[{ paneId, tabId, label }]` from a `pane list` envelope. */
31
+ export declare function parsePanesInTab(output: string, tabId: string): HerdrPaneInfo[];
@@ -0,0 +1,87 @@
1
+ // Herdr pane/tab CLI envelope parsers (BB-190) — the building blocks the
2
+ // intent-level `herdrAgentHost` (agents.ts) uses to talk to herdr. Kept small
3
+ // and herdr-internal; the conductor/TUI never see these pane shapes.
4
+ function isRecord(v) {
5
+ return typeof v === 'object' && v !== null;
6
+ }
7
+ /**
8
+ * Marker exported into an AGENT pane's interactive shell, so a user's opt-in
9
+ * shell init can recognise an agent-run pane at startup and isolate its history
10
+ * (BB-64). It is a plain `BB_*` env entry on purpose: it rides {@link envArgs}
11
+ * like every other pane var instead of adding a second mechanism.
12
+ *
13
+ * AGENT panes only — the executor's per-run tab and its layout panes, plus the
14
+ * TUI-hosted agents. NOT the workspace's own open-layout pane: that one is the
15
+ * human's landing pane, and the opt-in unsets `HISTFILE`. See
16
+ * `docs/conductor/node.md` § Agent-pane shell marker.
17
+ */
18
+ export const AGENT_SHELL_ENV = {
19
+ BB_AGENT_SHELL: '1',
20
+ };
21
+ /** BB_* env vars as `--env KEY=VALUE` args (never dump the full environment). */
22
+ export function envArgs(env) {
23
+ const out = [];
24
+ for (const [key, value] of Object.entries(env)) {
25
+ if (key.startsWith('BB_') && value != null) {
26
+ out.push('--env', `${key}=${value}`);
27
+ }
28
+ }
29
+ return out;
30
+ }
31
+ /** `{ paneId, tabId }` from a `herdr tab create` envelope. */
32
+ export function parseTabCreate(output) {
33
+ const parsed = JSON.parse(output);
34
+ const result = isRecord(parsed)
35
+ ? parsed.result
36
+ : undefined;
37
+ const paneId = result?.root_pane?.pane_id;
38
+ const tabId = result?.tab?.tab_id;
39
+ if (typeof paneId !== 'string' || typeof tabId !== 'string') {
40
+ throw new Error('herdr tab create returned invalid schema');
41
+ }
42
+ return { paneId, tabId };
43
+ }
44
+ /** `{ paneId, tabId }` from a `result.pane` envelope (pane current / split). */
45
+ export function parsePaneInfo(output) {
46
+ const parsed = JSON.parse(output);
47
+ const pane = isRecord(parsed)
48
+ ? parsed.result
49
+ ?.pane
50
+ : undefined;
51
+ const paneId = pane?.pane_id;
52
+ const tabId = pane?.tab_id;
53
+ if (typeof paneId !== 'string' || typeof tabId !== 'string') {
54
+ throw new Error('herdr returned no pane info');
55
+ }
56
+ return { paneId, tabId };
57
+ }
58
+ /** The id of the first tab whose label equals `label`, else null. */
59
+ export function findTabIdByLabel(output, label) {
60
+ const parsed = JSON.parse(output);
61
+ const tabs = isRecord(parsed)
62
+ ? parsed.result?.tabs
63
+ : undefined;
64
+ if (!Array.isArray(tabs))
65
+ return null;
66
+ const match = tabs
67
+ .filter(isRecord)
68
+ .find((t) => t.label === label && typeof t.tab_id === 'string');
69
+ return match ? match.tab_id : null;
70
+ }
71
+ /** Panes in `tabId` as `[{ paneId, tabId, label }]` from a `pane list` envelope. */
72
+ export function parsePanesInTab(output, tabId) {
73
+ const parsed = JSON.parse(output);
74
+ const panes = isRecord(parsed)
75
+ ? parsed.result?.panes
76
+ : undefined;
77
+ if (!Array.isArray(panes))
78
+ return [];
79
+ return panes
80
+ .filter(isRecord)
81
+ .filter((p) => typeof p.pane_id === 'string' && p.tab_id === tabId)
82
+ .map((p) => ({
83
+ paneId: p.pane_id,
84
+ tabId,
85
+ label: typeof p.label === 'string' ? p.label : '',
86
+ }));
87
+ }
@@ -0,0 +1,6 @@
1
+ import type { Preset } from '@bakers-build/conductor/contract';
2
+ export declare const executors: Preset['executors'];
3
+ export declare const workspaces: Preset['workspaces'];
4
+ export declare const processHosts: Preset['processHosts'];
5
+ export declare const hooks: Preset['hooks'];
6
+ export declare const scopes: Preset['scopes'];
@@ -0,0 +1,18 @@
1
+ import { herdrFrozenMarker } from './frozen-marker.js';
2
+ import { herdrExecutor, herdrWorkspace } from './index.js';
3
+ import { herdrProcessHost } from './process-host.js';
4
+ export const executors = (acc, opts) => [
5
+ ...acc,
6
+ herdrExecutor(opts),
7
+ ];
8
+ export const workspaces = (acc, opts) => [
9
+ ...acc,
10
+ herdrWorkspace(opts),
11
+ ];
12
+ export const processHosts = (acc) => [
13
+ ...acc,
14
+ herdrProcessHost,
15
+ ];
16
+ export const hooks = (acc) => [...acc, herdrFrozenMarker];
17
+ // Hook listeners and the process host belong to one project (no machine hook listeners, BB-599).
18
+ export const scopes = ['project'];
@@ -0,0 +1,6 @@
1
+ import type { HerdrHost } from '@bakers-build/conductor/contract';
2
+ /**
3
+ * Close every herdr workspace whose label equals the conductor handle, then
4
+ * create one and `exec env` the pane command so herdr auto-closes on exit.
5
+ */
6
+ export declare const herdrProcessHost: HerdrHost;
@@ -0,0 +1,46 @@
1
+ import { exec } from '@bakers-build/core';
2
+ import { isRecord, parseJson } from './pane-layout.js';
3
+ import { parseWorkspaceList } from './workspace-list.js';
4
+ function parseWorkspaceCreate(output) {
5
+ const parsed = parseJson(output, 'workspace create');
6
+ const paneId = isRecord(parsed)
7
+ ? parsed.result
8
+ ?.root_pane?.pane_id
9
+ : undefined;
10
+ if (typeof paneId !== 'string' || paneId === '') {
11
+ throw new Error('herdr workspace create returned no pane id');
12
+ }
13
+ return paneId;
14
+ }
15
+ /**
16
+ * Close every herdr workspace whose label equals the conductor handle, then
17
+ * create one and `exec env` the pane command so herdr auto-closes on exit.
18
+ */
19
+ export const herdrProcessHost = {
20
+ async spawn(label, cwd, cmd) {
21
+ const listed = parseWorkspaceList(await exec('herdr', ['workspace', 'list']));
22
+ for (const ws of listed) {
23
+ if (ws.label === label) {
24
+ try {
25
+ await exec('herdr', ['workspace', 'close', ws.workspaceId]);
26
+ }
27
+ catch {
28
+ // herdr may already have auto-closed it after a previous stop
29
+ }
30
+ }
31
+ }
32
+ const paneId = parseWorkspaceCreate(await exec('herdr', [
33
+ 'workspace',
34
+ 'create',
35
+ '--cwd',
36
+ cwd,
37
+ '--label',
38
+ label,
39
+ '--no-focus',
40
+ ]));
41
+ // `exec env` replaces the pane's login shell (fish-safe VAR=value form)
42
+ // so herdr closes the workspace when the conductor exits by any path.
43
+ // `env` needs a real `bakers` executable on PATH.
44
+ await exec('herdr', ['pane', 'run', paneId, `exec env ${cmd}`]);
45
+ },
46
+ };
@@ -0,0 +1,13 @@
1
+ import { defaultRootGitExec, deriveCheckoutRoot, type RootGitExec } from '@bakers-build/core';
2
+ export type { RootGitExec };
3
+ export { defaultRootGitExec };
4
+ /**
5
+ * herdr's own name for the shared checkout-root anchor (BB-177), kept as a
6
+ * thin alias so `index.ts` and `workspace.ts` need no rename. The behaviour —
7
+ * including the injected git-exec seam — now lives in `@bakers-build/core` as
8
+ * `deriveCheckoutRoot()` (BB-450 D2): two other call sites
9
+ * (`@bakers-build/conductor`'s `checkoutRootOf()` and `addon-workspace-git`'s
10
+ * `gitWorkspace` fallback) needed the same derivation and neither may import
11
+ * this addon, so the helper moved to the kernel rather than staying herdr-owned.
12
+ */
13
+ export declare const deriveHerdrRoot: typeof deriveCheckoutRoot;
@@ -0,0 +1,12 @@
1
+ import { defaultRootGitExec, deriveCheckoutRoot, } from '@bakers-build/core';
2
+ export { defaultRootGitExec };
3
+ /**
4
+ * herdr's own name for the shared checkout-root anchor (BB-177), kept as a
5
+ * thin alias so `index.ts` and `workspace.ts` need no rename. The behaviour —
6
+ * including the injected git-exec seam — now lives in `@bakers-build/core` as
7
+ * `deriveCheckoutRoot()` (BB-450 D2): two other call sites
8
+ * (`@bakers-build/conductor`'s `checkoutRootOf()` and `addon-workspace-git`'s
9
+ * `gitWorkspace` fallback) needed the same derivation and neither may import
10
+ * this addon, so the helper moved to the kernel rather than staying herdr-owned.
11
+ */
12
+ export const deriveHerdrRoot = deriveCheckoutRoot;
@@ -0,0 +1,19 @@
1
+ /** One `herdr workspace list` entry. */
2
+ export interface WorkspaceEntry {
3
+ workspaceId: string;
4
+ /** Workspace label; null when herdr omits it. */
5
+ label: string | null;
6
+ focused: boolean;
7
+ isLinkedWorktree: boolean;
8
+ /** The worktree's parent repo root — the shared key between a linked
9
+ * ticket worktree and its main checkout. Null when herdr reports none. */
10
+ repoRoot: string | null;
11
+ /** The workspace's checkout directory. Null when herdr reports none. */
12
+ checkoutPath: string | null;
13
+ }
14
+ /**
15
+ * Parse `herdr workspace list` (which always emits JSON; it takes no `--json`
16
+ * flag). Enumerates every workspace on the host with its `label`, `focused`
17
+ * flag and its `worktree.{is_linked_worktree,repo_root}`.
18
+ */
19
+ export declare function parseWorkspaceList(output: string): WorkspaceEntry[];
@@ -0,0 +1,29 @@
1
+ import { isRecord, parseJson } from './pane-layout.js';
2
+ /**
3
+ * Parse `herdr workspace list` (which always emits JSON; it takes no `--json`
4
+ * flag). Enumerates every workspace on the host with its `label`, `focused`
5
+ * flag and its `worktree.{is_linked_worktree,repo_root}`.
6
+ */
7
+ export function parseWorkspaceList(output) {
8
+ const parsed = parseJson(output, 'workspace list');
9
+ const workspaces = isRecord(parsed)
10
+ ? parsed.result?.workspaces
11
+ : undefined;
12
+ if (!Array.isArray(workspaces)) {
13
+ return [];
14
+ }
15
+ return workspaces
16
+ .filter(isRecord)
17
+ .filter((w) => typeof w.workspace_id === 'string')
18
+ .map((w) => {
19
+ const wt = isRecord(w.worktree) ? w.worktree : undefined;
20
+ return {
21
+ workspaceId: w.workspace_id,
22
+ label: typeof w.label === 'string' ? w.label : null,
23
+ focused: w.focused === true,
24
+ isLinkedWorktree: wt?.is_linked_worktree === true,
25
+ repoRoot: typeof wt?.repo_root === 'string' ? wt.repo_root : null,
26
+ checkoutPath: typeof wt?.checkout_path === 'string' ? wt.checkout_path : null,
27
+ };
28
+ });
29
+ }