@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.
Files changed (89) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/README.md +10 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/browser.js +13 -3
  5. package/dist/commands/exec.js +51 -1
  6. package/dist/commands/funnel.d.ts +5 -0
  7. package/dist/commands/funnel.js +62 -0
  8. package/dist/commands/hosts.js +42 -0
  9. package/dist/commands/repo.d.ts +4 -4
  10. package/dist/commands/repo.js +30 -19
  11. package/dist/commands/routines.js +73 -16
  12. package/dist/commands/sessions-sync.d.ts +1 -0
  13. package/dist/commands/sessions-sync.js +16 -2
  14. package/dist/commands/sessions.js +8 -1
  15. package/dist/commands/setup.js +9 -0
  16. package/dist/commands/ssh.js +72 -2
  17. package/dist/commands/sync-provision.d.ts +23 -0
  18. package/dist/commands/sync-provision.js +107 -0
  19. package/dist/commands/webhook.d.ts +9 -0
  20. package/dist/commands/webhook.js +93 -0
  21. package/dist/index.js +6 -2
  22. package/dist/lib/agents.d.ts +26 -0
  23. package/dist/lib/agents.js +100 -28
  24. package/dist/lib/browser/ipc.js +5 -4
  25. package/dist/lib/browser/profiles.d.ts +13 -0
  26. package/dist/lib/browser/profiles.js +17 -0
  27. package/dist/lib/browser/service.d.ts +12 -1
  28. package/dist/lib/browser/service.js +48 -13
  29. package/dist/lib/browser/sessions-list.d.ts +40 -0
  30. package/dist/lib/browser/sessions-list.js +190 -0
  31. package/dist/lib/commands.js +29 -0
  32. package/dist/lib/convert.d.ts +11 -0
  33. package/dist/lib/convert.js +22 -0
  34. package/dist/lib/daemon.js +2 -0
  35. package/dist/lib/devices/fleet.d.ts +62 -0
  36. package/dist/lib/devices/fleet.js +128 -0
  37. package/dist/lib/doctor-diff.js +17 -1
  38. package/dist/lib/funnel.d.ts +5 -0
  39. package/dist/lib/funnel.js +23 -0
  40. package/dist/lib/git.d.ts +21 -5
  41. package/dist/lib/git.js +64 -14
  42. package/dist/lib/goose-commands.d.ts +41 -0
  43. package/dist/lib/goose-commands.js +176 -0
  44. package/dist/lib/hooks.js +134 -0
  45. package/dist/lib/hosts/credentials.d.ts +28 -0
  46. package/dist/lib/hosts/credentials.js +48 -0
  47. package/dist/lib/hosts/dispatch.d.ts +25 -0
  48. package/dist/lib/hosts/dispatch.js +68 -2
  49. package/dist/lib/hosts/passthrough.d.ts +13 -10
  50. package/dist/lib/hosts/passthrough.js +119 -29
  51. package/dist/lib/mailbox-gc.js +4 -16
  52. package/dist/lib/migrate.d.ts +12 -0
  53. package/dist/lib/migrate.js +55 -1
  54. package/dist/lib/permissions.d.ts +15 -0
  55. package/dist/lib/permissions.js +99 -0
  56. package/dist/lib/plugins.d.ts +20 -0
  57. package/dist/lib/plugins.js +118 -0
  58. package/dist/lib/resources/permissions.js +2 -0
  59. package/dist/lib/routines.d.ts +29 -10
  60. package/dist/lib/routines.js +47 -15
  61. package/dist/lib/session/active.d.ts +8 -1
  62. package/dist/lib/session/active.js +1 -0
  63. package/dist/lib/session/parse.js +12 -0
  64. package/dist/lib/session/state.d.ts +33 -0
  65. package/dist/lib/session/state.js +40 -0
  66. package/dist/lib/session/sync/agents.d.ts +2 -0
  67. package/dist/lib/session/sync/agents.js +39 -1
  68. package/dist/lib/session/sync/config.d.ts +8 -0
  69. package/dist/lib/session/sync/config.js +6 -1
  70. package/dist/lib/session/sync/provision.d.ts +49 -0
  71. package/dist/lib/session/sync/provision.js +91 -0
  72. package/dist/lib/session/sync/sync.d.ts +3 -0
  73. package/dist/lib/session/sync/sync.js +26 -6
  74. package/dist/lib/session/sync/transcript-crypto.d.ts +77 -0
  75. package/dist/lib/session/sync/transcript-crypto.js +147 -0
  76. package/dist/lib/staleness/detectors/permissions.js +23 -0
  77. package/dist/lib/staleness/detectors/subagents.js +36 -0
  78. package/dist/lib/staleness/writers/commands.js +7 -0
  79. package/dist/lib/staleness/writers/hooks.js +1 -1
  80. package/dist/lib/staleness/writers/subagents.js +22 -3
  81. package/dist/lib/startup/command-registry.d.ts +2 -0
  82. package/dist/lib/startup/command-registry.js +11 -0
  83. package/dist/lib/state.d.ts +10 -2
  84. package/dist/lib/state.js +14 -2
  85. package/dist/lib/subagents.d.ts +32 -0
  86. package/dist/lib/subagents.js +238 -0
  87. package/dist/lib/triggers/webhook.d.ts +70 -27
  88. package/dist/lib/triggers/webhook.js +264 -43
  89. 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 changes in a repo.
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
- // Check for changes
375
- const status = await git.status();
376
- if (status.files.length === 0) {
377
- return { success: true }; // Nothing to commit
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
- // Stage all changes
380
- await git.add('-A');
381
- // Commit
382
- await git.commit(message);
383
- // Push
384
- await git.push('origin', 'main');
385
- return { success: true };
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
- await git.fetch();
646
- await git.pull();
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