@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.
- package/CHANGELOG.md +29 -0
- package/dist/commands/doctor.d.ts +5 -2
- package/dist/commands/doctor.js +182 -30
- package/dist/commands/exec.d.ts +1 -1
- package/dist/commands/exec.js +177 -6
- package/dist/commands/hosts.d.ts +11 -0
- package/dist/commands/hosts.js +229 -0
- package/dist/commands/repo.d.ts +29 -0
- package/dist/commands/repo.js +174 -38
- package/dist/commands/secrets.d.ts +2 -7
- package/dist/commands/secrets.js +15 -23
- package/dist/commands/sessions.d.ts +2 -0
- package/dist/commands/sessions.js +27 -25
- package/dist/commands/sync.d.ts +2 -0
- package/dist/commands/sync.js +22 -5
- package/dist/commands/view.js +27 -11
- package/dist/index.js +4 -13
- package/dist/lib/agent-spec.d.ts +36 -0
- package/dist/lib/agent-spec.js +157 -0
- package/dist/lib/agents.d.ts +1 -0
- package/dist/lib/agents.js +45 -4
- package/dist/lib/browser/drivers/ssh.d.ts +47 -2
- package/dist/lib/browser/drivers/ssh.js +113 -24
- package/dist/lib/browser/profiles.js +28 -1
- package/dist/lib/browser/runtime-state.js +28 -8
- package/dist/lib/browser/types.d.ts +10 -1
- package/dist/lib/cli-resources.js +10 -1
- package/dist/lib/daemon.js +32 -0
- package/dist/lib/doctor-diff.d.ts +19 -0
- package/dist/lib/doctor-diff.js +107 -15
- package/dist/lib/exec.d.ts +27 -0
- package/dist/lib/exec.js +62 -19
- package/dist/lib/heal.d.ts +107 -0
- package/dist/lib/heal.js +279 -0
- package/dist/lib/hooks.d.ts +17 -0
- package/dist/lib/hooks.js +127 -3
- package/dist/lib/hosts/dispatch.d.ts +26 -0
- package/dist/lib/hosts/dispatch.js +71 -0
- package/dist/lib/hosts/progress.d.ts +21 -0
- package/dist/lib/hosts/progress.js +49 -0
- package/dist/lib/hosts/providers/local.d.ts +17 -0
- package/dist/lib/hosts/providers/local.js +81 -0
- package/dist/lib/hosts/ready.d.ts +37 -0
- package/dist/lib/hosts/ready.js +88 -0
- package/dist/lib/hosts/registry.d.ts +22 -0
- package/dist/lib/hosts/registry.js +65 -0
- package/dist/lib/hosts/ssh-config.d.ts +37 -0
- package/dist/lib/hosts/ssh-config.js +157 -0
- package/dist/lib/hosts/tasks.d.ts +32 -0
- package/dist/lib/hosts/tasks.js +58 -0
- package/dist/lib/hosts/types.d.ts +51 -0
- package/dist/lib/hosts/types.js +21 -0
- package/dist/lib/loop.d.ts +9 -0
- package/dist/lib/loop.js +13 -1
- package/dist/lib/mcp.js +12 -3
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/migrate.js +9 -5
- package/dist/lib/platform/exec.d.ts +10 -0
- package/dist/lib/platform/exec.js +17 -0
- package/dist/lib/platform/index.d.ts +1 -0
- package/dist/lib/platform/index.js +1 -0
- package/dist/lib/platform/links.d.ts +15 -0
- package/dist/lib/platform/links.js +42 -0
- package/dist/lib/platform/paths.d.ts +18 -0
- package/dist/lib/platform/paths.js +22 -0
- package/dist/lib/platform/posixpath.d.ts +28 -0
- package/dist/lib/platform/posixpath.js +153 -0
- package/dist/lib/plugin-marketplace.d.ts +18 -0
- package/dist/lib/plugin-marketplace.js +67 -1
- package/dist/lib/plugins.d.ts +33 -1
- package/dist/lib/plugins.js +56 -11
- package/dist/lib/project-launch.js +6 -3
- package/dist/lib/sandbox.js +5 -2
- package/dist/lib/self-update.js +7 -2
- package/dist/lib/session/db.d.ts +23 -0
- package/dist/lib/session/db.js +76 -1
- package/dist/lib/session/discover.d.ts +26 -0
- package/dist/lib/session/discover.js +75 -4
- package/dist/lib/session/relative-time.d.ts +7 -0
- package/dist/lib/session/relative-time.js +28 -0
- package/dist/lib/session/remote.d.ts +61 -0
- package/dist/lib/session/remote.js +221 -0
- package/dist/lib/ssh-exec.d.ts +45 -0
- package/dist/lib/ssh-exec.js +61 -0
- package/dist/lib/staleness/detectors/commands.js +7 -6
- package/dist/lib/staleness/writers/commands.js +7 -12
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +2 -0
- package/dist/lib/startup/dev-build.d.ts +22 -0
- package/dist/lib/startup/dev-build.js +41 -0
- package/dist/lib/types.d.ts +28 -0
- package/dist/lib/versions.d.ts +9 -3
- package/dist/lib/versions.js +43 -7
- package/package.json +3 -3
- package/scripts/postinstall.js +62 -0
package/dist/lib/heal.js
ADDED
|
@@ -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
|
+
}
|
package/dist/lib/hooks.d.ts
CHANGED
|
@@ -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]
|
|
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
|
-
|
|
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
|
+
}
|