@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.
- package/LICENSE +21 -0
- package/README.md +4 -2
- package/dist/src/agent-host.d.ts +45 -0
- package/dist/src/agent-host.js +9 -0
- package/dist/src/agents.d.ts +3 -0
- package/dist/src/agents.js +132 -0
- package/dist/src/config.d.ts +77 -0
- package/dist/src/config.js +29 -0
- package/dist/src/frozen-marker.d.ts +8 -0
- package/dist/src/frozen-marker.js +26 -0
- package/dist/src/fs.d.ts +22 -0
- package/dist/src/fs.js +58 -0
- package/dist/src/index.d.ts +219 -0
- package/dist/src/index.js +809 -0
- package/dist/src/launch-script.d.ts +34 -0
- package/dist/src/launch-script.js +98 -0
- package/dist/src/pane-layout.d.ts +45 -0
- package/dist/src/pane-layout.js +78 -0
- package/dist/src/panes.d.ts +31 -0
- package/dist/src/panes.js +87 -0
- package/dist/src/preset.d.ts +6 -0
- package/dist/src/preset.js +18 -0
- package/dist/src/process-host.d.ts +6 -0
- package/dist/src/process-host.js +46 -0
- package/dist/src/root-anchor.d.ts +13 -0
- package/dist/src/root-anchor.js +12 -0
- package/dist/src/workspace-list.d.ts +19 -0
- package/dist/src/workspace-list.js +29 -0
- package/dist/src/workspace.d.ts +185 -0
- package/dist/src/workspace.js +415 -0
- package/package.json +27 -4
|
@@ -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
|
+
}
|