@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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 keytec GmbH
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,5 @@
1
- # Temporary Holding Version
1
+ # @bakers-build/addon-herdr
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ bakers.build herdr integration: spawn executor + per-ticket worktree workspace.
4
+
5
+ Part of the bakers.build CLI. Install the meta package `@bakers-build/bakers` to get the `bakers` CLI with all addons. Source: https://git.key-tec.de/bakers-build/bakers-build (bakers-cli/).
@@ -0,0 +1,45 @@
1
+ /** A hosted agent as reported by the host. `id` is the host-owned stable handle
2
+ * (survives a TUI restart); `group` is the opaque grouping key it was launched
3
+ * under; `label` is a human tag for the list. */
4
+ export interface HostedAgent {
5
+ id: string;
6
+ group: string;
7
+ label: string;
8
+ }
9
+ /**
10
+ * The INTENT-level agent host herdr provides. The caller says "launch an agent
11
+ * in this group", "show that agent", "list this group" — it never names panes,
12
+ * tabs, splits or zoom. The host (herdr) owns the whole layout policy. `group`
13
+ * is OPAQUE (bakers passes the project slug). `command` is the resolved shell
14
+ * command the agent runs (the renderer's prompt is turned into a command by the
15
+ * `bakers ui` entry before it reaches here — herdr stays agent-binary-agnostic).
16
+ */
17
+ export interface AgentLaunchHost {
18
+ /**
19
+ * Start an agent and return its stable handle. The host decides where the
20
+ * session is placed and shows it; the caller only supplies intent + a group.
21
+ */
22
+ launch(opts: {
23
+ group: string;
24
+ cwd: string;
25
+ command: string;
26
+ env?: Record<string, string | undefined> | undefined;
27
+ label?: string | undefined;
28
+ }): Promise<HostedAgent>;
29
+ /** The live agents of a group — the source of truth for the list, rebuilt
30
+ * from the host on demand so sessions survive a TUI restart. */
31
+ list(group: string): Promise<HostedAgent[]>;
32
+ /** Bring one agent to the foreground (the host zooms/switches as it sees fit). */
33
+ focus(id: string): Promise<void>;
34
+ /** Stop showing a group's agents and return focus to the caller. Durable —
35
+ * the agents keep running; this never terminates them. */
36
+ hide(group: string): Promise<void>;
37
+ /** Terminate one agent for good. */
38
+ kill(id: string): Promise<void>;
39
+ /** Send literal text to an agent's input. */
40
+ sendText(id: string, text: string): Promise<void>;
41
+ }
42
+ /** A workspace (or other plugin) that can host agents at the intent level. */
43
+ export interface AgentLaunchHostProvider {
44
+ agentHost: AgentLaunchHost;
45
+ }
@@ -0,0 +1,9 @@
1
+ // Herdr's own agent-host contract (BB-194 AC-3). It used to live in
2
+ // `@bakers-build/core` as `AgentLaunchHost`/`HostedAgent`, but the agent-host seam is
3
+ // a TUI/renderer concern, not a conductor-engine one — so core dropped it and
4
+ // herdr types its impl LOCALLY. This interface imports nothing removed and
5
+ // nothing from `@bakers-build/addon-bakers-ui`: the renderer owns its own
6
+ // prompt-level `TuiAgentService`, and the conductor `bakers ui` entry adapts this
7
+ // mechanism-level (`command`-based) host to it. Structurally compatible, no
8
+ // cross-package type coupling.
9
+ export {};
@@ -0,0 +1,3 @@
1
+ import type { AgentLaunchHost } from './agent-host.js';
2
+ import type { HerdrExec } from './pane-layout.js';
3
+ export declare function herdrAgentHost(execHerdr: HerdrExec): AgentLaunchHost;
@@ -0,0 +1,132 @@
1
+ // Herdr-backed AgentLaunchHost (BB-190) — the INTENT-level agent host. It owns
2
+ // the whole layout policy the bakers-ui plugin used to drive from the conductor:
3
+ // one durable herdr tab per group, a new agent splits off a live pane, the shown
4
+ // agent is zoomed, hiding un-zooms and returns to the caller. The caller speaks
5
+ // only launch/list/focus/hide/kill/sendText — never panes, tabs or zoom.
6
+ //
7
+ // `group` is opaque (bakers passes a project slug); it maps to one tab labelled
8
+ // `bakers-agents:<group>`, so a group's agents are listed + hidden together and
9
+ // survive a TUI restart (rediscovered from the live tab).
10
+ import { rmSync } from 'node:fs';
11
+ import { writeLaunchScript } from './launch-script.js';
12
+ import { AGENT_SHELL_ENV, envArgs, findTabIdByLabel, parsePaneInfo, parsePanesInTab, parseTabCreate, } from './panes.js';
13
+ /** The single tab that holds a group's agent panes. */
14
+ const groupTabLabel = (group) => `bakers-agents:${group}`;
15
+ export function herdrAgentHost(execHerdr) {
16
+ // The caller's home tab, returned to on hide. Captured lazily on the first
17
+ // launch — that is when the caller is focused on its own tab (pressing `n`),
18
+ // and it keeps construction side-effect-free. Best-effort: a probe failure
19
+ // just leaves hide without a home to return to.
20
+ let homeTab = null;
21
+ const captureHome = async () => {
22
+ if (homeTab !== null)
23
+ return;
24
+ try {
25
+ homeTab = parsePaneInfo(await execHerdr(['pane', 'current'])).tabId;
26
+ }
27
+ catch {
28
+ // no home tab known; hide() becomes a no-op focus
29
+ }
30
+ };
31
+ // Cache each group's tab id to skip the `tab list` round-trip once known.
32
+ const tabByGroup = new Map();
33
+ const resolveTab = async (group) => {
34
+ const cached = tabByGroup.get(group);
35
+ if (cached)
36
+ return cached;
37
+ const found = findTabIdByLabel(await execHerdr(['tab', 'list']), groupTabLabel(group));
38
+ if (found)
39
+ tabByGroup.set(group, found);
40
+ return found;
41
+ };
42
+ return {
43
+ async launch({ group, cwd, command, env, label }) {
44
+ await captureHome();
45
+ // Every pane this host opens holds an agent, so all of them carry the
46
+ // agent-shell marker (BB-64) — this is the pane a `bakers ui` user actually
47
+ // attaches to. The caller's own env wins on a key clash; the marker is
48
+ // merged in, never substituted for it.
49
+ const e = { ...AGENT_SHELL_ENV, ...(env ?? {}) };
50
+ // Find the group's tab and split off a LIVE pane (newest-first; a stale
51
+ // pane — a previous agent died/was closed — is skipped). None survive, or
52
+ // no tab yet → create the group tab (its root pane is the agent).
53
+ let pane = null;
54
+ const tabId = await resolveTab(group);
55
+ if (tabId) {
56
+ const panes = parsePanesInTab(await execHerdr(['pane', 'list']), tabId);
57
+ for (const p of [...panes].reverse()) {
58
+ try {
59
+ pane = parsePaneInfo(await execHerdr([
60
+ 'pane',
61
+ 'split',
62
+ p.paneId,
63
+ '--direction',
64
+ 'right',
65
+ '--no-focus',
66
+ '--cwd',
67
+ cwd,
68
+ ...envArgs(e),
69
+ ]));
70
+ break;
71
+ }
72
+ catch {
73
+ // stale pane — try the next
74
+ }
75
+ }
76
+ if (!pane)
77
+ tabByGroup.delete(group);
78
+ }
79
+ if (!pane) {
80
+ pane = parseTabCreate(await execHerdr([
81
+ 'tab',
82
+ 'create',
83
+ '--cwd',
84
+ cwd,
85
+ '--no-focus',
86
+ '--label',
87
+ groupTabLabel(group),
88
+ ...envArgs(e),
89
+ ]));
90
+ tabByGroup.set(group, pane.tabId);
91
+ }
92
+ const launch = writeLaunchScript(command);
93
+ // ponytail: on success the wrapper's started/exit markers stay in tmp
94
+ // (no secrets); a sweep is only worth it if they ever pile up.
95
+ await execHerdr(['pane', 'run', pane.paneId, launch.typedCommand]).catch((err) => {
96
+ rmSync(launch.dir, { recursive: true, force: true });
97
+ throw err;
98
+ });
99
+ const human = label ?? 'agent';
100
+ await execHerdr(['pane', 'rename', pane.paneId, human]).catch(() => { });
101
+ await execHerdr(['pane', 'zoom', pane.paneId, '--on']).catch(() => { });
102
+ return { id: pane.paneId, group, label: human };
103
+ },
104
+ async list(group) {
105
+ const tabId = await resolveTab(group);
106
+ if (!tabId)
107
+ return [];
108
+ return parsePanesInTab(await execHerdr(['pane', 'list']), tabId).map((p) => ({ id: p.paneId, group, label: p.label }));
109
+ },
110
+ async focus(id) {
111
+ await execHerdr(['pane', 'zoom', id, '--on']);
112
+ },
113
+ async hide(group) {
114
+ // Un-zoom the group's panes (so it reads as the tiled overview next time)
115
+ // and return focus to the caller's home tab. The agents keep running.
116
+ const tabId = await resolveTab(group);
117
+ if (tabId) {
118
+ for (const p of parsePanesInTab(await execHerdr(['pane', 'list']), tabId)) {
119
+ await execHerdr(['pane', 'zoom', p.paneId, '--off']).catch(() => { });
120
+ }
121
+ }
122
+ if (homeTab)
123
+ await execHerdr(['tab', 'focus', homeTab]).catch(() => { });
124
+ },
125
+ async kill(id) {
126
+ await execHerdr(['pane', 'close', id]);
127
+ },
128
+ async sendText(id, text) {
129
+ await execHerdr(['pane', 'send-text', id, text]);
130
+ },
131
+ };
132
+ }
@@ -0,0 +1,77 @@
1
+ import type { RootGitExec } from './root-anchor.js';
2
+ export interface PaneSpec {
3
+ direction: 'right' | 'down';
4
+ command: string;
5
+ focus?: boolean;
6
+ }
7
+ export interface StateConfig {
8
+ /**
9
+ * Tab label template. Must contain {identifier} — the conductor matches a
10
+ * run's tab by its ticket identifier (stop/state), so the identifier is the
11
+ * stable anchor in the visible name. Other vars: {title}, {titleSlug},
12
+ * {runUuid}, {repoName}, {workspacePath}.
13
+ */
14
+ tabLabel?: string;
15
+ workspaceLabel?: string;
16
+ /**
17
+ * Root-pane (panel) label template applied via `herdr pane rename` after the
18
+ * state tab is created (BB-374). Default `{title}`. Distinct from
19
+ * {@link tabLabel}: stop/state matching still anchors on the tab label.
20
+ */
21
+ panelLabel?: string;
22
+ /** Extra layout panes (diff/logs); NOT the agent command. */
23
+ panes?: PaneSpec[];
24
+ }
25
+ export interface HerdrExecutorOptions {
26
+ /** Legacy fallback for input.command. */
27
+ command?: string;
28
+ /**
29
+ * Lifecycle-hook commands, keyed like `config.hooks` (after_create,
30
+ * before_run, after_run, after_done, on_freeze, on_resume). The factory copies `config.hooks` here
31
+ * so the executor — the single best-effort catch point — can run them.
32
+ */
33
+ hooks?: {
34
+ after_create?: string;
35
+ before_run?: string;
36
+ after_run?: string;
37
+ after_done?: string;
38
+ on_freeze?: string;
39
+ on_resume?: string;
40
+ };
41
+ /**
42
+ * The parent repo root (main clone) — herdr `--cwd` target for reattaching a
43
+ * closed worktree. herdr rejects a `worktree open` whose --cwd is a linked
44
+ * worktree (`linked_worktree_source`): open/new must start from the parent
45
+ * workspace. When unset the factory derives it from the git common dir
46
+ * (BB-177), exactly like the sibling `herdrWorkspace` plugin; this override
47
+ * always wins.
48
+ */
49
+ root?: string;
50
+ /** Overrideable git exec for root derivation (tests). */
51
+ execGit?: RootGitExec;
52
+ default?: StateConfig;
53
+ states?: Record<string, StateConfig>;
54
+ /**
55
+ * Launch-watch windows. `startTimeoutMs` is how long startRun waits for the
56
+ * wrapper to write `started` (default 60s, so a slow interactive shell can
57
+ * finish rc). `timeoutMs` is the short window after `started` in which an
58
+ * immediate agent exit fails the launch (default 4000 ms). Tests inject
59
+ * shorter windows, including `timeoutMs: 0` to skip the exit wait.
60
+ */
61
+ launchWatch?: {
62
+ timeoutMs?: number;
63
+ startTimeoutMs?: number;
64
+ pollMs?: number;
65
+ sleep?: (ms: number) => Promise<void>;
66
+ };
67
+ }
68
+ export interface ResolvedConfig {
69
+ tabLabel: string;
70
+ workspaceLabel: string;
71
+ panelLabel: string;
72
+ panes: PaneSpec[];
73
+ }
74
+ export declare const BUILTIN_DEFAULTS: ResolvedConfig;
75
+ export declare function resolveStateConfig(options: HerdrExecutorOptions, state: string): ResolvedConfig;
76
+ export declare function renderTemplate(tpl: string, vars: Record<string, string>): string;
77
+ export declare function assertTabLabelTemplate(tabLabel: string): void;
@@ -0,0 +1,29 @@
1
+ export const BUILTIN_DEFAULTS = {
2
+ tabLabel: '{identifier} · {titleSlug}',
3
+ workspaceLabel: '{repoName}',
4
+ panelLabel: '{title}',
5
+ panes: [],
6
+ };
7
+ export function resolveStateConfig(options, state) {
8
+ const base = options.default ?? {};
9
+ const stateCfg = options.states?.[state] ?? {};
10
+ return {
11
+ tabLabel: stateCfg.tabLabel ?? base.tabLabel ?? BUILTIN_DEFAULTS.tabLabel,
12
+ workspaceLabel: stateCfg.workspaceLabel ??
13
+ base.workspaceLabel ??
14
+ BUILTIN_DEFAULTS.workspaceLabel,
15
+ panelLabel: stateCfg.panelLabel ?? base.panelLabel ?? BUILTIN_DEFAULTS.panelLabel,
16
+ panes: stateCfg.panes ?? base.panes ?? BUILTIN_DEFAULTS.panes,
17
+ };
18
+ }
19
+ export function renderTemplate(tpl, vars) {
20
+ return tpl.replace(/\{(\w+)\}/g, (whole, key) => {
21
+ const value = vars[key];
22
+ return value === undefined ? whole : value;
23
+ });
24
+ }
25
+ export function assertTabLabelTemplate(tabLabel) {
26
+ if (!tabLabel.includes('{identifier}')) {
27
+ throw new Error(`herdr tabLabel must contain {identifier} for stop/state matching: ${tabLabel}`);
28
+ }
29
+ }
@@ -0,0 +1,8 @@
1
+ import type { HookListener } from '@bakers-build/conductor/contract';
2
+ /** The sidebar token; operators show it with `$bb_frozen` in a row. */
3
+ export declare const FROZEN_TOKEN = "bb_frozen";
4
+ /**
5
+ * Marks a frozen ticket's workspace in herdr's sidebar with display-only
6
+ * metadata. A rename is avoided on purpose: run teardown matches labels.
7
+ */
8
+ export declare const herdrFrozenMarker: HookListener;
@@ -0,0 +1,26 @@
1
+ import { exec } from '@bakers-build/core';
2
+ import { parseWorkspaceList } from './workspace-list.js';
3
+ /** The sidebar token; operators show it with `$bb_frozen` in a row. */
4
+ export const FROZEN_TOKEN = 'bb_frozen';
5
+ async function reportOnWorktree(ctx, token) {
6
+ const workspace = parseWorkspaceList(await exec('herdr', ['workspace', 'list'])).find((w) => w.checkoutPath === ctx.worktreePath);
7
+ if (!workspace)
8
+ return;
9
+ await exec('herdr', [
10
+ 'workspace',
11
+ 'report-metadata',
12
+ workspace.workspaceId,
13
+ '--source',
14
+ 'bakers',
15
+ ...token,
16
+ ]);
17
+ }
18
+ /**
19
+ * Marks a frozen ticket's workspace in herdr's sidebar with display-only
20
+ * metadata. A rename is avoided on purpose: run teardown matches labels.
21
+ */
22
+ export const herdrFrozenMarker = {
23
+ id: 'herdr-frozen-marker',
24
+ on_freeze: (ctx) => reportOnWorktree(ctx, ['--token', `${FROZEN_TOKEN}=frozen`]),
25
+ on_resume: (ctx) => reportOnWorktree(ctx, ['--clear-token', FROZEN_TOKEN]),
26
+ };
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Recursively re-add the owner-write bit to a directory tree.
3
+ *
4
+ * A bakers.build worktree is a Drupal install, and Drupal hardens `web/sites/default`
5
+ * to `0555` and `settings.php` / `files/.htaccess` to `0444`. A directory
6
+ * without its write bit cannot have its entries unlinked, so removing the
7
+ * worktree (`git worktree remove --force` / `rm -rf`, however herdr does it)
8
+ * gets EACCES. Restoring `u+w` on every dir/file first is what makes removal
9
+ * succeed — this is not an ownership problem, only the permission bits.
10
+ *
11
+ * Best-effort per entry: a single un-chmod-able entry (already gone, foreign
12
+ * owner) must not abort the walk. Symlinks are never followed — chmod would
13
+ * touch the link target, which may live outside the tree.
14
+ */
15
+ export declare function restoreWritable(path: string): void;
16
+ /**
17
+ * Remove a directory tree that may be hardened read-only by Drupal:
18
+ * {@link restoreWritable} first, then `rm -rf`. Used where the conductor owns
19
+ * the unlink; when herdr performs the removal, call {@link restoreWritable}
20
+ * alone beforehand. A missing path is a no-op.
21
+ */
22
+ export declare function forceRemoveDir(path: string): void;
package/dist/src/fs.js ADDED
@@ -0,0 +1,58 @@
1
+ import { chmodSync, lstatSync, readdirSync, rmSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ /**
4
+ * Recursively re-add the owner-write bit to a directory tree.
5
+ *
6
+ * A bakers.build worktree is a Drupal install, and Drupal hardens `web/sites/default`
7
+ * to `0555` and `settings.php` / `files/.htaccess` to `0444`. A directory
8
+ * without its write bit cannot have its entries unlinked, so removing the
9
+ * worktree (`git worktree remove --force` / `rm -rf`, however herdr does it)
10
+ * gets EACCES. Restoring `u+w` on every dir/file first is what makes removal
11
+ * succeed — this is not an ownership problem, only the permission bits.
12
+ *
13
+ * Best-effort per entry: a single un-chmod-able entry (already gone, foreign
14
+ * owner) must not abort the walk. Symlinks are never followed — chmod would
15
+ * touch the link target, which may live outside the tree.
16
+ */
17
+ export function restoreWritable(path) {
18
+ let stat;
19
+ try {
20
+ stat = lstatSync(path);
21
+ }
22
+ catch {
23
+ return; // gone / inaccessible — nothing to restore
24
+ }
25
+ if (stat.isSymbolicLink()) {
26
+ return; // don't follow: the target lives elsewhere
27
+ }
28
+ try {
29
+ // Add owner-write, preserving every other bit (read/execute stay intact so
30
+ // a directory remains traversable and readable for the recursion below).
31
+ chmodSync(path, stat.mode | 0o200);
32
+ }
33
+ catch {
34
+ // best-effort: one un-chmod-able entry must not abort the walk
35
+ }
36
+ if (stat.isDirectory()) {
37
+ let entries;
38
+ try {
39
+ entries = readdirSync(path);
40
+ }
41
+ catch {
42
+ return;
43
+ }
44
+ for (const entry of entries) {
45
+ restoreWritable(join(path, entry));
46
+ }
47
+ }
48
+ }
49
+ /**
50
+ * Remove a directory tree that may be hardened read-only by Drupal:
51
+ * {@link restoreWritable} first, then `rm -rf`. Used where the conductor owns
52
+ * the unlink; when herdr performs the removal, call {@link restoreWritable}
53
+ * alone beforehand. A missing path is a no-op.
54
+ */
55
+ export function forceRemoveDir(path) {
56
+ restoreWritable(path);
57
+ rmSync(path, { recursive: true, force: true });
58
+ }