@gaia-ai/addon-herdr 0.6.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.
- package/LICENSE +21 -0
- package/README.md +5 -0
- 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 +111 -0
- package/dist/src/config.d.ts +55 -0
- package/dist/src/config.js +27 -0
- package/dist/src/fs.d.ts +22 -0
- package/dist/src/fs.js +58 -0
- package/dist/src/index.d.ts +172 -0
- package/dist/src/index.js +708 -0
- package/dist/src/pane-layout.d.ts +37 -0
- package/dist/src/pane-layout.js +69 -0
- package/dist/src/panes.d.ts +19 -0
- package/dist/src/panes.js +73 -0
- package/dist/src/preset.d.ts +3 -0
- package/dist/src/preset.js +9 -0
- package/dist/src/root-anchor.d.ts +26 -0
- package/dist/src/root-anchor.js +40 -0
- package/dist/src/workspace.d.ts +85 -0
- package/dist/src/workspace.js +261 -0
- package/package.json +29 -0
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
ADDED
|
@@ -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 (gaia 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
|
+
* `gaia 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 (GAIA-194 AC-3). It used to live in
|
|
2
|
+
// `@gaia-ai/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 `@gaia-ai/addon-gaia-ui`: the renderer owns its own
|
|
6
|
+
// prompt-level `TuiAgentService`, and the conductor `gaia 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,111 @@
|
|
|
1
|
+
import { envArgs, findTabIdByLabel, parsePaneInfo, parsePanesInTab, parseTabCreate, } from './panes.js';
|
|
2
|
+
/** The single tab that holds a group's agent panes. */
|
|
3
|
+
const groupTabLabel = (group) => `gaia-agents:${group}`;
|
|
4
|
+
export function herdrAgentHost(execHerdr) {
|
|
5
|
+
// The caller's home tab, returned to on hide. Captured lazily on the first
|
|
6
|
+
// launch — that is when the caller is focused on its own tab (pressing `n`),
|
|
7
|
+
// and it keeps construction side-effect-free. Best-effort: a probe failure
|
|
8
|
+
// just leaves hide without a home to return to.
|
|
9
|
+
let homeTab = null;
|
|
10
|
+
const captureHome = async () => {
|
|
11
|
+
if (homeTab !== null)
|
|
12
|
+
return;
|
|
13
|
+
try {
|
|
14
|
+
homeTab = parsePaneInfo(await execHerdr(['pane', 'current'])).tabId;
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
// no home tab known; hide() becomes a no-op focus
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
// Cache each group's tab id to skip the `tab list` round-trip once known.
|
|
21
|
+
const tabByGroup = new Map();
|
|
22
|
+
const resolveTab = async (group) => {
|
|
23
|
+
const cached = tabByGroup.get(group);
|
|
24
|
+
if (cached)
|
|
25
|
+
return cached;
|
|
26
|
+
const found = findTabIdByLabel(await execHerdr(['tab', 'list']), groupTabLabel(group));
|
|
27
|
+
if (found)
|
|
28
|
+
tabByGroup.set(group, found);
|
|
29
|
+
return found;
|
|
30
|
+
};
|
|
31
|
+
return {
|
|
32
|
+
async launch({ group, cwd, command, env, label }) {
|
|
33
|
+
await captureHome();
|
|
34
|
+
const e = env ?? {};
|
|
35
|
+
// Find the group's tab and split off a LIVE pane (newest-first; a stale
|
|
36
|
+
// pane — a previous agent died/was closed — is skipped). None survive, or
|
|
37
|
+
// no tab yet → create the group tab (its root pane is the agent).
|
|
38
|
+
let pane = null;
|
|
39
|
+
const tabId = await resolveTab(group);
|
|
40
|
+
if (tabId) {
|
|
41
|
+
const panes = parsePanesInTab(await execHerdr(['pane', 'list']), tabId);
|
|
42
|
+
for (const p of [...panes].reverse()) {
|
|
43
|
+
try {
|
|
44
|
+
pane = parsePaneInfo(await execHerdr([
|
|
45
|
+
'pane',
|
|
46
|
+
'split',
|
|
47
|
+
p.paneId,
|
|
48
|
+
'--direction',
|
|
49
|
+
'right',
|
|
50
|
+
'--no-focus',
|
|
51
|
+
'--cwd',
|
|
52
|
+
cwd,
|
|
53
|
+
...envArgs(e),
|
|
54
|
+
]));
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
// stale pane — try the next
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (!pane)
|
|
62
|
+
tabByGroup.delete(group);
|
|
63
|
+
}
|
|
64
|
+
if (!pane) {
|
|
65
|
+
pane = parseTabCreate(await execHerdr([
|
|
66
|
+
'tab',
|
|
67
|
+
'create',
|
|
68
|
+
'--cwd',
|
|
69
|
+
cwd,
|
|
70
|
+
'--no-focus',
|
|
71
|
+
'--label',
|
|
72
|
+
groupTabLabel(group),
|
|
73
|
+
...envArgs(e),
|
|
74
|
+
]));
|
|
75
|
+
tabByGroup.set(group, pane.tabId);
|
|
76
|
+
}
|
|
77
|
+
await execHerdr(['pane', 'run', pane.paneId, command]);
|
|
78
|
+
const human = label ?? 'agent';
|
|
79
|
+
await execHerdr(['pane', 'rename', pane.paneId, human]).catch(() => { });
|
|
80
|
+
await execHerdr(['pane', 'zoom', pane.paneId, '--on']).catch(() => { });
|
|
81
|
+
return { id: pane.paneId, group, label: human };
|
|
82
|
+
},
|
|
83
|
+
async list(group) {
|
|
84
|
+
const tabId = await resolveTab(group);
|
|
85
|
+
if (!tabId)
|
|
86
|
+
return [];
|
|
87
|
+
return parsePanesInTab(await execHerdr(['pane', 'list']), tabId).map((p) => ({ id: p.paneId, group, label: p.label }));
|
|
88
|
+
},
|
|
89
|
+
async focus(id) {
|
|
90
|
+
await execHerdr(['pane', 'zoom', id, '--on']);
|
|
91
|
+
},
|
|
92
|
+
async hide(group) {
|
|
93
|
+
// Un-zoom the group's panes (so it reads as the tiled overview next time)
|
|
94
|
+
// and return focus to the caller's home tab. The agents keep running.
|
|
95
|
+
const tabId = await resolveTab(group);
|
|
96
|
+
if (tabId) {
|
|
97
|
+
for (const p of parsePanesInTab(await execHerdr(['pane', 'list']), tabId)) {
|
|
98
|
+
await execHerdr(['pane', 'zoom', p.paneId, '--off']).catch(() => { });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (homeTab)
|
|
102
|
+
await execHerdr(['tab', 'focus', homeTab]).catch(() => { });
|
|
103
|
+
},
|
|
104
|
+
async kill(id) {
|
|
105
|
+
await execHerdr(['pane', 'close', id]);
|
|
106
|
+
},
|
|
107
|
+
async sendText(id, text) {
|
|
108
|
+
await execHerdr(['pane', 'send-text', id, text]);
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
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
|
+
/** Extra layout panes (diff/logs); NOT the agent command. */
|
|
17
|
+
panes?: PaneSpec[];
|
|
18
|
+
}
|
|
19
|
+
export interface HerdrExecutorOptions {
|
|
20
|
+
/** Legacy fallback for input.command. */
|
|
21
|
+
command?: string;
|
|
22
|
+
/**
|
|
23
|
+
* Lifecycle-hook commands, keyed like `config.hooks` (after_create,
|
|
24
|
+
* before_run, after_run, after_done). The factory copies `config.hooks` here
|
|
25
|
+
* so the executor — the single best-effort catch point — can run them.
|
|
26
|
+
*/
|
|
27
|
+
hooks?: {
|
|
28
|
+
after_create?: string;
|
|
29
|
+
before_run?: string;
|
|
30
|
+
after_run?: string;
|
|
31
|
+
after_done?: string;
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* The parent repo root (main clone) — herdr `--cwd` target for reattaching a
|
|
35
|
+
* closed worktree. herdr rejects a `worktree open` whose --cwd is a linked
|
|
36
|
+
* worktree (`linked_worktree_source`): open/new must start from the parent
|
|
37
|
+
* workspace. When unset the factory derives it from the git common dir
|
|
38
|
+
* (GAIA-177), exactly like the sibling `herdrWorkspace` plugin; this override
|
|
39
|
+
* always wins.
|
|
40
|
+
*/
|
|
41
|
+
root?: string;
|
|
42
|
+
/** Overrideable git exec for root derivation (tests). */
|
|
43
|
+
execGit?: RootGitExec;
|
|
44
|
+
default?: StateConfig;
|
|
45
|
+
states?: Record<string, StateConfig>;
|
|
46
|
+
}
|
|
47
|
+
export interface ResolvedConfig {
|
|
48
|
+
tabLabel: string;
|
|
49
|
+
workspaceLabel: string;
|
|
50
|
+
panes: PaneSpec[];
|
|
51
|
+
}
|
|
52
|
+
export declare const BUILTIN_DEFAULTS: ResolvedConfig;
|
|
53
|
+
export declare function resolveStateConfig(options: HerdrExecutorOptions, state: string): ResolvedConfig;
|
|
54
|
+
export declare function renderTemplate(tpl: string, vars: Record<string, string>): string;
|
|
55
|
+
export declare function assertTabLabelTemplate(tabLabel: string): void;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export const BUILTIN_DEFAULTS = {
|
|
2
|
+
tabLabel: '{identifier} · {titleSlug}',
|
|
3
|
+
workspaceLabel: '{repoName}',
|
|
4
|
+
panes: [],
|
|
5
|
+
};
|
|
6
|
+
export function resolveStateConfig(options, state) {
|
|
7
|
+
const base = options.default ?? {};
|
|
8
|
+
const stateCfg = options.states?.[state] ?? {};
|
|
9
|
+
return {
|
|
10
|
+
tabLabel: stateCfg.tabLabel ?? base.tabLabel ?? BUILTIN_DEFAULTS.tabLabel,
|
|
11
|
+
workspaceLabel: stateCfg.workspaceLabel ??
|
|
12
|
+
base.workspaceLabel ??
|
|
13
|
+
BUILTIN_DEFAULTS.workspaceLabel,
|
|
14
|
+
panes: stateCfg.panes ?? base.panes ?? BUILTIN_DEFAULTS.panes,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
export function renderTemplate(tpl, vars) {
|
|
18
|
+
return tpl.replace(/\{(\w+)\}/g, (whole, key) => {
|
|
19
|
+
const value = vars[key];
|
|
20
|
+
return value === undefined ? whole : value;
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
export function assertTabLabelTemplate(tabLabel) {
|
|
24
|
+
if (!tabLabel.includes('{identifier}')) {
|
|
25
|
+
throw new Error(`herdr tabLabel must contain {identifier} for stop/state matching: ${tabLabel}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
package/dist/src/fs.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recursively re-add the owner-write bit to a directory tree.
|
|
3
|
+
*
|
|
4
|
+
* A GAIA 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 GAIA 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
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import type { ExecutorCapabilities, ExecutorPlugin, GaiaExecutor, HookContext, HookName, SpawnedSession, SpawnRunInput } from '@gaia-ai/conductor/contract';
|
|
2
|
+
import type { ConductorLogger } from '@gaia-ai/core';
|
|
3
|
+
import { type HerdrExecutorOptions } from './config.js';
|
|
4
|
+
import { type HerdrExec } from './pane-layout.js';
|
|
5
|
+
export type { AgentLaunchHost, AgentLaunchHostProvider, HostedAgent, } from './agent-host.js';
|
|
6
|
+
export { herdrAgentHost } from './agents.js';
|
|
7
|
+
export type { HerdrExec } from './pane-layout.js';
|
|
8
|
+
export { envArgs, findTabIdByLabel, parsePaneInfo, parsePanesInTab, parseTabCreate, } from './panes.js';
|
|
9
|
+
export { HerdrWorkspace, type HerdrWorkspaceOptions, herdrWorkspace, type OpenLayout, } from './workspace.js';
|
|
10
|
+
/** Runs a lifecycle-hook shell command in a cwd; rejects on non-zero exit. */
|
|
11
|
+
export type ShellRunner = (command: string, cwd: string, env?: Record<string, string>) => Promise<void>;
|
|
12
|
+
/** Runs `git <args>` in a cwd; resolves with stdout, rejects on non-zero exit. */
|
|
13
|
+
export type GitRunner = (args: string[], cwd: string) => Promise<string>;
|
|
14
|
+
export declare class HerdrExecutor implements GaiaExecutor {
|
|
15
|
+
private readonly options;
|
|
16
|
+
private readonly execHerdr;
|
|
17
|
+
private readonly logger;
|
|
18
|
+
private readonly runShell;
|
|
19
|
+
private readonly runGit;
|
|
20
|
+
readonly id = "herdr";
|
|
21
|
+
constructor(options: HerdrExecutorOptions, execHerdr?: HerdrExec, logger?: ConductorLogger, runShell?: ShellRunner, runGit?: GitRunner);
|
|
22
|
+
capabilities(): ExecutorCapabilities;
|
|
23
|
+
/**
|
|
24
|
+
* Run the lifecycle hook `name` best-effort — the single catch point for all
|
|
25
|
+
* lifecycle hooks (see {@link GaiaExecutor.runHook}). No configured command →
|
|
26
|
+
* silent no-op. A failing command → log loudly (hook + worktree + ticket +
|
|
27
|
+
* err) and return. NEVER throws, so a hook failure can't abort dispatch or
|
|
28
|
+
* wedge a run.
|
|
29
|
+
*/
|
|
30
|
+
runHook(name: HookName, cwd: string, ctx: HookContext, env?: Record<string, string>): Promise<void>;
|
|
31
|
+
/**
|
|
32
|
+
* List worktrees, ALWAYS anchored to the configured parent repo root
|
|
33
|
+
* (GAIA-211). `herdr worktree list` is machine-global, but run WITHOUT
|
|
34
|
+
* `--cwd` it resolves herdr's own ambient (previously selected / persisted /
|
|
35
|
+
* process / shell / daemon) repository context. When a preceding
|
|
36
|
+
* `gaia-cleanup-*` integration test selected then deleted a
|
|
37
|
+
* `/tmp/gaia-cleanup-*` repo, that stale context survives and the unanchored
|
|
38
|
+
* list exits (`cannot change to '/tmp/gaia-cleanup-…'`), aborting dispatch.
|
|
39
|
+
* Anchoring every list with `--cwd this.options.root` makes listing
|
|
40
|
+
* independent of that ambient context. This is the SINGLE choke point for
|
|
41
|
+
* all executor `worktree list` call paths — the sibling
|
|
42
|
+
* {@link HerdrWorkspace.ensure} anchors identically. The `--cwd` is only
|
|
43
|
+
* omitted when no root is configured (rootless test callers).
|
|
44
|
+
*
|
|
45
|
+
* On a herdr failure it throws an error naming the explicit configured root
|
|
46
|
+
* and the operation (AC-9) instead of surfacing herdr's raw stale-context
|
|
47
|
+
* message, so a future context failure is diagnosable.
|
|
48
|
+
*/
|
|
49
|
+
private listWorktrees;
|
|
50
|
+
/**
|
|
51
|
+
* Find the worktree entry (path + open_workspace_id) for a branch via
|
|
52
|
+
* worktree list. Returns null if the branch has no worktree.
|
|
53
|
+
*/
|
|
54
|
+
private findWorktreeByBranch;
|
|
55
|
+
/**
|
|
56
|
+
* Find the open_workspace_id for a branch via worktree list.
|
|
57
|
+
* Returns null if no workspace is open for that branch.
|
|
58
|
+
*/
|
|
59
|
+
private findWorkspaceByBranch;
|
|
60
|
+
/**
|
|
61
|
+
* Find the open_workspace_id for a worktree by its stable `worktreePath`
|
|
62
|
+
* (falling back to `branch`; see {@link resolveWorktreeEntry}). Unlike
|
|
63
|
+
* {@link findWorkspaceByBranch}, this does not rely on the mutable branch —
|
|
64
|
+
* it is the startRun lookup the GAIA-114 fix hangs on. Returns null when the
|
|
65
|
+
* worktree is not listed as open.
|
|
66
|
+
*/
|
|
67
|
+
private findOpenWorkspace;
|
|
68
|
+
/** Parent repo root for git worktree admin ops (falls back to `fallback`). */
|
|
69
|
+
private gitRoot;
|
|
70
|
+
/**
|
|
71
|
+
* Sweep orphaned `.git/worktrees/<name>` admin entries at the parent repo
|
|
72
|
+
* root — the ones whose on-disk dir has vanished (GAIA-141: they show up as
|
|
73
|
+
* `prunable` in `git worktree list`). Best-effort: a prune failure must never
|
|
74
|
+
* fail teardown.
|
|
75
|
+
*/
|
|
76
|
+
private pruneWorktrees;
|
|
77
|
+
/**
|
|
78
|
+
* Remove an on-disk worktree GIT-AWARELY (GAIA-141 RC-1). The old fallback
|
|
79
|
+
* raw-`rm`'d the directory and left the parent repo's `.git/worktrees/<name>`
|
|
80
|
+
* admin entry behind → a `prunable` orphan. This:
|
|
81
|
+
* 1. restores owner-write (Drupal hardens web/sites/default 0555 +
|
|
82
|
+
* settings.php 0444 — chmod-restorable by the owning user, no sudo);
|
|
83
|
+
* 2. `git worktree remove --force <path>` from the PARENT repo root, which
|
|
84
|
+
* unlinks the directory AND drops the `.git/worktrees/<name>` entry;
|
|
85
|
+
* 3. guarantees the directory is gone (a raw force-remove) in case git
|
|
86
|
+
* refused (the path was never a registered worktree) — a no-op if git
|
|
87
|
+
* already removed it;
|
|
88
|
+
* 4. prunes the repo so any now-stale admin entry is swept.
|
|
89
|
+
*/
|
|
90
|
+
private gitAwareRemove;
|
|
91
|
+
/**
|
|
92
|
+
* When herdr has lost track of a worktree and its `worktreePath` was never
|
|
93
|
+
* persisted, the PARENT repo's `git worktree list` still knows it (GAIA-141
|
|
94
|
+
* RC-2 — the ~28 never-touched leftovers). Resolve the lost worktree's on-disk
|
|
95
|
+
* path there, preferring an exact `worktreePath` match, then a branch match;
|
|
96
|
+
* never the main worktree (the repo root itself). Returns null when nothing
|
|
97
|
+
* matches. Best-effort: a git failure resolves to null.
|
|
98
|
+
*/
|
|
99
|
+
private findGitWorktree;
|
|
100
|
+
/**
|
|
101
|
+
* GAIA-166 focus guard. herdr's `worktree remove` deletes the ticket
|
|
102
|
+
* worktree's on-disk directory; if that workspace is the CURRENTLY FOCUSED
|
|
103
|
+
* one, the interactive client is left in a now-deleted `cwd` and herdr spawns
|
|
104
|
+
* a stray empty workspace for the dead path. So before the remove, if the
|
|
105
|
+
* target workspace is focused, switch focus to the main-checkout (non-linked)
|
|
106
|
+
* workspace of the SAME repo. A background reap of a non-focused worktree
|
|
107
|
+
* (or one herdr no longer lists) focuses nothing.
|
|
108
|
+
*
|
|
109
|
+
* Returns whether it is SAFE to proceed with the remove: `false` when a
|
|
110
|
+
* required focus switch cannot be resolved or verified — the caller then
|
|
111
|
+
* aborts (returns `false` → `cleaned_up=0`) so the reaper retries later,
|
|
112
|
+
* rather than deleting the focused path out from under the user.
|
|
113
|
+
*/
|
|
114
|
+
private ensureFocusSafeToRemove;
|
|
115
|
+
/**
|
|
116
|
+
* Tear down a ticket's entire worktree (git worktree + hosted workspace).
|
|
117
|
+
* `herdr worktree remove` is keyed by an open workspace id, so resolve the
|
|
118
|
+
* worktree first — by its stable `worktreePath`, falling back to `branch`
|
|
119
|
+
* (see {@link resolveWorktreeEntry}): if its workspace is already open use
|
|
120
|
+
* that id; otherwise open the on-disk worktree to obtain one (mirrors
|
|
121
|
+
* startRun's open-if-needed).
|
|
122
|
+
*
|
|
123
|
+
* `herdr worktree list` is machine-global — it enumerates every repo's
|
|
124
|
+
* worktrees on the host, most of which are not ours. So a no-match does NOT
|
|
125
|
+
* mean "error"; it means herdr no longer tracks this ticket's worktree. When
|
|
126
|
+
* herdr has lost track, teardown is resolved off git itself (never a foreign
|
|
127
|
+
* worktree), git-awarely so no `.git/worktrees/<name>` metadata is orphaned
|
|
128
|
+
* (GAIA-141 RC-1):
|
|
129
|
+
* - `worktreePath` still on disk → reclaim it git-awarely;
|
|
130
|
+
* - `worktreePath` known but gone from disk → already torn down; sweep any
|
|
131
|
+
* orphaned metadata and report verified;
|
|
132
|
+
* - `worktreePath` unknown → resolve the lost worktree via the parent repo's
|
|
133
|
+
* `git worktree list` (GAIA-141 RC-2) and reclaim it git-awarely.
|
|
134
|
+
*
|
|
135
|
+
* Returns whether the teardown was VERIFIED on THIS host: `true` when the
|
|
136
|
+
* worktree is gone (removed by us, an on-disk orphan reclaimed, or confirmed
|
|
137
|
+
* already absent), `false` ONLY when nothing was resolvable and the teardown
|
|
138
|
+
* could not be verified — the reaper then leaves the ticket on its work list
|
|
139
|
+
* for a later retry instead of falsely flagging it cleaned (GAIA-141 RC-2).
|
|
140
|
+
*/
|
|
141
|
+
removeWorktree(branch: string, worktreePath?: string): Promise<boolean>;
|
|
142
|
+
startRun(input: SpawnRunInput): Promise<SpawnedSession>;
|
|
143
|
+
/** Best-effort cleanup: close only the just-created tab. Swallows errors. */
|
|
144
|
+
private rollback;
|
|
145
|
+
/**
|
|
146
|
+
* No-op: at finalise time the agent is idle and its transcript is already on
|
|
147
|
+
* disk; the {@link cleanupRun} tab-close kills the PTY, so the agent dies
|
|
148
|
+
* implicitly. Kept to satisfy the run lifecycle seam.
|
|
149
|
+
*/
|
|
150
|
+
stopRun(_branch: string): Promise<void>;
|
|
151
|
+
/**
|
|
152
|
+
* Close ONLY the branch-workspace tab whose label carries the `#<runId>`
|
|
153
|
+
* token (`herdr tab close <tab_id>`), killing that PTY (GAIA-183). The run
|
|
154
|
+
* identity lives in the tab label, so teardown is run-scoped: a sibling run's
|
|
155
|
+
* tab and any non-run tab in the same workspace are left untouched, and a
|
|
156
|
+
* missing match is a quiet no-op. Best-effort: a failing close is swallowed.
|
|
157
|
+
* No `/exit`/C-c and no `(done)` rename — the matched tab simply goes away.
|
|
158
|
+
* The branch worktree is untouched (that is the reap/{@link removeWorktree}
|
|
159
|
+
* lifecycle).
|
|
160
|
+
*/
|
|
161
|
+
cleanupRun(branch: string, runId: number): Promise<void>;
|
|
162
|
+
private commandFor;
|
|
163
|
+
/**
|
|
164
|
+
* The pane-run command with every env VALUE masked (key names kept). Used
|
|
165
|
+
* only for the exec log so a failed `pane run` never carries a secret DSN —
|
|
166
|
+
* consistent with the conductor's key-name-only env logging (GAIA-99).
|
|
167
|
+
*/
|
|
168
|
+
private redactedCommandFor;
|
|
169
|
+
private assembleCommand;
|
|
170
|
+
}
|
|
171
|
+
export declare function herdrExecutor(options?: HerdrExecutorOptions): ExecutorPlugin;
|
|
172
|
+
export default herdrExecutor;
|