@phnx-labs/agents-cli 1.20.61 → 1.20.63
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/CHANGELOG.md +57 -0
- package/README.md +10 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/browser.js +13 -3
- package/dist/commands/exec.js +51 -1
- package/dist/commands/funnel.d.ts +5 -0
- package/dist/commands/funnel.js +62 -0
- package/dist/commands/hosts.js +42 -0
- package/dist/commands/repo.d.ts +4 -4
- package/dist/commands/repo.js +30 -19
- package/dist/commands/routines.js +73 -16
- package/dist/commands/sessions-sync.d.ts +1 -0
- package/dist/commands/sessions-sync.js +16 -2
- package/dist/commands/sessions.js +8 -1
- package/dist/commands/setup.js +9 -0
- package/dist/commands/ssh.js +72 -2
- package/dist/commands/sync-provision.d.ts +23 -0
- package/dist/commands/sync-provision.js +107 -0
- package/dist/commands/webhook.d.ts +9 -0
- package/dist/commands/webhook.js +93 -0
- package/dist/index.js +6 -2
- package/dist/lib/agents.d.ts +26 -0
- package/dist/lib/agents.js +100 -28
- package/dist/lib/browser/ipc.js +5 -4
- package/dist/lib/browser/profiles.d.ts +13 -0
- package/dist/lib/browser/profiles.js +17 -0
- package/dist/lib/browser/service.d.ts +12 -1
- package/dist/lib/browser/service.js +48 -13
- package/dist/lib/browser/sessions-list.d.ts +40 -0
- package/dist/lib/browser/sessions-list.js +190 -0
- package/dist/lib/commands.js +29 -0
- package/dist/lib/convert.d.ts +11 -0
- package/dist/lib/convert.js +22 -0
- package/dist/lib/daemon.js +2 -0
- package/dist/lib/devices/fleet.d.ts +62 -0
- package/dist/lib/devices/fleet.js +128 -0
- package/dist/lib/doctor-diff.js +17 -1
- package/dist/lib/funnel.d.ts +5 -0
- package/dist/lib/funnel.js +23 -0
- package/dist/lib/git.d.ts +21 -5
- package/dist/lib/git.js +64 -14
- package/dist/lib/goose-commands.d.ts +41 -0
- package/dist/lib/goose-commands.js +176 -0
- package/dist/lib/hooks.js +134 -0
- package/dist/lib/hosts/credentials.d.ts +28 -0
- package/dist/lib/hosts/credentials.js +48 -0
- package/dist/lib/hosts/dispatch.d.ts +25 -0
- package/dist/lib/hosts/dispatch.js +68 -2
- package/dist/lib/hosts/passthrough.d.ts +13 -10
- package/dist/lib/hosts/passthrough.js +119 -29
- package/dist/lib/mailbox-gc.js +4 -16
- package/dist/lib/migrate.d.ts +12 -0
- package/dist/lib/migrate.js +55 -1
- package/dist/lib/permissions.d.ts +15 -0
- package/dist/lib/permissions.js +99 -0
- package/dist/lib/plugins.d.ts +20 -0
- package/dist/lib/plugins.js +118 -0
- package/dist/lib/resources/permissions.js +2 -0
- package/dist/lib/routines.d.ts +29 -10
- package/dist/lib/routines.js +47 -15
- package/dist/lib/session/active.d.ts +8 -1
- package/dist/lib/session/active.js +1 -0
- package/dist/lib/session/parse.js +12 -0
- package/dist/lib/session/state.d.ts +33 -0
- package/dist/lib/session/state.js +40 -0
- package/dist/lib/session/sync/agents.d.ts +2 -0
- package/dist/lib/session/sync/agents.js +39 -1
- package/dist/lib/session/sync/config.d.ts +8 -0
- package/dist/lib/session/sync/config.js +6 -1
- package/dist/lib/session/sync/provision.d.ts +49 -0
- package/dist/lib/session/sync/provision.js +91 -0
- package/dist/lib/session/sync/sync.d.ts +3 -0
- package/dist/lib/session/sync/sync.js +26 -6
- package/dist/lib/session/sync/transcript-crypto.d.ts +77 -0
- package/dist/lib/session/sync/transcript-crypto.js +147 -0
- package/dist/lib/staleness/detectors/permissions.js +23 -0
- package/dist/lib/staleness/detectors/subagents.js +36 -0
- package/dist/lib/staleness/writers/commands.js +7 -0
- package/dist/lib/staleness/writers/hooks.js +1 -1
- package/dist/lib/staleness/writers/subagents.js +22 -3
- package/dist/lib/startup/command-registry.d.ts +2 -0
- package/dist/lib/startup/command-registry.js +11 -0
- package/dist/lib/state.d.ts +10 -2
- package/dist/lib/state.js +14 -2
- package/dist/lib/subagents.d.ts +32 -0
- package/dist/lib/subagents.js +238 -0
- package/dist/lib/triggers/webhook.d.ts +70 -27
- package/dist/lib/triggers/webhook.js +264 -43
- package/package.json +1 -1
package/dist/lib/git.js
CHANGED
|
@@ -366,23 +366,67 @@ export async function checkGitHubRepoExists(owner, repo) {
|
|
|
366
366
|
}
|
|
367
367
|
}
|
|
368
368
|
/**
|
|
369
|
-
* Commit and push
|
|
369
|
+
* Commit (if dirty) and push a repo.
|
|
370
|
+
*
|
|
371
|
+
* Clean tree + local ahead of origin still pushes — "nothing to commit" is not
|
|
372
|
+
* "nothing to push". Reports "already up to date" only when `ahead === 0` and
|
|
373
|
+
* there is nothing to commit.
|
|
370
374
|
*/
|
|
371
375
|
export async function commitAndPush(repoPath, message) {
|
|
372
376
|
try {
|
|
373
377
|
const git = simpleGit(repoPath);
|
|
374
|
-
|
|
375
|
-
const
|
|
376
|
-
|
|
377
|
-
|
|
378
|
+
let status = await git.status();
|
|
379
|
+
const branch = status.current || 'main';
|
|
380
|
+
let committed = false;
|
|
381
|
+
if (status.files.length > 0) {
|
|
382
|
+
await git.add('-A');
|
|
383
|
+
await git.commit(message);
|
|
384
|
+
committed = true;
|
|
385
|
+
status = await git.status();
|
|
386
|
+
}
|
|
387
|
+
const ahead = status.ahead ?? 0;
|
|
388
|
+
if (!committed && ahead === 0) {
|
|
389
|
+
return {
|
|
390
|
+
success: true,
|
|
391
|
+
detail: 'already up to date',
|
|
392
|
+
branch,
|
|
393
|
+
committed: false,
|
|
394
|
+
pushed: false,
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
// Capture remote tip before push for a real ref range in the detail string.
|
|
398
|
+
let before = '';
|
|
399
|
+
try {
|
|
400
|
+
before = (await git.raw(['rev-parse', '--short=8', `origin/${branch}`])).trim();
|
|
401
|
+
}
|
|
402
|
+
catch {
|
|
403
|
+
/* origin/<branch> may not exist yet (first push) */
|
|
378
404
|
}
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
405
|
+
await git.push('origin', branch);
|
|
406
|
+
let after = '';
|
|
407
|
+
try {
|
|
408
|
+
after = (await git.raw(['rev-parse', '--short=8', 'HEAD'])).trim();
|
|
409
|
+
}
|
|
410
|
+
catch {
|
|
411
|
+
after = 'unknown';
|
|
412
|
+
}
|
|
413
|
+
const range = before && after && before !== after
|
|
414
|
+
? `${before}..${after}`
|
|
415
|
+
: after || undefined;
|
|
416
|
+
const detail = committed
|
|
417
|
+
? range
|
|
418
|
+
? `committed and pushed ${range}`
|
|
419
|
+
: 'committed and pushed'
|
|
420
|
+
: range
|
|
421
|
+
? `pushed ${range}`
|
|
422
|
+
: 'pushed';
|
|
423
|
+
return {
|
|
424
|
+
success: true,
|
|
425
|
+
detail,
|
|
426
|
+
branch,
|
|
427
|
+
committed,
|
|
428
|
+
pushed: true,
|
|
429
|
+
};
|
|
386
430
|
}
|
|
387
431
|
catch (err) {
|
|
388
432
|
return { success: false, error: err.message };
|
|
@@ -630,6 +674,10 @@ export function displayHomePath(dir) {
|
|
|
630
674
|
/**
|
|
631
675
|
* Pull changes in an existing repo.
|
|
632
676
|
* Refuses to pull if the working tree is dirty -- user must commit or discard changes first.
|
|
677
|
+
*
|
|
678
|
+
* Uses `git pull --rebase` (same strategy as {@link syncRepoGit}) so a diverged
|
|
679
|
+
* branch reconciles instead of failing with "Need to specify how to reconcile
|
|
680
|
+
* divergent branches".
|
|
633
681
|
*/
|
|
634
682
|
export async function pullRepo(dir) {
|
|
635
683
|
try {
|
|
@@ -642,13 +690,15 @@ export async function pullRepo(dir) {
|
|
|
642
690
|
error: `Working tree has uncommitted changes. Commit or discard them before pulling.\n\n cd ${displayHomePath(dir)} && git status`,
|
|
643
691
|
};
|
|
644
692
|
}
|
|
645
|
-
|
|
646
|
-
await git.
|
|
693
|
+
const branch = status.current || 'main';
|
|
694
|
+
await git.fetch('origin');
|
|
695
|
+
await git.pull('origin', branch, { '--rebase': 'true' });
|
|
647
696
|
installGithooksSymlinks(dir);
|
|
648
697
|
const log = await git.log({ maxCount: 1 });
|
|
649
698
|
return {
|
|
650
699
|
success: true,
|
|
651
700
|
commit: log.latest?.hash.slice(0, 8) || 'unknown',
|
|
701
|
+
branch,
|
|
652
702
|
};
|
|
653
703
|
}
|
|
654
704
|
catch (err) {
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Goose slash-command install/remove/list/compare.
|
|
3
|
+
*
|
|
4
|
+
* Goose has no native slash-command file format — a slash command is a recipe
|
|
5
|
+
* YAML file registered in `~/.config/goose/config.yaml` under a `slash_commands`
|
|
6
|
+
* array: `[{ command: "<name>", recipe_path: "<abs path to recipe.yaml>" }]`.
|
|
7
|
+
* (See goose-docs.ai context-engineering/slash-commands.)
|
|
8
|
+
*
|
|
9
|
+
* agents-cli writes each command's recipe to `<versionHome>/.config/goose/commands/
|
|
10
|
+
* <name>.yaml` — a dir distinct from the workflow recipes dir
|
|
11
|
+
* (`.config/goose/recipes/`) so the workflow detector never treats a command
|
|
12
|
+
* recipe as a workflow — and registers/unregisters the `slash_commands` entry in
|
|
13
|
+
* `config.yaml` via a read-modify-write that preserves every other key
|
|
14
|
+
* (`mcp_servers`, `extensions`, …). Under agents-cli version isolation HOME is the
|
|
15
|
+
* version home, so both files live under it and the absolute `recipe_path`
|
|
16
|
+
* resolves correctly at goose runtime.
|
|
17
|
+
*/
|
|
18
|
+
/** Directory holding Goose slash-command recipe YAML files in a version home. */
|
|
19
|
+
export declare function gooseCommandsDir(versionHome: string): string;
|
|
20
|
+
/** Path to the Goose config.yaml (holds the `slash_commands` registry) in a version home. */
|
|
21
|
+
export declare function gooseCommandConfigPath(versionHome: string): string;
|
|
22
|
+
/**
|
|
23
|
+
* Install a command into a Goose version home: write its recipe YAML and register
|
|
24
|
+
* the `slash_commands` entry in config.yaml.
|
|
25
|
+
*/
|
|
26
|
+
export declare function installGooseCommandToVersion(versionHome: string, commandName: string, sourcePath: string): {
|
|
27
|
+
success: boolean;
|
|
28
|
+
error?: string;
|
|
29
|
+
};
|
|
30
|
+
/** List Goose command names (recipe files) installed in a version home. */
|
|
31
|
+
export declare function listGooseCommandsInVersion(versionHome: string): string[];
|
|
32
|
+
/** Whether an installed Goose command recipe matches the central Markdown source. */
|
|
33
|
+
export declare function gooseCommandMatches(versionHome: string, commandName: string, sourcePath: string): boolean;
|
|
34
|
+
/**
|
|
35
|
+
* Remove a Goose command from a version home: soft-delete the recipe to `trashDir`
|
|
36
|
+
* (when provided) and unregister its `slash_commands` entry.
|
|
37
|
+
*/
|
|
38
|
+
export declare function removeGooseCommandFromVersion(versionHome: string, commandName: string, trashDir?: string): {
|
|
39
|
+
success: boolean;
|
|
40
|
+
error?: string;
|
|
41
|
+
};
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Goose slash-command install/remove/list/compare.
|
|
3
|
+
*
|
|
4
|
+
* Goose has no native slash-command file format — a slash command is a recipe
|
|
5
|
+
* YAML file registered in `~/.config/goose/config.yaml` under a `slash_commands`
|
|
6
|
+
* array: `[{ command: "<name>", recipe_path: "<abs path to recipe.yaml>" }]`.
|
|
7
|
+
* (See goose-docs.ai context-engineering/slash-commands.)
|
|
8
|
+
*
|
|
9
|
+
* agents-cli writes each command's recipe to `<versionHome>/.config/goose/commands/
|
|
10
|
+
* <name>.yaml` — a dir distinct from the workflow recipes dir
|
|
11
|
+
* (`.config/goose/recipes/`) so the workflow detector never treats a command
|
|
12
|
+
* recipe as a workflow — and registers/unregisters the `slash_commands` entry in
|
|
13
|
+
* `config.yaml` via a read-modify-write that preserves every other key
|
|
14
|
+
* (`mcp_servers`, `extensions`, …). Under agents-cli version isolation HOME is the
|
|
15
|
+
* version home, so both files live under it and the absolute `recipe_path`
|
|
16
|
+
* resolves correctly at goose runtime.
|
|
17
|
+
*/
|
|
18
|
+
import * as fs from 'fs';
|
|
19
|
+
import * as path from 'path';
|
|
20
|
+
import * as yaml from 'yaml';
|
|
21
|
+
import { safeJoin } from './paths.js';
|
|
22
|
+
import { markdownToGooseRecipe } from './convert.js';
|
|
23
|
+
/** Directory holding Goose slash-command recipe YAML files in a version home. */
|
|
24
|
+
export function gooseCommandsDir(versionHome) {
|
|
25
|
+
return path.join(versionHome, '.config', 'goose', 'commands');
|
|
26
|
+
}
|
|
27
|
+
/** Path to the Goose config.yaml (holds the `slash_commands` registry) in a version home. */
|
|
28
|
+
export function gooseCommandConfigPath(versionHome) {
|
|
29
|
+
return path.join(versionHome, '.config', 'goose', 'config.yaml');
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Read the goose config.yaml as a mutable object. Throws — rather than returning
|
|
33
|
+
* `{}` — when a NON-EMPTY file fails to parse or isn't a mapping, so the caller
|
|
34
|
+
* (which rewrites the whole file) never silently clobbers a real user config
|
|
35
|
+
* (`mcp_servers`, `GOOSE_MODEL`, `extensions`, …). A missing or genuinely empty
|
|
36
|
+
* file returns `{}`.
|
|
37
|
+
*/
|
|
38
|
+
function readGooseConfig(configPath) {
|
|
39
|
+
if (!fs.existsSync(configPath))
|
|
40
|
+
return {};
|
|
41
|
+
const raw = fs.readFileSync(configPath, 'utf-8');
|
|
42
|
+
if (raw.trim() === '')
|
|
43
|
+
return {};
|
|
44
|
+
let parsed;
|
|
45
|
+
try {
|
|
46
|
+
parsed = yaml.parse(raw);
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
throw new Error(`Refusing to rewrite goose config at ${configPath}: existing file is not valid YAML ` +
|
|
50
|
+
`(${err.message}). Fix or remove it, then re-sync.`);
|
|
51
|
+
}
|
|
52
|
+
if (parsed === null || parsed === undefined)
|
|
53
|
+
return {}; // comments/whitespace only
|
|
54
|
+
if (typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
55
|
+
throw new Error(`Refusing to rewrite goose config at ${configPath}: expected a YAML mapping but found ` +
|
|
56
|
+
`${Array.isArray(parsed) ? 'a list' : typeof parsed}.`);
|
|
57
|
+
}
|
|
58
|
+
return parsed;
|
|
59
|
+
}
|
|
60
|
+
function readSlashCommands(config) {
|
|
61
|
+
const raw = config.slash_commands;
|
|
62
|
+
if (!Array.isArray(raw))
|
|
63
|
+
return [];
|
|
64
|
+
return raw.filter((e) => !!e && typeof e === 'object' && typeof e.command === 'string');
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Register (or update) a `slash_commands` entry for `commandName` pointing at
|
|
68
|
+
* `recipePath`, preserving every other config key and other entries. Idempotent.
|
|
69
|
+
*/
|
|
70
|
+
function registerSlashCommand(configPath, commandName, recipePath) {
|
|
71
|
+
const config = readGooseConfig(configPath);
|
|
72
|
+
const entries = readSlashCommands(config);
|
|
73
|
+
const existing = entries.find((e) => e.command === commandName);
|
|
74
|
+
if (existing && existing.recipe_path === recipePath)
|
|
75
|
+
return; // already current — no rewrite
|
|
76
|
+
const next = entries.filter((e) => e.command !== commandName);
|
|
77
|
+
next.push({ command: commandName, recipe_path: recipePath });
|
|
78
|
+
next.sort((a, b) => a.command.localeCompare(b.command));
|
|
79
|
+
config.slash_commands = next;
|
|
80
|
+
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
81
|
+
fs.writeFileSync(configPath, yaml.stringify(config), 'utf-8');
|
|
82
|
+
}
|
|
83
|
+
/** Remove the `slash_commands` entry for `commandName`, preserving all other config. */
|
|
84
|
+
function unregisterSlashCommand(configPath, commandName) {
|
|
85
|
+
if (!fs.existsSync(configPath))
|
|
86
|
+
return;
|
|
87
|
+
const config = readGooseConfig(configPath);
|
|
88
|
+
const entries = readSlashCommands(config);
|
|
89
|
+
if (!entries.some((e) => e.command === commandName))
|
|
90
|
+
return; // nothing to do
|
|
91
|
+
const next = entries.filter((e) => e.command !== commandName);
|
|
92
|
+
if (next.length > 0) {
|
|
93
|
+
config.slash_commands = next;
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
delete config.slash_commands;
|
|
97
|
+
}
|
|
98
|
+
fs.writeFileSync(configPath, yaml.stringify(config), 'utf-8');
|
|
99
|
+
}
|
|
100
|
+
/** Serialize a Goose command recipe from a central Markdown command file. */
|
|
101
|
+
function buildGooseCommandRecipe(commandName, sourcePath) {
|
|
102
|
+
const markdown = fs.readFileSync(sourcePath, 'utf-8');
|
|
103
|
+
return yaml.stringify(markdownToGooseRecipe(commandName, markdown));
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Install a command into a Goose version home: write its recipe YAML and register
|
|
107
|
+
* the `slash_commands` entry in config.yaml.
|
|
108
|
+
*/
|
|
109
|
+
export function installGooseCommandToVersion(versionHome, commandName, sourcePath) {
|
|
110
|
+
try {
|
|
111
|
+
const dir = gooseCommandsDir(versionHome);
|
|
112
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
113
|
+
const recipePath = safeJoin(dir, `${commandName}.yaml`);
|
|
114
|
+
fs.writeFileSync(recipePath, buildGooseCommandRecipe(commandName, sourcePath), 'utf-8');
|
|
115
|
+
registerSlashCommand(gooseCommandConfigPath(versionHome), commandName, recipePath);
|
|
116
|
+
return { success: true };
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
return { success: false, error: err.message };
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/** List Goose command names (recipe files) installed in a version home. */
|
|
123
|
+
export function listGooseCommandsInVersion(versionHome) {
|
|
124
|
+
const dir = gooseCommandsDir(versionHome);
|
|
125
|
+
if (!fs.existsSync(dir))
|
|
126
|
+
return [];
|
|
127
|
+
return fs.readdirSync(dir)
|
|
128
|
+
.filter((f) => f.endsWith('.yaml'))
|
|
129
|
+
.map((f) => f.slice(0, -'.yaml'.length))
|
|
130
|
+
.sort();
|
|
131
|
+
}
|
|
132
|
+
/** Whether an installed Goose command recipe matches the central Markdown source. */
|
|
133
|
+
export function gooseCommandMatches(versionHome, commandName, sourcePath) {
|
|
134
|
+
const recipePath = safeJoin(gooseCommandsDir(versionHome), `${commandName}.yaml`);
|
|
135
|
+
if (!fs.existsSync(recipePath) || !fs.existsSync(sourcePath))
|
|
136
|
+
return false;
|
|
137
|
+
try {
|
|
138
|
+
// The slash_commands entry must also be registered for the command to be live.
|
|
139
|
+
// (An unparseable config.yaml surfaces here as "not a match" → a re-sync, which
|
|
140
|
+
// fails loudly rather than clobbering, instead of a crash during a read-only diff.)
|
|
141
|
+
const registered = readSlashCommands(readGooseConfig(gooseCommandConfigPath(versionHome)))
|
|
142
|
+
.some((e) => e.command === commandName);
|
|
143
|
+
if (!registered)
|
|
144
|
+
return false;
|
|
145
|
+
const installed = fs.readFileSync(recipePath, 'utf-8').trim();
|
|
146
|
+
const expected = buildGooseCommandRecipe(commandName, sourcePath).trim();
|
|
147
|
+
return installed === expected;
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Remove a Goose command from a version home: soft-delete the recipe to `trashDir`
|
|
155
|
+
* (when provided) and unregister its `slash_commands` entry.
|
|
156
|
+
*/
|
|
157
|
+
export function removeGooseCommandFromVersion(versionHome, commandName, trashDir) {
|
|
158
|
+
try {
|
|
159
|
+
const recipePath = safeJoin(gooseCommandsDir(versionHome), `${commandName}.yaml`);
|
|
160
|
+
if (fs.existsSync(recipePath)) {
|
|
161
|
+
if (trashDir) {
|
|
162
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
163
|
+
fs.mkdirSync(trashDir, { recursive: true, mode: 0o700 });
|
|
164
|
+
fs.renameSync(recipePath, path.join(trashDir, `${commandName}.yaml.${stamp}`));
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
fs.unlinkSync(recipePath);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
unregisterSlashCommand(gooseCommandConfigPath(versionHome), commandName);
|
|
171
|
+
return { success: true };
|
|
172
|
+
}
|
|
173
|
+
catch (err) {
|
|
174
|
+
return { success: false, error: err.message };
|
|
175
|
+
}
|
|
176
|
+
}
|
package/dist/lib/hooks.js
CHANGED
|
@@ -1006,6 +1006,9 @@ export function registerHooksToSettings(agentId, versionHome, hookManifest, agen
|
|
|
1006
1006
|
if (agentId === 'cursor') {
|
|
1007
1007
|
return registerHooksForCursor(versionHome, manifest, resolveScript, managedPrefixes);
|
|
1008
1008
|
}
|
|
1009
|
+
if (agentId === 'hermes') {
|
|
1010
|
+
return registerHooksForHermes(versionHome, manifest, resolveScript, managedPrefixes);
|
|
1011
|
+
}
|
|
1009
1012
|
return { registered: [], errors: [] };
|
|
1010
1013
|
}
|
|
1011
1014
|
/**
|
|
@@ -2186,3 +2189,134 @@ function registerHooksForCursor(versionHome, manifest, resolveScript, managedPre
|
|
|
2186
2189
|
}
|
|
2187
2190
|
return { registered, errors };
|
|
2188
2191
|
}
|
|
2192
|
+
/**
|
|
2193
|
+
* Canonical → Hermes (Nous Research) snake_case lifecycle events.
|
|
2194
|
+
*
|
|
2195
|
+
* Hermes ≥ 0.11.0 runs configurable hooks declared under `hooks:` in
|
|
2196
|
+
* ~/.hermes/config.yaml. Only events with a documented Hermes equivalent are
|
|
2197
|
+
* mapped; unmapped canonical events (the manifest may declare events for other
|
|
2198
|
+
* agents) are skipped silently. UserPromptSubmit maps to `pre_llm_call` (the
|
|
2199
|
+
* closest pre-turn phase) and Stop to `on_session_finalize`.
|
|
2200
|
+
*/
|
|
2201
|
+
const HERMES_EVENT_MAP = {
|
|
2202
|
+
SessionStart: 'on_session_start',
|
|
2203
|
+
SessionEnd: 'on_session_end',
|
|
2204
|
+
PreToolUse: 'pre_tool_call',
|
|
2205
|
+
PostToolUse: 'post_tool_call',
|
|
2206
|
+
SubagentStop: 'subagent_stop',
|
|
2207
|
+
UserPromptSubmit: 'pre_llm_call',
|
|
2208
|
+
Stop: 'on_session_finalize',
|
|
2209
|
+
};
|
|
2210
|
+
/** Hermes caps hook timeouts at 300s (default 60s). */
|
|
2211
|
+
const HERMES_TIMEOUT_CAP = 300;
|
|
2212
|
+
const HERMES_TIMEOUT_DEFAULT = 60;
|
|
2213
|
+
/**
|
|
2214
|
+
* Register hooks for Hermes Agent (Nous Research ≥ 0.11.0).
|
|
2215
|
+
*
|
|
2216
|
+
* Read-modify-writes the shared `~/.hermes/config.yaml` (under the version
|
|
2217
|
+
* home): it merges a `hooks:` block of the form
|
|
2218
|
+
* hooks: { <event>: [ { command, timeout, matcher? } ] }
|
|
2219
|
+
* into the YAML doc WITHOUT touching sibling keys (`mcp_servers` in
|
|
2220
|
+
* particular — a naive overwrite would wipe the user's MCP servers). No
|
|
2221
|
+
* `version` wrapper: Hermes' config is a flat YAML map.
|
|
2222
|
+
*
|
|
2223
|
+
* GC: rewrite managed entries by command path under managedPrefixes; preserve
|
|
2224
|
+
* user-authored entries whose command is outside managed roots. Keyed by
|
|
2225
|
+
* event|command|matcher so a matcher or event change drops the stale entry.
|
|
2226
|
+
*/
|
|
2227
|
+
function registerHooksForHermes(versionHome, manifest, resolveScript, managedPrefixes) {
|
|
2228
|
+
const registered = [];
|
|
2229
|
+
const errors = [];
|
|
2230
|
+
const configDir = path.join(versionHome, '.hermes');
|
|
2231
|
+
const configPath = path.join(configDir, 'config.yaml');
|
|
2232
|
+
// Read-modify-write: preserve the full existing YAML doc (mcp_servers, etc.).
|
|
2233
|
+
let config = {};
|
|
2234
|
+
if (fs.existsSync(configPath)) {
|
|
2235
|
+
try {
|
|
2236
|
+
const parsed = yaml.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
2237
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
2238
|
+
config = parsed;
|
|
2239
|
+
}
|
|
2240
|
+
}
|
|
2241
|
+
catch {
|
|
2242
|
+
errors.push('Failed to parse existing config.yaml');
|
|
2243
|
+
return { registered, errors };
|
|
2244
|
+
}
|
|
2245
|
+
}
|
|
2246
|
+
const existingHooks = config.hooks && typeof config.hooks === 'object' && !Array.isArray(config.hooks)
|
|
2247
|
+
? config.hooks
|
|
2248
|
+
: {};
|
|
2249
|
+
// Desired managed entries keyed by event|command|matcher so a matcher or
|
|
2250
|
+
// event change drops the stale entry instead of retaining it by command alone.
|
|
2251
|
+
const desiredManaged = new Set();
|
|
2252
|
+
for (const [hookName, hookDef] of Object.entries(manifest)) {
|
|
2253
|
+
if (!hookDef.events || hookDef.events.length === 0)
|
|
2254
|
+
continue;
|
|
2255
|
+
const resolved = resolveHookCommand(hookName, hookDef, resolveScript);
|
|
2256
|
+
if (!resolved)
|
|
2257
|
+
continue;
|
|
2258
|
+
for (const event of hookDef.events) {
|
|
2259
|
+
const hermesEvent = HERMES_EVENT_MAP[event];
|
|
2260
|
+
if (!hermesEvent)
|
|
2261
|
+
continue;
|
|
2262
|
+
desiredManaged.add(`${hermesEvent}|${resolved}|${hookDef.matcher ?? ''}`);
|
|
2263
|
+
}
|
|
2264
|
+
}
|
|
2265
|
+
// GC managed entries that are no longer in the manifest; preserve user entries.
|
|
2266
|
+
const hooks = {};
|
|
2267
|
+
for (const [event, entries] of Object.entries(existingHooks)) {
|
|
2268
|
+
if (!Array.isArray(entries))
|
|
2269
|
+
continue;
|
|
2270
|
+
hooks[event] = entries.filter((e) => {
|
|
2271
|
+
if (typeof e?.command !== 'string')
|
|
2272
|
+
return true;
|
|
2273
|
+
if (!isManagedHookCommand(e.command, managedPrefixes))
|
|
2274
|
+
return true;
|
|
2275
|
+
return desiredManaged.has(`${event}|${e.command}|${e.matcher ?? ''}`);
|
|
2276
|
+
});
|
|
2277
|
+
if (hooks[event].length === 0)
|
|
2278
|
+
delete hooks[event];
|
|
2279
|
+
}
|
|
2280
|
+
for (const [name, hookDef] of Object.entries(manifest)) {
|
|
2281
|
+
if (!hookDef.events || hookDef.events.length === 0)
|
|
2282
|
+
continue;
|
|
2283
|
+
const commandPath = resolveHookCommand(name, hookDef, resolveScript);
|
|
2284
|
+
if (!commandPath) {
|
|
2285
|
+
errors.push(`${name}: script not found in user or system hooks dir`);
|
|
2286
|
+
continue;
|
|
2287
|
+
}
|
|
2288
|
+
const timeout = Math.min(HERMES_TIMEOUT_CAP, hookDef.timeout ?? HERMES_TIMEOUT_DEFAULT);
|
|
2289
|
+
for (const event of hookDef.events) {
|
|
2290
|
+
const hermesEvent = HERMES_EVENT_MAP[event];
|
|
2291
|
+
if (!hermesEvent)
|
|
2292
|
+
continue;
|
|
2293
|
+
if (!hooks[hermesEvent])
|
|
2294
|
+
hooks[hermesEvent] = [];
|
|
2295
|
+
const entry = { command: commandPath, timeout };
|
|
2296
|
+
if (hookDef.matcher)
|
|
2297
|
+
entry.matcher = hookDef.matcher;
|
|
2298
|
+
const existingIdx = hooks[hermesEvent].findIndex((h) => h.command === entry.command && (h.matcher ?? '') === (entry.matcher ?? ''));
|
|
2299
|
+
if (existingIdx >= 0) {
|
|
2300
|
+
hooks[hermesEvent][existingIdx] = entry;
|
|
2301
|
+
}
|
|
2302
|
+
else {
|
|
2303
|
+
hooks[hermesEvent].push(entry);
|
|
2304
|
+
}
|
|
2305
|
+
registered.push(`${name} -> ${hermesEvent}`);
|
|
2306
|
+
}
|
|
2307
|
+
}
|
|
2308
|
+
if (Object.keys(hooks).length > 0) {
|
|
2309
|
+
config.hooks = hooks;
|
|
2310
|
+
}
|
|
2311
|
+
else {
|
|
2312
|
+
delete config.hooks;
|
|
2313
|
+
}
|
|
2314
|
+
try {
|
|
2315
|
+
fs.mkdirSync(configDir, { recursive: true });
|
|
2316
|
+
fs.writeFileSync(configPath, yaml.stringify(config), 'utf-8');
|
|
2317
|
+
}
|
|
2318
|
+
catch (err) {
|
|
2319
|
+
errors.push(`Failed to write config.yaml: ${err.message}`);
|
|
2320
|
+
}
|
|
2321
|
+
return { registered, errors };
|
|
2322
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credential provisioning for `agents run --host --copy-creds`.
|
|
3
|
+
*
|
|
4
|
+
* Reuses the lease flow's runtime detection + credential-script builder so a
|
|
5
|
+
* persistent host can boot logged-in the same way an ephemeral leased box does.
|
|
6
|
+
* Unlike `--lease`, a host is persistent, so copying tokens is strictly opt-in
|
|
7
|
+
* per run and we shred the files after the run to bound the credential window.
|
|
8
|
+
*/
|
|
9
|
+
import type { AgentId } from '../types.js';
|
|
10
|
+
import { type DetectedRuntime } from '../crabbox/runtimes.js';
|
|
11
|
+
export interface HostCredentials {
|
|
12
|
+
runtimes: AgentId[];
|
|
13
|
+
detected: DetectedRuntime[];
|
|
14
|
+
claudeCredentialsJson?: string | null;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Build the setup (write credential files) and teardown (shred them) scripts for
|
|
18
|
+
* a host run. Returns shell snippets meant to be run on the remote host.
|
|
19
|
+
*/
|
|
20
|
+
export declare function buildHostCredentialScript(opts: HostCredentials): {
|
|
21
|
+
setup: string;
|
|
22
|
+
teardown: string;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Wrap a remote command so credentials are written before it runs and shredded
|
|
26
|
+
* after it exits, regardless of success or failure.
|
|
27
|
+
*/
|
|
28
|
+
export declare function wrapHostCommandWithCredentials(innerCommand: string, opts: HostCredentials): string;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credential provisioning for `agents run --host --copy-creds`.
|
|
3
|
+
*
|
|
4
|
+
* Reuses the lease flow's runtime detection + credential-script builder so a
|
|
5
|
+
* persistent host can boot logged-in the same way an ephemeral leased box does.
|
|
6
|
+
* Unlike `--lease`, a host is persistent, so copying tokens is strictly opt-in
|
|
7
|
+
* per run and we shred the files after the run to bound the credential window.
|
|
8
|
+
*/
|
|
9
|
+
import { buildCredentialScript, CLAUDE_TOKEN_REMOTE } from '../crabbox/runtimes.js';
|
|
10
|
+
function getShredPaths(runtimes) {
|
|
11
|
+
const pathsById = {
|
|
12
|
+
claude: ['.claude.json', CLAUDE_TOKEN_REMOTE],
|
|
13
|
+
codex: ['.codex/auth.json'],
|
|
14
|
+
gemini: ['.gemini/google_accounts.json'],
|
|
15
|
+
grok: ['.grok/auth.json'],
|
|
16
|
+
};
|
|
17
|
+
return runtimes.flatMap((id) => pathsById[id] ?? []);
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Build the setup (write credential files) and teardown (shred them) scripts for
|
|
21
|
+
* a host run. Returns shell snippets meant to be run on the remote host.
|
|
22
|
+
*/
|
|
23
|
+
export function buildHostCredentialScript(opts) {
|
|
24
|
+
const setup = buildCredentialScript(opts.runtimes, opts.detected, {
|
|
25
|
+
claudeCredentialsJson: opts.claudeCredentialsJson,
|
|
26
|
+
});
|
|
27
|
+
const teardown = getShredPaths(opts.runtimes)
|
|
28
|
+
.map((p) => `rm -f "$HOME/${p}" 2>/dev/null || true`)
|
|
29
|
+
.join('\n');
|
|
30
|
+
return { setup, teardown };
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Wrap a remote command so credentials are written before it runs and shredded
|
|
34
|
+
* after it exits, regardless of success or failure.
|
|
35
|
+
*/
|
|
36
|
+
export function wrapHostCommandWithCredentials(innerCommand, opts) {
|
|
37
|
+
const { setup, teardown } = buildHostCredentialScript(opts);
|
|
38
|
+
return [
|
|
39
|
+
'set -uo pipefail',
|
|
40
|
+
setup,
|
|
41
|
+
innerCommand,
|
|
42
|
+
'rc=$?',
|
|
43
|
+
teardown,
|
|
44
|
+
'exit $rc',
|
|
45
|
+
]
|
|
46
|
+
.filter((l) => l.length > 0)
|
|
47
|
+
.join('\n');
|
|
48
|
+
}
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import type { Host } from './types.js';
|
|
12
12
|
import { type HostTask } from './tasks.js';
|
|
13
|
+
import { type HostCredentials } from './credentials.js';
|
|
13
14
|
/**
|
|
14
15
|
* Build a `cd <dir> && ` prefix that resolves on the REMOTE host.
|
|
15
16
|
*
|
|
@@ -36,6 +37,26 @@ export interface DispatchResult {
|
|
|
36
37
|
}
|
|
37
38
|
/** Terminate a detached dispatch that its caller could not persist locally. */
|
|
38
39
|
export declare function terminateDispatchedTask(task: HostTask): void;
|
|
40
|
+
/**
|
|
41
|
+
* Build the remote shell used by {@link stopDispatchedTask}. Exported for
|
|
42
|
+
* unit tests — the keep-log / no-clobber contract lives in this script.
|
|
43
|
+
*
|
|
44
|
+
* Protocol (printed to stdout for the local caller):
|
|
45
|
+
* - `SIGNALED` — process group was live; SIGTERM/KILL applied; wrote 143
|
|
46
|
+
* - `ALREADY` + code — group gone; adopted existing `.exit` (never overwrite)
|
|
47
|
+
* - `GONE` — group gone and no `.exit`; write 143 as the local stop outcome
|
|
48
|
+
* Exit 1 if the group is still alive after TERM/KILL (can't stop it).
|
|
49
|
+
*/
|
|
50
|
+
export declare function buildStopRemoteCommand(pid: number, remoteExit: string): string;
|
|
51
|
+
/**
|
|
52
|
+
* Stop a running host task from the origin machine (`agents hosts stop <id>`).
|
|
53
|
+
*
|
|
54
|
+
* Unlike {@link terminateDispatchedTask} (rollback cleanup after a failed
|
|
55
|
+
* persist), this keeps the remote log so `agents hosts logs <id>` still works,
|
|
56
|
+
* writes a terminal `.exit` marker only when we actually stopped a live group
|
|
57
|
+
* (or no code existed), and never clobbers a real completed-run exit code.
|
|
58
|
+
*/
|
|
59
|
+
export declare function stopDispatchedTask(task: HostTask): HostTask;
|
|
39
60
|
export interface DispatchOptions {
|
|
40
61
|
agent: string;
|
|
41
62
|
prompt: string;
|
|
@@ -76,6 +97,8 @@ export interface DispatchOptions {
|
|
|
76
97
|
/** Stream progress and block until completion (default true). */
|
|
77
98
|
follow?: boolean;
|
|
78
99
|
timeoutMs?: number;
|
|
100
|
+
/** Copy runtime credentials to the host before the run and shred them after. */
|
|
101
|
+
copyCreds?: HostCredentials;
|
|
79
102
|
}
|
|
80
103
|
/**
|
|
81
104
|
* Build the remote `agents run …` argv for a host dispatch. Pure so the
|
|
@@ -116,6 +139,8 @@ export interface InteractiveDispatchOptions {
|
|
|
116
139
|
raw?: boolean;
|
|
117
140
|
/** Forward `--interactive` to the remote so a prompt-bearing run still starts the TUI. */
|
|
118
141
|
forceInteractive?: boolean;
|
|
142
|
+
/** Copy runtime credentials to the host before the run and shred them after. */
|
|
143
|
+
copyCreds?: HostCredentials;
|
|
119
144
|
}
|
|
120
145
|
/**
|
|
121
146
|
* Build the remote `agents run …` argv for an INTERACTIVE host dispatch. The
|