@phnx-labs/agents-cli 1.20.26 → 1.20.28

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 (95) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/commands/doctor.d.ts +5 -2
  3. package/dist/commands/doctor.js +182 -30
  4. package/dist/commands/exec.d.ts +1 -1
  5. package/dist/commands/exec.js +177 -6
  6. package/dist/commands/hosts.d.ts +11 -0
  7. package/dist/commands/hosts.js +229 -0
  8. package/dist/commands/repo.d.ts +29 -0
  9. package/dist/commands/repo.js +174 -38
  10. package/dist/commands/secrets.d.ts +2 -7
  11. package/dist/commands/secrets.js +15 -23
  12. package/dist/commands/sessions.d.ts +2 -0
  13. package/dist/commands/sessions.js +27 -25
  14. package/dist/commands/sync.d.ts +2 -0
  15. package/dist/commands/sync.js +22 -5
  16. package/dist/commands/view.js +27 -11
  17. package/dist/index.js +4 -13
  18. package/dist/lib/agent-spec.d.ts +36 -0
  19. package/dist/lib/agent-spec.js +157 -0
  20. package/dist/lib/agents.d.ts +1 -0
  21. package/dist/lib/agents.js +45 -4
  22. package/dist/lib/browser/drivers/ssh.d.ts +47 -2
  23. package/dist/lib/browser/drivers/ssh.js +113 -24
  24. package/dist/lib/browser/profiles.js +28 -1
  25. package/dist/lib/browser/runtime-state.js +28 -8
  26. package/dist/lib/browser/types.d.ts +10 -1
  27. package/dist/lib/cli-resources.js +10 -1
  28. package/dist/lib/daemon.js +32 -0
  29. package/dist/lib/doctor-diff.d.ts +19 -0
  30. package/dist/lib/doctor-diff.js +107 -15
  31. package/dist/lib/exec.d.ts +27 -0
  32. package/dist/lib/exec.js +62 -19
  33. package/dist/lib/heal.d.ts +107 -0
  34. package/dist/lib/heal.js +279 -0
  35. package/dist/lib/hooks.d.ts +17 -0
  36. package/dist/lib/hooks.js +127 -3
  37. package/dist/lib/hosts/dispatch.d.ts +26 -0
  38. package/dist/lib/hosts/dispatch.js +71 -0
  39. package/dist/lib/hosts/progress.d.ts +21 -0
  40. package/dist/lib/hosts/progress.js +49 -0
  41. package/dist/lib/hosts/providers/local.d.ts +17 -0
  42. package/dist/lib/hosts/providers/local.js +81 -0
  43. package/dist/lib/hosts/ready.d.ts +37 -0
  44. package/dist/lib/hosts/ready.js +88 -0
  45. package/dist/lib/hosts/registry.d.ts +22 -0
  46. package/dist/lib/hosts/registry.js +65 -0
  47. package/dist/lib/hosts/ssh-config.d.ts +37 -0
  48. package/dist/lib/hosts/ssh-config.js +157 -0
  49. package/dist/lib/hosts/tasks.d.ts +32 -0
  50. package/dist/lib/hosts/tasks.js +58 -0
  51. package/dist/lib/hosts/types.d.ts +51 -0
  52. package/dist/lib/hosts/types.js +21 -0
  53. package/dist/lib/loop.d.ts +9 -0
  54. package/dist/lib/loop.js +13 -1
  55. package/dist/lib/mcp.js +12 -3
  56. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  57. package/dist/lib/migrate.js +9 -5
  58. package/dist/lib/platform/exec.d.ts +10 -0
  59. package/dist/lib/platform/exec.js +17 -0
  60. package/dist/lib/platform/index.d.ts +1 -0
  61. package/dist/lib/platform/index.js +1 -0
  62. package/dist/lib/platform/links.d.ts +15 -0
  63. package/dist/lib/platform/links.js +42 -0
  64. package/dist/lib/platform/paths.d.ts +18 -0
  65. package/dist/lib/platform/paths.js +22 -0
  66. package/dist/lib/platform/posixpath.d.ts +28 -0
  67. package/dist/lib/platform/posixpath.js +153 -0
  68. package/dist/lib/plugin-marketplace.d.ts +18 -0
  69. package/dist/lib/plugin-marketplace.js +67 -1
  70. package/dist/lib/plugins.d.ts +33 -1
  71. package/dist/lib/plugins.js +56 -11
  72. package/dist/lib/project-launch.js +6 -3
  73. package/dist/lib/sandbox.js +5 -2
  74. package/dist/lib/self-update.js +7 -2
  75. package/dist/lib/session/db.d.ts +23 -0
  76. package/dist/lib/session/db.js +76 -1
  77. package/dist/lib/session/discover.d.ts +26 -0
  78. package/dist/lib/session/discover.js +75 -4
  79. package/dist/lib/session/relative-time.d.ts +7 -0
  80. package/dist/lib/session/relative-time.js +28 -0
  81. package/dist/lib/session/remote.d.ts +61 -0
  82. package/dist/lib/session/remote.js +221 -0
  83. package/dist/lib/ssh-exec.d.ts +45 -0
  84. package/dist/lib/ssh-exec.js +61 -0
  85. package/dist/lib/staleness/detectors/commands.js +7 -6
  86. package/dist/lib/staleness/writers/commands.js +7 -12
  87. package/dist/lib/startup/command-registry.d.ts +1 -0
  88. package/dist/lib/startup/command-registry.js +2 -0
  89. package/dist/lib/startup/dev-build.d.ts +22 -0
  90. package/dist/lib/startup/dev-build.js +41 -0
  91. package/dist/lib/types.d.ts +28 -0
  92. package/dist/lib/versions.d.ts +9 -3
  93. package/dist/lib/versions.js +43 -7
  94. package/package.json +3 -3
  95. package/scripts/postinstall.js +62 -0
@@ -0,0 +1,279 @@
1
+ /**
2
+ * Resource heal engine — close the gap between what DotAgents repos DEFINE and
3
+ * what is actually present/valid in each installed agent home.
4
+ *
5
+ * Powers two callers:
6
+ * - `agents doctor --fix` — explicit, operator-driven. Mode 'full': fills
7
+ * missing, overwrites drifted content, and refreshes stale plugins even when
8
+ * the baseline is unknown (the operator asked for it).
9
+ * - the routines daemon's periodic safety check — Mode 'safe': fixes only the
10
+ * unambiguous gaps (missing resources, Claude-invalid plugin manifests, and
11
+ * provably-unmodified stale plugins). Drift and risky refreshes are reported,
12
+ * never clobbered.
13
+ *
14
+ * Built on the LIVE-home diff (`diffVersionResources`) — NOT the staleness
15
+ * manifest. `isStale()` only compares the last-synced manifest against the
16
+ * sources, so home-side rot (a deleted, corrupted, or Claude-rejected file in a
17
+ * version home whose source never changed) is invisible to it and to the sync
18
+ * fast-guard. The diff reads the actual home, so heal catches exactly that class
19
+ * of drift — the kind that silently broke the `code` plugin on a non-default
20
+ * Claude version.
21
+ *
22
+ * Heal FILLS and FIXES; it never deletes. Orphan/extra removal stays the job of
23
+ * `agents prune cleanup`, so a heal pass can never lose work.
24
+ */
25
+ import { ALL_AGENT_IDS } from './agents.js';
26
+ import { syncResourcesToVersion, listInstalledVersions, getVersionHomePath, getActuallySyncedResources, compareVersions, } from './versions.js';
27
+ import { diffVersionResources, } from './doctor-diff.js';
28
+ import { discoverPlugins, updatePlugin, readPluginSourceInfo, getUpstreamManifestVersion, } from './plugins.js';
29
+ import { repairPluginManifestFile } from './plugin-marketplace.js';
30
+ import * as fs from 'fs';
31
+ import * as path from 'path';
32
+ import * as os from 'os';
33
+ import { spawn } from 'child_process';
34
+ // ─── diff → selection mapping ────────────────────────────────────────────────
35
+ // Which ResourceSelection key each healable diff kind writes through. `rules`
36
+ // re-syncs via the whole-memory channel (not name-scoped); `promptcuts` is not
37
+ // version-synced at all, so it is never healed here.
38
+ const KIND_TO_SELECTION = {
39
+ commands: 'commands',
40
+ skills: 'skills',
41
+ hooks: 'hooks',
42
+ mcp: 'mcp',
43
+ permissions: 'permissions',
44
+ subagents: 'subagents',
45
+ plugins: 'plugins',
46
+ };
47
+ function totalHealed(r) {
48
+ return r.versions.reduce((n, v) => n + v.healed.length, 0);
49
+ }
50
+ /** True when a heal pass made (or would make) any change at all. */
51
+ export function healChangedAnything(r) {
52
+ return (totalHealed(r) > 0 ||
53
+ r.repairedManifests.length > 0 ||
54
+ r.refreshedPlugins.length > 0);
55
+ }
56
+ /** One-line summary of a heal pass for daemon logs. */
57
+ export function summarizeHeal(r) {
58
+ const parts = [];
59
+ const healed = totalHealed(r);
60
+ if (healed > 0)
61
+ parts.push(`${healed} resource(s) healed`);
62
+ if (r.repairedManifests.length > 0)
63
+ parts.push(`${r.repairedManifests.length} manifest(s) repaired`);
64
+ if (r.refreshedPlugins.length > 0)
65
+ parts.push(`${r.refreshedPlugins.length} plugin(s) refreshed`);
66
+ if (r.skippedPlugins.length > 0)
67
+ parts.push(`${r.skippedPlugins.length} plugin(s) need attention`);
68
+ return parts.length > 0 ? parts.join(', ') : 'nothing to heal';
69
+ }
70
+ /**
71
+ * Fire a native desktop notification when a background heal did something
72
+ * noteworthy. Best-effort — missing `osascript`/`notify-send` or no display is
73
+ * swallowed. Silent when the pass auto-fixed everything and nothing needs the
74
+ * operator (no point pinging them for routine self-healing).
75
+ */
76
+ export function notifyHeal(r) {
77
+ const needsAttention = r.skippedPlugins.length;
78
+ const healed = totalHealed(r) + r.repairedManifests.length + r.refreshedPlugins.length;
79
+ if (needsAttention === 0 && healed === 0)
80
+ return;
81
+ const title = needsAttention > 0
82
+ ? `agents: ${needsAttention} plugin${needsAttention === 1 ? '' : 's'} need attention`
83
+ : 'agents: auto-healed config gaps';
84
+ const body = needsAttention > 0
85
+ ? `${summarizeHeal(r)}. Run: agents doctor --fix`
86
+ : summarizeHeal(r);
87
+ const platform = os.platform();
88
+ try {
89
+ if (platform === 'darwin') {
90
+ const safeTitle = title.replace(/"/g, '\\"');
91
+ const safeBody = body.replace(/"/g, '\\"');
92
+ const child = spawn('osascript', ['-e', `display notification "${safeBody}" with title "${safeTitle}"`], { detached: true, stdio: 'ignore' });
93
+ child.unref();
94
+ }
95
+ else if (platform === 'linux') {
96
+ const child = spawn('notify-send', [title, body], { detached: true, stdio: 'ignore' });
97
+ child.unref();
98
+ }
99
+ }
100
+ catch {
101
+ // Notification is best-effort; nothing to do.
102
+ }
103
+ }
104
+ // ─── central plugin layer (version-independent, runs once per heal) ──────────
105
+ /**
106
+ * Strip Claude-invalid bare-name `skills`/`commands` fields from every central
107
+ * plugin's SOURCE plugin.json. Unambiguously safe (Claude auto-discovers both
108
+ * from their directories) and the precondition for those plugins loading at all.
109
+ */
110
+ export function repairCentralPluginManifests(dryRun = false) {
111
+ const out = [];
112
+ for (const p of discoverPlugins()) {
113
+ const manifestPath = path.join(p.root, '.claude-plugin', 'plugin.json');
114
+ const dropped = repairPluginManifestFile(manifestPath, { dryRun });
115
+ if (dropped.length > 0)
116
+ out.push({ plugin: p.name, droppedFields: dropped });
117
+ }
118
+ return out;
119
+ }
120
+ /**
121
+ * Fast-forward central plugins whose local `.source` upstream now ships a newer
122
+ * version. `allowModified` (full mode) re-pulls regardless of baseline; safe
123
+ * mode refreshes only when the central copy is provably an untouched mirror of
124
+ * its last pull (baseline version === current version) and reports the rest.
125
+ */
126
+ async function refreshStaleCentralPlugins(opts) {
127
+ const refreshed = [];
128
+ const skipped = [];
129
+ for (const p of discoverPlugins()) {
130
+ const info = readPluginSourceInfo(p.root);
131
+ if (!info)
132
+ continue;
133
+ const upstream = getUpstreamManifestVersion(info); // null for git sources
134
+ if (!upstream)
135
+ continue;
136
+ const central = p.manifest.version;
137
+ if (compareVersions(upstream, central) <= 0)
138
+ continue; // central already current
139
+ const baselineKnown = info.version !== undefined;
140
+ const modified = baselineKnown && info.version !== central;
141
+ if (!opts.allowModified) {
142
+ // Safe mode never overwrites a copy it can't prove is pristine.
143
+ if (modified) {
144
+ skipped.push({ plugin: p.name, from: central, upstream, reason: 'modified' });
145
+ continue;
146
+ }
147
+ if (!baselineKnown) {
148
+ skipped.push({ plugin: p.name, from: central, upstream, reason: 'no-baseline' });
149
+ continue;
150
+ }
151
+ }
152
+ if (opts.dryRun) {
153
+ refreshed.push({ plugin: p.name, from: central, to: upstream });
154
+ continue;
155
+ }
156
+ const r = await updatePlugin(p.name);
157
+ if (r.success)
158
+ refreshed.push({ plugin: p.name, from: central, to: upstream });
159
+ }
160
+ return { refreshed, skipped };
161
+ }
162
+ // ─── per-version heal ────────────────────────────────────────────────────────
163
+ function healVersion(agent, version, opts) {
164
+ const result = { agent, version, healed: [], skipped: [] };
165
+ const home = getVersionHomePath(agent, version);
166
+ if (!fs.existsSync(home))
167
+ return result;
168
+ // Always resolve against non-project layers: the global version home is never
169
+ // reconciled against per-cwd project resources (they layer in at launch).
170
+ const diffOpts = { cwd: opts.cwd, excludeProject: true };
171
+ const report = diffVersionResources(agent, version, diffOpts);
172
+ const selection = {};
173
+ // Resources we attempt to write, tracked so the post-write re-diff can tell
174
+ // "actually fixed" from "writer couldn't satisfy the diff" (no false claims).
175
+ const attempted = [];
176
+ for (const rows of Object.values(report.kinds)) {
177
+ for (const row of rows) {
178
+ const isMissing = row.status === 'missing';
179
+ const isDrift = row.status === 'diff';
180
+ if (!isMissing && !isDrift)
181
+ continue;
182
+ if (isDrift && !opts.includeDrift) {
183
+ // Ambiguous content drift — could be a deliberate hand-edit. Report it
184
+ // (the daemon notifies); never silently overwrite in safe mode.
185
+ result.skipped.push({ kind: row.kind, name: row.name, reason: 'drift' });
186
+ continue;
187
+ }
188
+ if (row.kind === 'promptcuts')
189
+ continue; // not version-synced
190
+ if (row.kind === 'rules') {
191
+ selection.memory = 'all';
192
+ attempted.push({ kind: row.kind, name: row.name, was: row.status });
193
+ continue;
194
+ }
195
+ const key = KIND_TO_SELECTION[row.kind];
196
+ if (!key)
197
+ continue;
198
+ (selection[key] ??= []).push(row.name);
199
+ attempted.push({ kind: row.kind, name: row.name, was: row.status });
200
+ }
201
+ }
202
+ // Plugins are presence-only in the diff, so a stale/invalid-but-present plugin
203
+ // mirror never shows as 'diff' — yet its central source just changed (repaired
204
+ // or refreshed). Re-push those into this version's marketplace mirror, but only
205
+ // where the plugin is already installed (don't force-install into a version
206
+ // that opted out). These are verified by the central change, not the re-diff.
207
+ const pluginHealed = [];
208
+ if (opts.changedPlugins.size > 0) {
209
+ const synced = new Set(getActuallySyncedResources(agent, version, diffOpts).plugins);
210
+ const already = new Set(selection.plugins ?? []);
211
+ for (const name of opts.changedPlugins) {
212
+ if (!synced.has(name) || already.has(name))
213
+ continue;
214
+ (selection.plugins ??= []).push(name);
215
+ pluginHealed.push({ kind: 'plugins', name, was: 'diff' });
216
+ }
217
+ }
218
+ const hasWork = Object.keys(selection).length > 0;
219
+ if (!hasWork)
220
+ return result;
221
+ if (opts.dryRun) {
222
+ // No write — report the intended fixes as-is.
223
+ result.healed.push(...attempted, ...pluginHealed);
224
+ return result;
225
+ }
226
+ // Explicit selection => bypasses the manifest fast-guard and writes exactly
227
+ // these names (additive; no orphan-sweep), so nothing outside the gap moves.
228
+ syncResourcesToVersion(agent, version, selection, { cwd: opts.cwd });
229
+ result.healed.push(...pluginHealed);
230
+ // Verify: re-diff and only claim resources that actually flipped to ok. Ones
231
+ // still flagged are reported as 'unreconcilable' so repeated runs converge in
232
+ // messaging instead of "fixing" the same item forever.
233
+ const post = diffVersionResources(agent, version, diffOpts);
234
+ const stillBad = new Set();
235
+ for (const rows of Object.values(post.kinds)) {
236
+ for (const row of rows) {
237
+ if (row.status === 'missing' || row.status === 'diff')
238
+ stillBad.add(`${row.kind}:${row.name}`);
239
+ }
240
+ }
241
+ for (const a of attempted) {
242
+ if (stillBad.has(`${a.kind}:${a.name}`)) {
243
+ result.skipped.push({ kind: a.kind, name: a.name, reason: 'unreconcilable' });
244
+ }
245
+ else {
246
+ result.healed.push(a);
247
+ }
248
+ }
249
+ return result;
250
+ }
251
+ // ─── public entrypoint ────────────────────────────────────────────────────────
252
+ /**
253
+ * Run a heal pass. Repairs the central plugin layer once (manifest + stale
254
+ * refresh), then reconciles every targeted (agent, version) home against its
255
+ * live diff. Returns a full account of what changed (or would, under dryRun).
256
+ */
257
+ export async function heal(opts) {
258
+ const cwd = opts.cwd ?? os.homedir();
259
+ const full = opts.mode === 'full';
260
+ const repairedManifests = repairCentralPluginManifests(opts.dryRun);
261
+ const { refreshed, skipped: skippedPlugins } = await refreshStaleCentralPlugins({
262
+ dryRun: opts.dryRun,
263
+ allowModified: full,
264
+ });
265
+ const changedPlugins = new Set([
266
+ ...repairedManifests.map((r) => r.plugin),
267
+ ...refreshed.map((r) => r.plugin),
268
+ ]);
269
+ const targets = opts.agent
270
+ ? [{ agent: opts.agent, versions: opts.versions ?? listInstalledVersions(opts.agent) }]
271
+ : ALL_AGENT_IDS.map((a) => ({ agent: a, versions: listInstalledVersions(a) }));
272
+ const versions = [];
273
+ for (const t of targets) {
274
+ for (const v of t.versions) {
275
+ versions.push(healVersion(t.agent, v, { cwd, includeDrift: full, changedPlugins, dryRun: opts.dryRun }));
276
+ }
277
+ }
278
+ return { versions, repairedManifests, refreshedPlugins: refreshed, skippedPlugins };
279
+ }
@@ -144,6 +144,23 @@ export declare function unmanagedHookNames(installedHookNames: string[], manifes
144
144
  * shadow/override warnings the registrar path prints.
145
145
  */
146
146
  export declare function listUnmanagedHooksInVersionHome(agent: AgentId, version: string): string[];
147
+ /**
148
+ * Compute the trust hash Codex expects for a single command hook handler, so
149
+ * agents-cli can pre-trust the hooks it registers. Without a matching
150
+ * trusted_hash in [hooks.state], Codex classifies the hook Untrusted and
151
+ * silently drops it in non-interactive (`codex exec`) mode where there is no
152
+ * TUI prompt to approve it.
153
+ *
154
+ * Mirrors command_hook_hash() in codex-rs/hooks/src/engine/discovery.rs +
155
+ * version_for_toml() in codex-rs/config/src/fingerprint.rs:
156
+ * sha256( canonicalJson( NormalizedHookIdentity ) ) prefixed with "sha256:".
157
+ *
158
+ * The identity passes through TOML on the Codex side, which drops None fields
159
+ * (commandWindows, statusMessage, and matcher when absent). `async` is always
160
+ * false (async hooks are not yet supported) and is always present. `timeout`
161
+ * is normalized to >= 1 (Codex: unwrap_or(600).max(1)).
162
+ */
163
+ export declare function computeCodexHookTrustHash(eventKeyLabel: string, command: string, timeout: number, matcher: string | undefined): string;
147
164
  export declare function registerHooksToSettings(agentId: AgentId, versionHome: string, hookManifest?: Record<string, ManifestHook>, agentsDirOverride?: string): {
148
165
  registered: string[];
149
166
  errors: string[];
package/dist/lib/hooks.js CHANGED
@@ -7,6 +7,7 @@
7
7
  * parsing those manifests, registering hooks into agent-native settings files,
8
8
  * and syncing them across version switches.
9
9
  */
10
+ import * as crypto from 'crypto';
10
11
  import * as fs from 'fs';
11
12
  import * as os from 'os';
12
13
  import * as path from 'path';
@@ -148,6 +149,12 @@ const NON_SCRIPT_EXTENSIONS = new Set([
148
149
  '.md', '.markdown', '.rst', '.txt',
149
150
  '.yaml', '.yml', '.json', '.toml', '.ini', '.conf',
150
151
  ]);
152
+ // Documentation siblings of a hook (e.g. `git-guard.md` next to `git-guard.sh`)
153
+ // are human-readable docs the hook never reads at runtime — NOT a data sidecar.
154
+ // Treating them as the hook's `dataFile` made the installer's correct omission
155
+ // of docs look like perpetual drift in `agents doctor` that no sync could fix.
156
+ // Structured siblings (.yaml/.json/.toml/...) remain valid data files.
157
+ const DOC_EXTENSIONS = new Set(['.md', '.markdown', '.rst']);
151
158
  const SCRIPT_EXTENSIONS = new Set([
152
159
  '.sh',
153
160
  '.bash',
@@ -261,7 +268,7 @@ export function listHookEntriesFromDir(dir) {
261
268
  group.find((f) => f.isExec && !NON_SCRIPT_EXTENSIONS.has(f.ext.toLowerCase()));
262
269
  if (!script)
263
270
  continue;
264
- const data = group.find((f) => f !== script);
271
+ const data = group.find((f) => f !== script && !DOC_EXTENSIONS.has(f.ext.toLowerCase()));
265
272
  entries.push({
266
273
  name: base,
267
274
  scriptPath: script.fullPath,
@@ -763,6 +770,71 @@ export function listUnmanagedHooksInVersionHome(agent, version) {
763
770
  // Codex events that support a matcher field (matches tool name or session type).
764
771
  // UserPromptSubmit and Stop never include a matcher.
765
772
  const CODEX_MATCHER_EVENTS = new Set(['PreToolUse', 'PostToolUse', 'SessionStart']);
773
+ // Maps PascalCase hook event names (as written in hooks.json) to the
774
+ // snake_case labels Codex uses in its persisted [hooks.state] keys.
775
+ // Mirrors hook_event_key_label() in codex-rs/hooks/src/lib.rs.
776
+ const CODEX_EVENT_KEY_LABELS = {
777
+ PreToolUse: 'pre_tool_use',
778
+ PermissionRequest: 'permission_request',
779
+ PostToolUse: 'post_tool_use',
780
+ PreCompact: 'pre_compact',
781
+ PostCompact: 'post_compact',
782
+ SessionStart: 'session_start',
783
+ UserPromptSubmit: 'user_prompt_submit',
784
+ SubagentStart: 'subagent_start',
785
+ SubagentStop: 'subagent_stop',
786
+ Stop: 'stop',
787
+ };
788
+ // Recursively sort object keys alphabetically at every level, mirroring
789
+ // canonical_json() in codex-rs/config/src/fingerprint.rs. Codex hashes the
790
+ // canonical JSON form so trust survives key-order differences.
791
+ function canonicalizeForHash(value) {
792
+ if (Array.isArray(value)) {
793
+ return value.map(canonicalizeForHash);
794
+ }
795
+ if (value && typeof value === 'object') {
796
+ const sorted = {};
797
+ for (const key of Object.keys(value).sort()) {
798
+ sorted[key] = canonicalizeForHash(value[key]);
799
+ }
800
+ return sorted;
801
+ }
802
+ return value;
803
+ }
804
+ /**
805
+ * Compute the trust hash Codex expects for a single command hook handler, so
806
+ * agents-cli can pre-trust the hooks it registers. Without a matching
807
+ * trusted_hash in [hooks.state], Codex classifies the hook Untrusted and
808
+ * silently drops it in non-interactive (`codex exec`) mode where there is no
809
+ * TUI prompt to approve it.
810
+ *
811
+ * Mirrors command_hook_hash() in codex-rs/hooks/src/engine/discovery.rs +
812
+ * version_for_toml() in codex-rs/config/src/fingerprint.rs:
813
+ * sha256( canonicalJson( NormalizedHookIdentity ) ) prefixed with "sha256:".
814
+ *
815
+ * The identity passes through TOML on the Codex side, which drops None fields
816
+ * (commandWindows, statusMessage, and matcher when absent). `async` is always
817
+ * false (async hooks are not yet supported) and is always present. `timeout`
818
+ * is normalized to >= 1 (Codex: unwrap_or(600).max(1)).
819
+ */
820
+ export function computeCodexHookTrustHash(eventKeyLabel, command, timeout, matcher) {
821
+ const handler = {
822
+ type: 'command',
823
+ command,
824
+ timeout: Math.max(timeout, 1),
825
+ async: false,
826
+ };
827
+ const identity = {
828
+ event_name: eventKeyLabel,
829
+ hooks: [handler],
830
+ };
831
+ if (matcher !== undefined && matcher !== '') {
832
+ identity.matcher = matcher;
833
+ }
834
+ const canonical = canonicalizeForHash(identity);
835
+ const hex = crypto.createHash('sha256').update(JSON.stringify(canonical), 'utf-8').digest('hex');
836
+ return `sha256:${hex}`;
837
+ }
766
838
  /**
767
839
  * Register hooks as lifecycle events in an agent's config.
768
840
  * Reads hooks.yaml manifest, merges into the agent's config file(s).
@@ -1075,7 +1147,11 @@ function registerHooksForCodex(versionHome, manifest, resolveScript, managedPref
1075
1147
  errors.push(`Failed to write hooks.json: ${err.message}`);
1076
1148
  return { registered, errors };
1077
1149
  }
1078
- // Ensure [features] codex_hooks = true in config.toml
1150
+ // Ensure [features] hooks = true and pre-trust every registered hook in
1151
+ // config.toml. Codex only runs hooks that are enabled AND trusted; in
1152
+ // non-interactive (`codex exec`) mode there is no TUI prompt to approve
1153
+ // them, so an untrusted hook is silently dropped. We compute the same
1154
+ // trust hash Codex would and persist it under [hooks.state].
1079
1155
  try {
1080
1156
  let tomlConfig = {};
1081
1157
  if (fs.existsSync(configPath)) {
@@ -1087,7 +1163,55 @@ function registerHooksForCodex(versionHome, manifest, resolveScript, managedPref
1087
1163
  if (!tomlConfig.features || typeof tomlConfig.features !== 'object') {
1088
1164
  tomlConfig.features = {};
1089
1165
  }
1090
- tomlConfig.features.codex_hooks = true;
1166
+ // Codex 0.116+ feature flag is `hooks` (the legacy `codex_hooks` name is
1167
+ // an unrecognized key that triggers a deprecation error and is ignored).
1168
+ const features = tomlConfig.features;
1169
+ delete features.codex_hooks;
1170
+ features.hooks = true;
1171
+ // Pre-trust hooks. The [hooks.state] key is keyed by the hooks.json path
1172
+ // exactly as Codex resolves it (the absolute CODEX_HOME path), the
1173
+ // snake_case event label, and the per-event group/handler indices — which
1174
+ // must match Codex's parse order, so we iterate the just-written
1175
+ // hooksFile structure in array order.
1176
+ if (!tomlConfig.hooks || typeof tomlConfig.hooks !== 'object') {
1177
+ tomlConfig.hooks = {};
1178
+ }
1179
+ const hooksTable = tomlConfig.hooks;
1180
+ const existingState = hooksTable.state && typeof hooksTable.state === 'object'
1181
+ ? hooksTable.state
1182
+ : {};
1183
+ const hookState = {};
1184
+ for (const [event, eventGroups] of Object.entries(hooksFile.hooks)) {
1185
+ const eventKeyLabel = CODEX_EVENT_KEY_LABELS[event];
1186
+ if (!eventKeyLabel)
1187
+ continue;
1188
+ eventGroups.forEach((group, groupIdx) => {
1189
+ if (!group.hooks)
1190
+ return;
1191
+ group.hooks.forEach((handler, handlerIdx) => {
1192
+ if (handler.type !== 'command')
1193
+ return;
1194
+ const key = `${hooksPath}:${eventKeyLabel}:${groupIdx}:${handlerIdx}`;
1195
+ const trustedHash = computeCodexHookTrustHash(eventKeyLabel, handler.command, handler.timeout, group.matcher);
1196
+ // Preserve a user's explicit `enabled = false` for this exact hook;
1197
+ // only (re)write the trust hash.
1198
+ const prior = existingState[key];
1199
+ const entry = { trusted_hash: trustedHash };
1200
+ if (prior && prior.enabled === false) {
1201
+ entry.enabled = false;
1202
+ }
1203
+ hookState[key] = entry;
1204
+ });
1205
+ });
1206
+ }
1207
+ // Carry forward trust state for any hooks we did not (re)register this
1208
+ // pass — e.g. user-added hooks under a different command path.
1209
+ for (const [key, entry] of Object.entries(existingState)) {
1210
+ if (!(key in hookState)) {
1211
+ hookState[key] = entry;
1212
+ }
1213
+ }
1214
+ hooksTable.state = hookState;
1091
1215
  fs.writeFileSync(configPath, TOML.stringify(tomlConfig), 'utf-8');
1092
1216
  }
1093
1217
  catch (err) {
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Dispatch a headless agent run onto a host over SSH.
3
+ *
4
+ * The run is launched detached (`nohup … &`) writing combined output to a remote
5
+ * log and its exit code to a sibling `.exit` file, so progress survives a dropped
6
+ * connection (followed via offset-tail in progress.ts). This is the offload win:
7
+ * the agent's process/thread/file fan-out happens on the host, not the laptop.
8
+ */
9
+ import type { Host } from './types.js';
10
+ import { type HostTask } from './tasks.js';
11
+ export interface DispatchOptions {
12
+ agent: string;
13
+ prompt: string;
14
+ mode?: string;
15
+ model?: string;
16
+ remoteCwd?: string;
17
+ /** Stream progress and block until completion (default true). */
18
+ follow?: boolean;
19
+ timeoutMs?: number;
20
+ }
21
+ export interface DispatchResult {
22
+ task: HostTask;
23
+ /** Exit code when followed; undefined when detached (--no-follow). */
24
+ exitCode?: number;
25
+ }
26
+ export declare function dispatchToHost(host: Host, opts: DispatchOptions): Promise<DispatchResult>;
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Dispatch a headless agent run onto a host over SSH.
3
+ *
4
+ * The run is launched detached (`nohup … &`) writing combined output to a remote
5
+ * log and its exit code to a sibling `.exit` file, so progress survives a dropped
6
+ * connection (followed via offset-tail in progress.ts). This is the offload win:
7
+ * the agent's process/thread/file fan-out happens on the host, not the laptop.
8
+ */
9
+ import { randomUUID } from 'crypto';
10
+ import { sshExec, shellQuote } from '../ssh-exec.js';
11
+ import { sshTargetFor } from './types.js';
12
+ import { ensureHostReady } from './ready.js';
13
+ import { saveTask, updateTask } from './tasks.js';
14
+ import { followHostTask } from './progress.js';
15
+ // Use $HOME (not ~) so the path is correct whether or not it's quoted and
16
+ // regardless of the run's cwd. Task ids are 8 hex chars, so these paths are
17
+ // injection-safe to interpolate unquoted into remote commands.
18
+ const REMOTE_DIR = '$HOME/.agents/.cache/hosts';
19
+ export async function dispatchToHost(host, opts) {
20
+ const target = sshTargetFor(host);
21
+ const { warnings } = ensureHostReady(host, { agent: opts.agent });
22
+ for (const w of warnings)
23
+ process.stderr.write(`[hosts] warning: ${w}\n`);
24
+ const id = randomUUID().slice(0, 8);
25
+ const remoteLog = `${REMOTE_DIR}/${id}.log`;
26
+ const remoteExit = `${REMOTE_DIR}/${id}.exit`;
27
+ // Inner command run under a login shell so PATH resolves `agents`.
28
+ const runParts = ['agents', 'run', shellQuote(opts.agent), shellQuote(opts.prompt), '--quiet'];
29
+ if (opts.mode)
30
+ runParts.push('--mode', shellQuote(opts.mode));
31
+ if (opts.model)
32
+ runParts.push('--model', shellQuote(opts.model));
33
+ const cwd = opts.remoteCwd ? `cd ${shellQuote(opts.remoteCwd)} && ` : '';
34
+ const inner = `${cwd}${runParts.join(' ')} > ${remoteLog} 2>&1; echo $? > ${remoteExit}`;
35
+ // Outer: ensure dir, launch detached under bash -lc, print the PID.
36
+ const launch = `mkdir -p ${REMOTE_DIR}; nohup bash -lc ${shellQuote(inner)} >/dev/null 2>&1 & echo $!`;
37
+ const res = sshExec(target, launch, { timeoutMs: 30000 });
38
+ if (res.code !== 0) {
39
+ throw new Error(`Failed to launch on "${host.name}": ${(res.stderr || res.stdout).trim() || 'ssh error'}`);
40
+ }
41
+ const pid = parseInt(res.stdout.trim().split('\n').pop() ?? '', 10);
42
+ const task = {
43
+ id,
44
+ host: host.name,
45
+ target,
46
+ agent: opts.agent,
47
+ prompt: opts.prompt,
48
+ pid: Number.isFinite(pid) ? pid : undefined,
49
+ remoteLog,
50
+ remoteExit,
51
+ status: 'running',
52
+ createdAt: new Date().toISOString(),
53
+ };
54
+ saveTask(task);
55
+ if (opts.follow === false) {
56
+ return { task };
57
+ }
58
+ const exitCode = await followHostTask(target, {
59
+ remoteLog,
60
+ remoteExit,
61
+ taskId: id,
62
+ echo: true,
63
+ timeoutMs: opts.timeoutMs,
64
+ });
65
+ const finished = updateTask(id, {
66
+ status: exitCode === 0 ? 'completed' : exitCode === -1 ? 'unknown' : 'failed',
67
+ exitCode: exitCode === -1 ? undefined : exitCode,
68
+ finishedAt: new Date().toISOString(),
69
+ });
70
+ return { task: finished ?? task, exitCode };
71
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Follow a dispatched host run by offset-tailing its remote log.
3
+ *
4
+ * The run writes combined output to a log file on the host and its exit code to
5
+ * a sibling `.exit` file. We poll `tail -c +<offset>` (durable, offset-tracked —
6
+ * a dropped connection resumes from the saved offset) and finish when `.exit`
7
+ * appears. Rich transcript-parser rendering is a fast-follow.
8
+ */
9
+ export interface FollowOptions {
10
+ remoteLog: string;
11
+ remoteExit: string;
12
+ /** Mirror remote output into this task's local log too. */
13
+ taskId: string;
14
+ /** Print streamed output to stdout. */
15
+ echo?: boolean;
16
+ /** Overall wall-clock cap; returns -1 on timeout. */
17
+ timeoutMs?: number;
18
+ pollMs?: number;
19
+ }
20
+ /** Tail the remote log to stdout until the run finishes; return its exit code. */
21
+ export declare function followHostTask(target: string, opts: FollowOptions): Promise<number>;
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Follow a dispatched host run by offset-tailing its remote log.
3
+ *
4
+ * The run writes combined output to a log file on the host and its exit code to
5
+ * a sibling `.exit` file. We poll `tail -c +<offset>` (durable, offset-tracked —
6
+ * a dropped connection resumes from the saved offset) and finish when `.exit`
7
+ * appears. Rich transcript-parser rendering is a fast-follow.
8
+ */
9
+ import * as fs from 'fs';
10
+ import { sshExec } from '../ssh-exec.js';
11
+ import { localLogPath } from './tasks.js';
12
+ function sleep(ms) {
13
+ return new Promise((resolve) => setTimeout(resolve, ms));
14
+ }
15
+ /** Tail the remote log to stdout until the run finishes; return its exit code. */
16
+ export async function followHostTask(target, opts) {
17
+ const pollMs = opts.pollMs ?? 1500;
18
+ const deadline = Date.now() + (opts.timeoutMs ?? 3600_000);
19
+ const local = localLogPath(opts.taskId);
20
+ let offset = 0;
21
+ const drain = () => {
22
+ // remoteLog is a $HOME-prefixed path with a safe (hex) basename — intentionally
23
+ // unquoted so the remote shell expands $HOME.
24
+ const chunk = sshExec(target, `tail -c +${offset + 1} ${opts.remoteLog} 2>/dev/null`, { timeoutMs: 20000 });
25
+ if (chunk.stdout) {
26
+ if (opts.echo)
27
+ process.stdout.write(chunk.stdout);
28
+ try {
29
+ fs.appendFileSync(local, chunk.stdout);
30
+ }
31
+ catch { /* best-effort */ }
32
+ offset += Buffer.byteLength(chunk.stdout, 'utf8');
33
+ }
34
+ };
35
+ for (;;) {
36
+ drain();
37
+ const exit = sshExec(target, `cat ${opts.remoteExit} 2>/dev/null`, { timeoutMs: 12000 });
38
+ if (exit.code === 0 && exit.stdout.trim() !== '') {
39
+ drain(); // final flush
40
+ const code = parseInt(exit.stdout.trim(), 10);
41
+ return Number.isFinite(code) ? code : 0;
42
+ }
43
+ if (Date.now() > deadline) {
44
+ process.stderr.write('\n[hosts] follow timed out; the run continues on the host. Reattach with: agents hosts logs ' + opts.taskId + ' -f\n');
45
+ return -1;
46
+ }
47
+ await sleep(pollMs);
48
+ }
49
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Local host provider: the v1 directory.
3
+ *
4
+ * `list()` is the union of ssh-config `Host` stanzas (read-only, connection
5
+ * details owned by ssh) and inline entries the user registered in agents.yaml.
6
+ * The `Meta.hosts` overlay (caps/os, keyed by name) is merged onto both. We
7
+ * never copy or rewrite ssh config.
8
+ */
9
+ import type { Host, HostProvider, HostProviderCapabilities } from '../types.js';
10
+ export declare class LocalHostProvider implements HostProvider {
11
+ readonly id: "local";
12
+ capabilities(): HostProviderCapabilities;
13
+ list(): Promise<Host[]>;
14
+ resolve(name: string): Promise<Host | null>;
15
+ register(spec: Host): Promise<Host>;
16
+ remove(name: string): Promise<void>;
17
+ }