@phnx-labs/agents-cli 1.22.23 → 1.22.24
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 +193 -0
- package/README.md +5 -4
- package/dist/bin/agents +0 -0
- package/dist/commands/attach.d.ts +2 -0
- package/dist/commands/attach.js +32 -7
- package/dist/commands/defaults.js +2 -0
- package/dist/commands/doctor.js +5 -0
- package/dist/commands/exec.js +80 -28
- package/dist/commands/feed.d.ts +18 -0
- package/dist/commands/feed.js +44 -1
- package/dist/commands/focus.d.ts +34 -3
- package/dist/commands/focus.js +300 -56
- package/dist/commands/go.d.ts +14 -1
- package/dist/commands/go.js +49 -5
- package/dist/commands/insights.js +6 -2
- package/dist/commands/inspect.js +39 -5
- package/dist/commands/menubar.js +6 -1
- package/dist/commands/models.js +1 -0
- package/dist/commands/modes.d.ts +12 -0
- package/dist/commands/modes.js +147 -0
- package/dist/commands/secrets-sync.js +11 -13
- package/dist/commands/secrets.d.ts +2 -0
- package/dist/commands/secrets.js +54 -17
- package/dist/commands/sessions-browser.d.ts +35 -0
- package/dist/commands/sessions-browser.js +140 -14
- package/dist/commands/sessions-resume.d.ts +2 -0
- package/dist/commands/sessions-resume.js +26 -11
- package/dist/commands/sessions.d.ts +10 -0
- package/dist/commands/sessions.js +34 -59
- package/dist/commands/setup-secrets.js +1 -1
- package/dist/commands/sync.js +246 -42
- package/dist/commands/view.js +2 -0
- package/dist/index.js +2 -1
- package/dist/lib/agent-modes.d.ts +49 -0
- package/dist/lib/agent-modes.js +70 -0
- package/dist/lib/doctor-diff.d.ts +3 -0
- package/dist/lib/doctor-diff.js +15 -13
- package/dist/lib/event-stream.d.ts +3 -1
- package/dist/lib/event-stream.js +14 -1
- package/dist/lib/exec.d.ts +12 -0
- package/dist/lib/exec.js +88 -9
- package/dist/lib/hooks/cache.js +36 -3
- package/dist/lib/hooks.d.ts +19 -7
- package/dist/lib/hooks.js +88 -38
- package/dist/lib/hosts/session-index.d.ts +4 -0
- package/dist/lib/hosts/session-index.js +7 -0
- package/dist/lib/manifest.d.ts +12 -2
- package/dist/lib/manifest.js +60 -5
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +5 -1
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +15 -2
- package/dist/lib/menubar/install-menubar.d.ts +52 -2
- package/dist/lib/menubar/install-menubar.js +128 -6
- package/dist/lib/refresh.d.ts +5 -0
- package/dist/lib/refresh.js +37 -33
- package/dist/lib/resource-inventory.d.ts +79 -0
- package/dist/lib/resource-inventory.js +122 -0
- package/dist/lib/resources.js +8 -5
- package/dist/lib/run-defaults.d.ts +2 -0
- package/dist/lib/run-defaults.js +23 -2
- package/dist/lib/runner.js +22 -17
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +2 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +13 -1
- package/dist/lib/secrets/bundles.js +157 -65
- package/dist/lib/secrets/filestore.d.ts +5 -3
- package/dist/lib/secrets/filestore.js +12 -8
- package/dist/lib/secrets/index.js +34 -8
- package/dist/lib/secrets/reaper.d.ts +10 -0
- package/dist/lib/secrets/reaper.js +50 -15
- package/dist/lib/secrets/sync-passphrase.d.ts +27 -0
- package/dist/lib/secrets/sync-passphrase.js +78 -0
- package/dist/lib/session/recovery.d.ts +37 -0
- package/dist/lib/session/recovery.js +95 -0
- package/dist/lib/shims.d.ts +2 -2
- package/dist/lib/shims.js +24 -5
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +2 -0
- package/dist/lib/sync-umbrella.d.ts +5 -0
- package/dist/lib/sync-umbrella.js +5 -4
- package/dist/lib/tmux/session.d.ts +4 -2
- package/dist/lib/tmux/session.js +5 -5
- package/dist/lib/types.d.ts +2 -0
- package/package.json +1 -1
package/dist/lib/doctor-diff.js
CHANGED
|
@@ -33,7 +33,8 @@ import { shouldInstallCommandAsSkill, commandSkillMatches, commandSkillName } fr
|
|
|
33
33
|
import { gooseCommandMatches, gooseCommandsDir } from './goose-commands.js';
|
|
34
34
|
import { supports } from './capabilities.js';
|
|
35
35
|
import { listSkillsInVersionHome, getVersionSkillsDir } from './skills.js';
|
|
36
|
-
import {
|
|
36
|
+
import { listHookEntriesFromDir } from './hooks.js';
|
|
37
|
+
import { getResourceInventory } from './resource-inventory.js';
|
|
37
38
|
const RULES_DOC_FILENAME = 'README.md';
|
|
38
39
|
const ALL_KINDS = [
|
|
39
40
|
'commands',
|
|
@@ -300,11 +301,10 @@ function diffSkills(agent, version, cwd, excludeProject = false) {
|
|
|
300
301
|
return rows.sort((a, b) => a.name.localeCompare(b.name));
|
|
301
302
|
}
|
|
302
303
|
// ─── hooks ────────────────────────────────────────────────────────────────────
|
|
303
|
-
function diffHooks(agent, version, cwd) {
|
|
304
|
+
function diffHooks(agent, version, cwd, inventory) {
|
|
304
305
|
if (!AGENTS[agent].supportsHooks)
|
|
305
306
|
return [];
|
|
306
|
-
const
|
|
307
|
-
const installedByName = new Map(installedEntries.map((e) => [e.name, e]));
|
|
307
|
+
const installedByName = new Map(inventory.onDisk.map((e) => [e.name, e]));
|
|
308
308
|
// Sync intentionally excludes project/.agents/hooks/ — mirror that.
|
|
309
309
|
const layerBases = buildLayerBases(cwd, 'hooks', { excludeProject: true });
|
|
310
310
|
// Group source files the same way the hook installer does (basename across
|
|
@@ -329,14 +329,14 @@ function diffHooks(agent, version, cwd) {
|
|
|
329
329
|
continue;
|
|
330
330
|
}
|
|
331
331
|
const a = readSafe(src.entry.scriptPath);
|
|
332
|
-
const b = readSafe(installed.
|
|
332
|
+
const b = readSafe(installed.path);
|
|
333
333
|
let matches = a != null && b != null && normalize(a) === normalize(b);
|
|
334
|
-
if (matches && src.entry.dataFile && installed.
|
|
334
|
+
if (matches && src.entry.dataFile && installed.detail) {
|
|
335
335
|
const ad = readSafe(src.entry.dataFile);
|
|
336
|
-
const bd = readSafe(installed.
|
|
336
|
+
const bd = readSafe(installed.detail);
|
|
337
337
|
matches = ad != null && bd != null && normalize(ad) === normalize(bd);
|
|
338
338
|
}
|
|
339
|
-
else if (matches && (!!src.entry.dataFile !== !!installed.
|
|
339
|
+
else if (matches && (!!src.entry.dataFile !== !!installed.detail)) {
|
|
340
340
|
matches = false;
|
|
341
341
|
}
|
|
342
342
|
rows.push({
|
|
@@ -345,13 +345,13 @@ function diffHooks(agent, version, cwd) {
|
|
|
345
345
|
status: matches ? 'ok' : 'diff',
|
|
346
346
|
source: src.layer,
|
|
347
347
|
sourcePath: src.entry.scriptPath,
|
|
348
|
-
homePath: installed.
|
|
348
|
+
homePath: installed.path,
|
|
349
349
|
});
|
|
350
350
|
}
|
|
351
351
|
for (const [name, installed] of installedByName) {
|
|
352
352
|
if (seen.has(name))
|
|
353
353
|
continue;
|
|
354
|
-
rows.push({ kind: 'hooks', name, status: 'extra', homePath: installed.
|
|
354
|
+
rows.push({ kind: 'hooks', name, status: 'extra', homePath: installed.path });
|
|
355
355
|
}
|
|
356
356
|
return rows.sort((a, b) => a.name.localeCompare(b.name));
|
|
357
357
|
}
|
|
@@ -609,12 +609,13 @@ export function diffVersionResources(agent, version, options = {}) {
|
|
|
609
609
|
empty.commands = diffCommands(agent, version, cwd, excludeProject);
|
|
610
610
|
if (requested.has('skills'))
|
|
611
611
|
empty.skills = diffSkills(agent, version, cwd, excludeProject);
|
|
612
|
-
|
|
613
|
-
|
|
612
|
+
const hookInventory = requested.has('hooks') ? getResourceInventory(agent, version, 'hooks', { cwd }) : undefined;
|
|
613
|
+
if (hookInventory)
|
|
614
|
+
empty.hooks = diffHooks(agent, version, cwd, hookInventory);
|
|
614
615
|
// Wiring check: a hook FILE can be present and byte-identical to source (ok
|
|
615
616
|
// above) yet never referenced in settings.json, so it never fires. Only
|
|
616
617
|
// meaningful when hooks are in scope.
|
|
617
|
-
const hookWiring =
|
|
618
|
+
const hookWiring = hookInventory?.wiring;
|
|
618
619
|
if (requested.has('rules'))
|
|
619
620
|
empty.rules = diffRules(agent, version, cwd, excludeProject);
|
|
620
621
|
if (requested.has('mcp'))
|
|
@@ -654,6 +655,7 @@ export function diffVersionResources(agent, version, options = {}) {
|
|
|
654
655
|
kinds: empty,
|
|
655
656
|
summary: { ok, diff, missing, extra },
|
|
656
657
|
...(hookWiring ? { hookWiring } : {}),
|
|
658
|
+
...(hookInventory ? { hookInventory } : {}),
|
|
657
659
|
};
|
|
658
660
|
}
|
|
659
661
|
export const DOCTOR_ALL_KINDS = ALL_KINDS;
|
|
@@ -36,6 +36,8 @@ export interface UnifiedQuery {
|
|
|
36
36
|
* Read a unified, newest-first event stream. Operational events come from
|
|
37
37
|
* events.ts `query()`; agent-semantic events from the activity logs, normalized
|
|
38
38
|
* to the same record shape and filtered identically. `limit` caps the merged
|
|
39
|
-
* result (each source is fetched up to `limit
|
|
39
|
+
* result (each source is fetched up to `limit` *after* its primary filters —
|
|
40
|
+
* eventTypes for activity, eventTypes/module/bundle for ops — so the top-N is
|
|
41
|
+
* exact for those filters).
|
|
40
42
|
*/
|
|
41
43
|
export declare function readUnifiedEvents(q?: UnifiedQuery): EventRecord[];
|
package/dist/lib/event-stream.js
CHANGED
|
@@ -46,7 +46,9 @@ function matches(r, q) {
|
|
|
46
46
|
* Read a unified, newest-first event stream. Operational events come from
|
|
47
47
|
* events.ts `query()`; agent-semantic events from the activity logs, normalized
|
|
48
48
|
* to the same record shape and filtered identically. `limit` caps the merged
|
|
49
|
-
* result (each source is fetched up to `limit
|
|
49
|
+
* result (each source is fetched up to `limit` *after* its primary filters —
|
|
50
|
+
* eventTypes for activity, eventTypes/module/bundle for ops — so the top-N is
|
|
51
|
+
* exact for those filters).
|
|
50
52
|
*/
|
|
51
53
|
export function readUnifiedEvents(q = {}) {
|
|
52
54
|
// `bundle` is filtered inside query()'s scan (before its limit cutoff) so a
|
|
@@ -66,10 +68,21 @@ export function readUnifiedEvents(q = {}) {
|
|
|
66
68
|
});
|
|
67
69
|
if (q.includeActivity === false)
|
|
68
70
|
return ops;
|
|
71
|
+
// Activity events always stamp module: 'activity'. A non-activity module
|
|
72
|
+
// filter can never match them — skip the activity scan entirely.
|
|
73
|
+
if (q.module != null && q.module !== 'activity')
|
|
74
|
+
return ops;
|
|
75
|
+
// Push eventTypes into the activity reader so `limit` is applied AFTER the
|
|
76
|
+
// event-type filter (readRecentActivity already does this for `events`).
|
|
77
|
+
// Without this, a rare match older than the newest-`limit` window of routine
|
|
78
|
+
// churn is silently dropped — the same class of bug as the ops-side bundle
|
|
79
|
+
// pre-filter above (RUSH-2093). Remaining filters (agent, sessionId, …) still
|
|
80
|
+
// run via matches() for fields activity.ts does not pre-filter.
|
|
69
81
|
const acts = readActivityAsEventRecords({
|
|
70
82
|
sinceMs: q.startDate?.getTime(),
|
|
71
83
|
limit: q.limit,
|
|
72
84
|
root: q.activityRoot,
|
|
85
|
+
events: q.eventTypes,
|
|
73
86
|
}).filter((r) => matches(r, q));
|
|
74
87
|
const merged = [...ops, ...acts].sort((a, b) => Date.parse(b.ts) - Date.parse(a.ts));
|
|
75
88
|
return typeof q.limit === 'number' ? merged.slice(0, q.limit) : merged;
|
package/dist/lib/exec.d.ts
CHANGED
|
@@ -389,7 +389,19 @@ export declare function shouldWrapInTmux(ctx: TmuxWrapContext): boolean;
|
|
|
389
389
|
*/
|
|
390
390
|
export declare function buildTmuxAgentCommand(executable: string, args: string[], env: NodeJS.ProcessEnv, opts?: {
|
|
391
391
|
redactEnvValues?: boolean;
|
|
392
|
+
envFile?: string;
|
|
392
393
|
}): string;
|
|
394
|
+
/**
|
|
395
|
+
* Serialize the pane env to a shell-sourceable file, created 0600 and exclusively
|
|
396
|
+
* (`wx`) so it can never adopt a pre-existing file's mode or content.
|
|
397
|
+
*
|
|
398
|
+
* EVERY key goes in the file, not a curated "secret-bearing" subset: a denylist
|
|
399
|
+
* has to be updated for each new credential and is wrong the moment someone
|
|
400
|
+
* forgets, whereas routing all of it through the file makes the guarantee hold by
|
|
401
|
+
* construction. Keys are filtered to valid shell identifiers for the same reason
|
|
402
|
+
* `env` needed it — an exported function (`BASH_FUNC_x%%`) is not assignable.
|
|
403
|
+
*/
|
|
404
|
+
export declare function writeTmuxEnvFile(env: NodeJS.ProcessEnv, filePath: string): void;
|
|
393
405
|
/**
|
|
394
406
|
* Trim a raw `tmux capture-pane` dump to its last `maxLines` non-empty lines
|
|
395
407
|
* (right-stripping each). Used by runInTmux to recap a fast-failed agent's
|
package/dist/lib/exec.js
CHANGED
|
@@ -17,7 +17,7 @@ import { isTierToken, resolveTier } from './model-tiers.js';
|
|
|
17
17
|
import { createTimer, redactPrompt, redactArgs } from './events.js';
|
|
18
18
|
import { sanitizeProcessEnv } from './secrets/bundles.js';
|
|
19
19
|
import { resolveActor, actorEnv } from './actor.js';
|
|
20
|
-
import { getShimsDir, getHistoryDir } from './state.js';
|
|
20
|
+
import { getShimsDir, getHistoryDir, getRuntimeStateDir } from './state.js';
|
|
21
21
|
import { resolveCodexHome } from './codex-home.js';
|
|
22
22
|
import { readCodexConfiguredModel } from './shims.js';
|
|
23
23
|
import { writePidSessionEntry, extractSessionIdArg } from './session/pid-registry.js';
|
|
@@ -362,6 +362,28 @@ export function buildExecEnv(options) {
|
|
|
362
362
|
delete result.CODEX_HOME;
|
|
363
363
|
delete result.COPILOT_HOME;
|
|
364
364
|
}
|
|
365
|
+
else if (options.agent === 'muse') {
|
|
366
|
+
// Muse has no MUSE_CONFIG_DIR. Config is XDG-based:
|
|
367
|
+
// $XDG_CONFIG_HOME/muse (settings, skills, hooks, auth)
|
|
368
|
+
// $XDG_DATA_HOME/muse (sessions, plugins)
|
|
369
|
+
// Pin both into the version home so multi-version isolation matches
|
|
370
|
+
// Claude's CLAUDE_CONFIG_DIR / Codex's CODEX_HOME, and so Muse never
|
|
371
|
+
// resolves through the adopt-time ~/.config/muse symlink (SymlinkOrReparse).
|
|
372
|
+
const cwd = options.cwd || process.cwd();
|
|
373
|
+
const resolvedVersion = options.version ?? resolveVersion('muse', cwd);
|
|
374
|
+
const version = options.version
|
|
375
|
+
? resolvedVersion
|
|
376
|
+
: (resolvedVersion && isVersionInstalled('muse', resolvedVersion) ? resolvedVersion : null);
|
|
377
|
+
if (version) {
|
|
378
|
+
const versionHome = getVersionHomePath('muse', version);
|
|
379
|
+
result.XDG_CONFIG_HOME = path.join(versionHome, '.config');
|
|
380
|
+
result.XDG_DATA_HOME = path.join(versionHome, '.local', 'share');
|
|
381
|
+
}
|
|
382
|
+
delete result.CLAUDE_CONFIG_DIR;
|
|
383
|
+
delete result.CODEX_HOME;
|
|
384
|
+
delete result.COPILOT_HOME;
|
|
385
|
+
delete result.KIMI_CODE_HOME;
|
|
386
|
+
}
|
|
365
387
|
else {
|
|
366
388
|
delete result.CLAUDE_CONFIG_DIR;
|
|
367
389
|
delete result.CODEX_HOME;
|
|
@@ -1167,13 +1189,53 @@ export function shouldWrapInTmux(ctx) {
|
|
|
1167
1189
|
* real values; the stored/informational copy uses the redacted form (RUSH-1758).
|
|
1168
1190
|
*/
|
|
1169
1191
|
export function buildTmuxAgentCommand(executable, args, env, opts = {}) {
|
|
1192
|
+
const agentCmd = [executable, ...args].map(shellQuote).join(' ');
|
|
1193
|
+
// envFile: source the values instead of inlining them, so no VALUE ever lands
|
|
1194
|
+
// in the pane's argv. `exec env K=V …` put every resolved secret into the
|
|
1195
|
+
// process table, readable by any process of this user — on one fleet box six
|
|
1196
|
+
// live processes carried the secrets-store master passphrase, which decrypts
|
|
1197
|
+
// every file-backed bundle including the Claude OAuth tokens (RUSH-2100).
|
|
1198
|
+
// `set -a` exports what the file assigns; the file is unlinked before `exec`,
|
|
1199
|
+
// so it exists only for the sourcing itself. A missing file aborts the pane
|
|
1200
|
+
// rather than silently launching with a half-built env.
|
|
1201
|
+
if (opts.envFile) {
|
|
1202
|
+
const f = shellQuote(opts.envFile);
|
|
1203
|
+
// Remove the file whether or not sourcing succeeds — a bare `. f || exit 1`
|
|
1204
|
+
// strands the plaintext env (incl. the secrets-store master passphrase) on
|
|
1205
|
+
// disk on any source failure, worse than the argv leak this replaces
|
|
1206
|
+
// (RUSH-2100). Capture the source rc, unlink, then honor it.
|
|
1207
|
+
return `set -a; . ${f}; __agents_rc=$?; set +a; rm -f ${f}; [ "$__agents_rc" -eq 0 ] || exit 1; exec ${agentCmd}`;
|
|
1208
|
+
}
|
|
1170
1209
|
const envPrefix = Object.entries(env)
|
|
1171
1210
|
.filter(([k, v]) => v !== undefined && EXEC_ENV_KEY_PATTERN.test(k))
|
|
1172
1211
|
.map(([k, v]) => `${k}=${opts.redactEnvValues ? '<redacted>' : shellQuote(String(v))}`)
|
|
1173
1212
|
.join(' ');
|
|
1174
|
-
const agentCmd = [executable, ...args].map(shellQuote).join(' ');
|
|
1175
1213
|
return `exec env ${envPrefix} ${agentCmd}`;
|
|
1176
1214
|
}
|
|
1215
|
+
/**
|
|
1216
|
+
* Serialize the pane env to a shell-sourceable file, created 0600 and exclusively
|
|
1217
|
+
* (`wx`) so it can never adopt a pre-existing file's mode or content.
|
|
1218
|
+
*
|
|
1219
|
+
* EVERY key goes in the file, not a curated "secret-bearing" subset: a denylist
|
|
1220
|
+
* has to be updated for each new credential and is wrong the moment someone
|
|
1221
|
+
* forgets, whereas routing all of it through the file makes the guarantee hold by
|
|
1222
|
+
* construction. Keys are filtered to valid shell identifiers for the same reason
|
|
1223
|
+
* `env` needed it — an exported function (`BASH_FUNC_x%%`) is not assignable.
|
|
1224
|
+
*/
|
|
1225
|
+
export function writeTmuxEnvFile(env, filePath) {
|
|
1226
|
+
const body = Object.entries(env)
|
|
1227
|
+
.filter(([k, v]) => v !== undefined && EXEC_ENV_KEY_PATTERN.test(k))
|
|
1228
|
+
.map(([k, v]) => `${k}=${shellQuote(String(v))}`)
|
|
1229
|
+
.join('\n');
|
|
1230
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
1231
|
+
const fd = fs.openSync(filePath, 'wx', 0o600);
|
|
1232
|
+
try {
|
|
1233
|
+
fs.writeSync(fd, `${body}\n`);
|
|
1234
|
+
}
|
|
1235
|
+
finally {
|
|
1236
|
+
fs.closeSync(fd);
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1177
1239
|
/**
|
|
1178
1240
|
* Trim a raw `tmux capture-pane` dump to its last `maxLines` non-empty lines
|
|
1179
1241
|
* (right-stripping each). Used by runInTmux to recap a fast-failed agent's
|
|
@@ -1231,15 +1293,32 @@ async function runInTmux(options, executable, args) {
|
|
|
1231
1293
|
// SessionStart learns some harness IDs only after launch. Carry the wrapper
|
|
1232
1294
|
// name into that hook so it can bind both identities durably.
|
|
1233
1295
|
const execEnv = { ...buildExecEnv(options), AGENT_TMUX_SESSION_NAME: name };
|
|
1234
|
-
//
|
|
1235
|
-
//
|
|
1236
|
-
//
|
|
1237
|
-
|
|
1296
|
+
// The pane sources its env from a 0600 file it unlinks before exec, so no
|
|
1297
|
+
// resolved secret VALUE reaches the process table (RUSH-2100). SessionMeta.cmd
|
|
1298
|
+
// keeps the value-redacted inline form — the human-readable record of what ran,
|
|
1299
|
+
// which never carried real values anyway (RUSH-1758).
|
|
1300
|
+
const envFile = path.join(getRuntimeStateDir(), 'tmux-env', `${name}-${randomUUID().slice(0, 8)}.env`);
|
|
1301
|
+
writeTmuxEnvFile(execEnv, envFile);
|
|
1302
|
+
const cmd = buildTmuxAgentCommand(executable, args, execEnv, { envFile });
|
|
1238
1303
|
const metaCmd = buildTmuxAgentCommand(executable, args, execEnv, { redactEnvValues: true });
|
|
1239
1304
|
const labels = { agent: options.agent };
|
|
1240
1305
|
if (options.sessionId)
|
|
1241
1306
|
labels.sessionId = options.sessionId;
|
|
1242
|
-
|
|
1307
|
+
// Only a launched pane sources-and-unlinks the env file. If createSession
|
|
1308
|
+
// throws, the pane never runs, so the resolved secrets (incl. the master
|
|
1309
|
+
// passphrase) would linger on disk — remove it on that failure path
|
|
1310
|
+
// (RUSH-2100). On success the detached pane owns the unlink.
|
|
1311
|
+
let meta;
|
|
1312
|
+
try {
|
|
1313
|
+
meta = await createSession({ name, cmd, metaCmd, cwd, socket, source: 'cli', labels });
|
|
1314
|
+
}
|
|
1315
|
+
catch (err) {
|
|
1316
|
+
try {
|
|
1317
|
+
fs.rmSync(envFile, { force: true });
|
|
1318
|
+
}
|
|
1319
|
+
catch { /* best-effort */ }
|
|
1320
|
+
throw err;
|
|
1321
|
+
}
|
|
1243
1322
|
const pane = meta.pane;
|
|
1244
1323
|
if (options.sessionId)
|
|
1245
1324
|
writeSessionAliasRecord(options.sessionId, name);
|
|
@@ -1317,7 +1396,7 @@ async function runInTmux(options, executable, args) {
|
|
|
1317
1396
|
};
|
|
1318
1397
|
// The agent could exit before we attach (fast failure). Don't attach to an
|
|
1319
1398
|
// already-dead pane — surface its output + status directly and tear down.
|
|
1320
|
-
const before = pane ? await paneExitStatus(pane, socket) : { dead: false };
|
|
1399
|
+
const before = pane ? await paneExitStatus(pane, socket) : { found: false, dead: false, status: undefined };
|
|
1321
1400
|
if (before.dead) {
|
|
1322
1401
|
// F2 (RUSH-2185 / EXEC-23a): for interactive runs, ALWAYS recap — a clean
|
|
1323
1402
|
// exit-0 before attach means the harness has no interactive REPL and the
|
|
@@ -1343,7 +1422,7 @@ async function runInTmux(options, executable, args) {
|
|
|
1343
1422
|
return false;
|
|
1344
1423
|
}
|
|
1345
1424
|
};
|
|
1346
|
-
const after = pane ? await paneExitStatus(pane, socket) : { dead: false };
|
|
1425
|
+
const after = pane ? await paneExitStatus(pane, socket) : { found: false, dead: false, status: undefined };
|
|
1347
1426
|
if (after.dead) {
|
|
1348
1427
|
// Nonzero exit after attach → the agent crashed rather than the user
|
|
1349
1428
|
// detaching cleanly (a clean detach leaves the pane ALIVE, handled below).
|
package/dist/lib/hooks/cache.js
CHANGED
|
@@ -463,6 +463,9 @@ except Exception: pass' 2>/dev/null || true)"
|
|
|
463
463
|
;;
|
|
464
464
|
esac
|
|
465
465
|
CACHE_FILE="$CACHE_DIR/$HOOK_NAME$cache_suffix.out"
|
|
466
|
+
# Failure sentinel: touched on bg-refresh failure; mtime drives the backoff window.
|
|
467
|
+
FAIL_FILE="$CACHE_FILE.fail"
|
|
468
|
+
BACKOFF_SEC=60
|
|
466
469
|
|
|
467
470
|
# Monotonic-ish nanosecond timer (macOS \`date\` has no %N).
|
|
468
471
|
now_ns() { "$PY" -c 'import time; print(int(time.time()*1e9))'; }
|
|
@@ -491,9 +494,38 @@ if [ "$CACHE_STATUS" = miss ]; then
|
|
|
491
494
|
# Stale-while-revalidate: serve stale immediately, refresh in detached child.
|
|
492
495
|
cat "$CACHE_FILE"
|
|
493
496
|
CACHE_STATUS=stale-prefetch
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
+
# Fix 2: backoff — skip bg refresh if last attempt failed within BACKOFF_SEC.
|
|
498
|
+
_in_backoff=0
|
|
499
|
+
if [ -f "$FAIL_FILE" ]; then
|
|
500
|
+
_fail_mtime=$("$PY" -c 'import os,sys; print(int(os.path.getmtime(sys.argv[1])))' "$FAIL_FILE" 2>/dev/null || echo 0)
|
|
501
|
+
_now_s=$(date +%s)
|
|
502
|
+
_fail_age=$((_now_s - \${_fail_mtime:-0}))
|
|
503
|
+
[ "$_fail_age" -lt "$BACKOFF_SEC" ] && _in_backoff=1
|
|
504
|
+
fi
|
|
505
|
+
if [ "$_in_backoff" -eq 0 ]; then
|
|
506
|
+
# Fix 1: lockdir — only one background refresh runs at a time.
|
|
507
|
+
LOCK_DIR="$CACHE_FILE.bg.lck"
|
|
508
|
+
if mkdir "$LOCK_DIR" 2>/dev/null; then
|
|
509
|
+
tmp="$CACHE_FILE.new.$$"
|
|
510
|
+
# Fix 3: background subshell captures and logs its own real exit code.
|
|
511
|
+
(
|
|
512
|
+
trap 'rm -rf "$LOCK_DIR"' EXIT
|
|
513
|
+
_bg_exit=0
|
|
514
|
+
if printf '%s' "$STDIN_PAYLOAD" | "$SOURCE" >"$tmp" 2>/dev/null; then
|
|
515
|
+
mv -f "$tmp" "$CACHE_FILE" && rm -f "$FAIL_FILE"
|
|
516
|
+
else
|
|
517
|
+
_bg_exit=$?
|
|
518
|
+
rm -f "$tmp"
|
|
519
|
+
touch "$FAIL_FILE"
|
|
520
|
+
fi
|
|
521
|
+
_bg_ts=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
|
522
|
+
_bg_log_file="$LOGS_DIR/events-$(date -u +%Y-%m-%d).jsonl"
|
|
523
|
+
printf '{"ts":"%s","event":"hook.cache.refresh","hook":"%s","cache":"bg-refresh","exit":%d}\\n' \\
|
|
524
|
+
"$_bg_ts" "$HOOK_NAME" "$_bg_exit" >>"$_bg_log_file" 2>/dev/null || true
|
|
525
|
+
) >/dev/null 2>&1 &
|
|
526
|
+
disown 2>/dev/null || true
|
|
527
|
+
fi
|
|
528
|
+
fi
|
|
497
529
|
else
|
|
498
530
|
# Synchronous fetch + cache.
|
|
499
531
|
tmp="$CACHE_FILE.new.$$"
|
|
@@ -501,6 +533,7 @@ if [ "$CACHE_STATUS" = miss ]; then
|
|
|
501
533
|
EXIT=0
|
|
502
534
|
cat "$tmp"
|
|
503
535
|
mv -f "$tmp" "$CACHE_FILE"
|
|
536
|
+
rm -f "$FAIL_FILE"
|
|
504
537
|
else
|
|
505
538
|
EXIT=$?
|
|
506
539
|
rm -f "$tmp"
|
package/dist/lib/hooks.d.ts
CHANGED
|
@@ -73,6 +73,14 @@ export declare function hookExists(agentId: AgentId, hookName: string): boolean;
|
|
|
73
73
|
* Compares both script file and data file (if present).
|
|
74
74
|
*/
|
|
75
75
|
export declare function hookContentMatches(agentId: AgentId, hookName: string, sourceEntry: HookEntry): boolean;
|
|
76
|
+
/**
|
|
77
|
+
* Hooks dir for an agent under an arbitrary home (version home or effective
|
|
78
|
+
* home). An agent whose `hooksDir` is configured absolute under `$HOME` (grok,
|
|
79
|
+
* kimi) must be translated to config-dir-relative first — a raw `path.join`
|
|
80
|
+
* would embed the absolute path as a relative segment and produce a hybrid
|
|
81
|
+
* path that never exists (RUSH-2237).
|
|
82
|
+
*/
|
|
83
|
+
export declare function getHooksDirInHome(agentId: AgentId, home: string): string;
|
|
76
84
|
export declare function listInstalledHooksWithScope(agentId: AgentId, cwd?: string, options?: {
|
|
77
85
|
home?: string;
|
|
78
86
|
}): InstalledHook[];
|
|
@@ -99,7 +107,7 @@ export interface HookWiringIssue {
|
|
|
99
107
|
* group). Real hooks scope by matcher — ask-user-question-guard=AskUserQuestion,
|
|
100
108
|
* user-message-guard=Bash — so wiring is verified per (event, matcher). */
|
|
101
109
|
matcher: string;
|
|
102
|
-
/** The command
|
|
110
|
+
/** The command the harness-native config should reference under `event`. */
|
|
103
111
|
command: string;
|
|
104
112
|
}
|
|
105
113
|
export interface HookWiringReport {
|
|
@@ -109,18 +117,22 @@ export interface HookWiringReport {
|
|
|
109
117
|
settingsPath?: string;
|
|
110
118
|
/** Number of hooks the manifest says should be wired for this version. */
|
|
111
119
|
expected?: number;
|
|
112
|
-
/**
|
|
120
|
+
/** Native hook config does not exist — nothing declared can be wired. */
|
|
113
121
|
settingsMissing?: boolean;
|
|
114
|
-
/**
|
|
122
|
+
/** Native hook config cannot be parsed — wiring can't be verified. */
|
|
115
123
|
settingsUnparseable?: boolean;
|
|
116
124
|
/** Hooks whose file is present/resolvable but that are NOT referenced in the
|
|
117
|
-
* event
|
|
125
|
+
* native event group/entry should carry them in. */
|
|
118
126
|
unwired: HookWiringIssue[];
|
|
127
|
+
/** Expected hooks that ARE referenced in native config (expected − unwired).
|
|
128
|
+
* Empty whenever wiring cannot be verified (unsupported family, missing or
|
|
129
|
+
* unparseable settings). */
|
|
130
|
+
wired: HookWiringIssue[];
|
|
119
131
|
}
|
|
120
132
|
/**
|
|
121
|
-
* Verify that every hook the manifest says should be wired
|
|
122
|
-
*
|
|
123
|
-
*
|
|
133
|
+
* Verify that every hook the manifest says should be wired is actually
|
|
134
|
+
* referenced in that version's harness-native config, not merely present as a
|
|
135
|
+
* file on disk.
|
|
124
136
|
*
|
|
125
137
|
* `agents doctor` compares hook FILES against source (see diffHooks in
|
|
126
138
|
* doctor-diff.ts) but never checks the wiring, so a hook whose script is
|
package/dist/lib/hooks.js
CHANGED
|
@@ -673,6 +673,20 @@ export function hookContentMatches(agentId, hookName, sourceEntry) {
|
|
|
673
673
|
return false;
|
|
674
674
|
}
|
|
675
675
|
}
|
|
676
|
+
/**
|
|
677
|
+
* Hooks dir for an agent under an arbitrary home (version home or effective
|
|
678
|
+
* home). An agent whose `hooksDir` is configured absolute under `$HOME` (grok,
|
|
679
|
+
* kimi) must be translated to config-dir-relative first — a raw `path.join`
|
|
680
|
+
* would embed the absolute path as a relative segment and produce a hybrid
|
|
681
|
+
* path that never exists (RUSH-2237).
|
|
682
|
+
*/
|
|
683
|
+
export function getHooksDirInHome(agentId, home) {
|
|
684
|
+
const config = AGENTS[agentId];
|
|
685
|
+
const hooksDir = path.isAbsolute(config.hooksDir)
|
|
686
|
+
? path.relative(config.configDir, config.hooksDir)
|
|
687
|
+
: config.hooksDir;
|
|
688
|
+
return path.join(home, agentConfigDirName(agentId), hooksDir);
|
|
689
|
+
}
|
|
676
690
|
export function listInstalledHooksWithScope(agentId, cwd = process.cwd(), options) {
|
|
677
691
|
const agent = AGENTS[agentId];
|
|
678
692
|
if (!agent.supportsHooks) {
|
|
@@ -702,7 +716,7 @@ export function listInstalledHooksWithScope(agentId, cwd = process.cwd(), option
|
|
|
702
716
|
}
|
|
703
717
|
// User-scoped hooks (version-aware when home is provided)
|
|
704
718
|
const home = options?.home || getEffectiveHome(agentId);
|
|
705
|
-
const userDir =
|
|
719
|
+
const userDir = getHooksDirInHome(agentId, home);
|
|
706
720
|
const userHooks = listHookEntriesFromDir(userDir);
|
|
707
721
|
for (const hook of userHooks) {
|
|
708
722
|
addHook(hook, 'user', agentId);
|
|
@@ -740,8 +754,7 @@ export async function installHooks(source, agents, options = {}) {
|
|
|
740
754
|
* Path to the hooks dir of a specific version home (not the active one).
|
|
741
755
|
*/
|
|
742
756
|
export function getVersionHooksDir(agent, version) {
|
|
743
|
-
|
|
744
|
-
return path.join(home, agentConfigDirName(agent), AGENTS[agent].hooksDir);
|
|
757
|
+
return getHooksDirInHome(agent, getVersionHomePath(agent, version));
|
|
745
758
|
}
|
|
746
759
|
/**
|
|
747
760
|
* List hook entries in a specific version home.
|
|
@@ -749,20 +762,20 @@ export function getVersionHooksDir(agent, version) {
|
|
|
749
762
|
export function listHooksInVersionHome(agent, version) {
|
|
750
763
|
return listHookEntriesFromDir(getVersionHooksDir(agent, version));
|
|
751
764
|
}
|
|
752
|
-
// ─── wiring inspection
|
|
765
|
+
// ─── wiring inspection ────────────────────────────────────────────────────────
|
|
753
766
|
/**
|
|
754
|
-
*
|
|
755
|
-
*
|
|
756
|
-
*
|
|
757
|
-
*
|
|
758
|
-
* map (Gemini/Antigravity settings.json variants, Codex config.toml, the OpenCode
|
|
759
|
-
* plugin, …), so it reports them unsupported rather than risk a false verdict.
|
|
767
|
+
* Native hook-config families understood by this read-only inspector. Claude,
|
|
768
|
+
* Droid, and Muse share settings.json; Grok uses the same grouped event shape
|
|
769
|
+
* in hooks/hooks.json; Kimi stores one hook per [[hooks]] config.toml table.
|
|
770
|
+
* Other harnesses report unsupported rather than risk a false verdict.
|
|
760
771
|
*/
|
|
761
772
|
const SETTINGS_JSON_HOOK_FAMILY = ['claude', 'droid', 'muse'];
|
|
773
|
+
const HOOKS_JSON_HOOK_FAMILY = ['grok'];
|
|
774
|
+
const TOML_ARRAY_HOOK_FAMILY = ['kimi'];
|
|
762
775
|
/**
|
|
763
|
-
* Verify that every hook the manifest says should be wired
|
|
764
|
-
*
|
|
765
|
-
*
|
|
776
|
+
* Verify that every hook the manifest says should be wired is actually
|
|
777
|
+
* referenced in that version's harness-native config, not merely present as a
|
|
778
|
+
* file on disk.
|
|
766
779
|
*
|
|
767
780
|
* `agents doctor` compares hook FILES against source (see diffHooks in
|
|
768
781
|
* doctor-diff.ts) but never checks the wiring, so a hook whose script is
|
|
@@ -774,11 +787,18 @@ const SETTINGS_JSON_HOOK_FAMILY = ['claude', 'droid', 'muse'];
|
|
|
774
787
|
* resolveHookCommand performs, so it never mutates the version home.
|
|
775
788
|
*/
|
|
776
789
|
export function checkVersionHookWiring(agent, version) {
|
|
777
|
-
if (!AGENTS[agent].supportsHooks ||
|
|
778
|
-
|
|
790
|
+
if (!AGENTS[agent].supportsHooks ||
|
|
791
|
+
(!SETTINGS_JSON_HOOK_FAMILY.includes(agent) &&
|
|
792
|
+
!HOOKS_JSON_HOOK_FAMILY.includes(agent) &&
|
|
793
|
+
!TOML_ARRAY_HOOK_FAMILY.includes(agent))) {
|
|
794
|
+
return { supported: false, unwired: [], wired: [] };
|
|
779
795
|
}
|
|
780
796
|
const versionHome = getVersionHomePath(agent, version);
|
|
781
|
-
const settingsPath =
|
|
797
|
+
const settingsPath = HOOKS_JSON_HOOK_FAMILY.includes(agent)
|
|
798
|
+
? path.join(versionHome, '.grok', 'hooks', 'hooks.json')
|
|
799
|
+
: TOML_ARRAY_HOOK_FAMILY.includes(agent)
|
|
800
|
+
? path.join(versionHome, '.kimi-code', 'config.toml')
|
|
801
|
+
: path.join(versionHome, agentConfigDirName(agent), 'settings.json');
|
|
782
802
|
const localHooksDir = getVersionHooksDir(agent, version);
|
|
783
803
|
// Resolve ONLY to a script that was actually synced for THIS agent+version: the
|
|
784
804
|
// copy in the version home hooks dir, or an absolute subrule-dir path (those are
|
|
@@ -815,11 +835,17 @@ export function checkVersionHookWiring(agent, version) {
|
|
|
815
835
|
const command = expectedCommand(name, hookDef);
|
|
816
836
|
if (!command)
|
|
817
837
|
continue; // script unresolved — a file gap, reported by diffHooks
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
838
|
+
for (const event of hookDef.events) {
|
|
839
|
+
if (HOOKS_JSON_HOOK_FAMILY.includes(agent)) {
|
|
840
|
+
const matcher = GROK_MATCHER_EVENTS.has(event)
|
|
841
|
+
? (GROK_MATCHER_ALIASES[hookDef.matcher || ''] ?? hookDef.matcher ?? '')
|
|
842
|
+
: '';
|
|
843
|
+
expected.push({ name, event, matcher, command });
|
|
844
|
+
}
|
|
845
|
+
else {
|
|
846
|
+
expected.push({ name, event, matcher: hookDef.matcher || '', command });
|
|
847
|
+
}
|
|
848
|
+
}
|
|
823
849
|
}
|
|
824
850
|
if (!fs.existsSync(settingsPath)) {
|
|
825
851
|
return {
|
|
@@ -828,11 +854,15 @@ export function checkVersionHookWiring(agent, version) {
|
|
|
828
854
|
expected: expected.length,
|
|
829
855
|
settingsMissing: expected.length > 0,
|
|
830
856
|
unwired: [],
|
|
857
|
+
wired: [],
|
|
831
858
|
};
|
|
832
859
|
}
|
|
833
860
|
let config;
|
|
834
861
|
try {
|
|
835
|
-
|
|
862
|
+
const raw = fs.readFileSync(settingsPath, 'utf-8');
|
|
863
|
+
config = TOML_ARRAY_HOOK_FAMILY.includes(agent)
|
|
864
|
+
? TOML.parse(raw)
|
|
865
|
+
: JSON.parse(raw);
|
|
836
866
|
}
|
|
837
867
|
catch {
|
|
838
868
|
return {
|
|
@@ -841,6 +871,7 @@ export function checkVersionHookWiring(agent, version) {
|
|
|
841
871
|
expected: expected.length,
|
|
842
872
|
settingsUnparseable: true,
|
|
843
873
|
unwired: [],
|
|
874
|
+
wired: [],
|
|
844
875
|
};
|
|
845
876
|
}
|
|
846
877
|
// Command strings actually referenced, keyed by (event, matcher) — a hook wired
|
|
@@ -848,30 +879,49 @@ export function checkVersionHookWiring(agent, version) {
|
|
|
848
879
|
// not just by event.
|
|
849
880
|
const wiredByGroup = new Map();
|
|
850
881
|
const groupKey = (event, matcher) => `${event}\n${matcher}`;
|
|
851
|
-
|
|
852
|
-
? config.hooks
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
if (!Array.isArray(groups))
|
|
856
|
-
continue;
|
|
857
|
-
for (const group of groups) {
|
|
858
|
-
if (!group || !Array.isArray(group.hooks))
|
|
882
|
+
if (TOML_ARRAY_HOOK_FAMILY.includes(agent)) {
|
|
883
|
+
const hooks = Array.isArray(config.hooks) ? config.hooks : [];
|
|
884
|
+
for (const hook of hooks) {
|
|
885
|
+
if (typeof hook.event !== 'string' || typeof hook.command !== 'string')
|
|
859
886
|
continue;
|
|
860
|
-
const matcher = typeof
|
|
861
|
-
const key = groupKey(event, matcher);
|
|
887
|
+
const matcher = typeof hook.matcher === 'string' ? hook.matcher : '';
|
|
888
|
+
const key = groupKey(hook.event, matcher);
|
|
862
889
|
let cmds = wiredByGroup.get(key);
|
|
863
890
|
if (!cmds) {
|
|
864
891
|
cmds = new Set();
|
|
865
892
|
wiredByGroup.set(key, cmds);
|
|
866
893
|
}
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
894
|
+
cmds.add(hook.command);
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
else {
|
|
898
|
+
const hooks = config.hooks && typeof config.hooks === 'object'
|
|
899
|
+
? config.hooks
|
|
900
|
+
: {};
|
|
901
|
+
for (const [event, groups] of Object.entries(hooks)) {
|
|
902
|
+
if (!Array.isArray(groups))
|
|
903
|
+
continue;
|
|
904
|
+
for (const group of groups) {
|
|
905
|
+
if (!group || !Array.isArray(group.hooks))
|
|
906
|
+
continue;
|
|
907
|
+
const matcher = typeof group.matcher === 'string' ? group.matcher : '';
|
|
908
|
+
const key = groupKey(event, matcher);
|
|
909
|
+
let cmds = wiredByGroup.get(key);
|
|
910
|
+
if (!cmds) {
|
|
911
|
+
cmds = new Set();
|
|
912
|
+
wiredByGroup.set(key, cmds);
|
|
913
|
+
}
|
|
914
|
+
for (const h of group.hooks) {
|
|
915
|
+
if (h && typeof h.command === 'string')
|
|
916
|
+
cmds.add(h.command);
|
|
917
|
+
}
|
|
870
918
|
}
|
|
871
919
|
}
|
|
872
920
|
}
|
|
873
|
-
const
|
|
874
|
-
|
|
921
|
+
const isWired = (entry) => wiredByGroup.get(groupKey(entry.event, entry.matcher))?.has(entry.command) ?? false;
|
|
922
|
+
const unwired = expected.filter((entry) => !isWired(entry));
|
|
923
|
+
const wired = expected.filter(isWired);
|
|
924
|
+
return { supported: true, settingsPath, expected: expected.length, unwired, wired };
|
|
875
925
|
}
|
|
876
926
|
/**
|
|
877
927
|
* Check if a hook installed in a specific version matches central content.
|
|
@@ -1402,7 +1452,7 @@ export function registerHooksToSettings(agentId, versionHome, hookManifest, agen
|
|
|
1402
1452
|
// Scripts are copied into the version home during sync — prefer that stable
|
|
1403
1453
|
// local path so registered commands don't break when source dirs change.
|
|
1404
1454
|
const localHooksDir = !overrideRoots
|
|
1405
|
-
?
|
|
1455
|
+
? getHooksDirInHome(agentId, versionHome)
|
|
1406
1456
|
: null;
|
|
1407
1457
|
const resolveScript = (script) => {
|
|
1408
1458
|
// Subrule-dir hooks declare an already-absolute script path. Use it
|
|
@@ -11,6 +11,10 @@
|
|
|
11
11
|
* issued from, so the run appears in that project's session listing like any
|
|
12
12
|
* local run. The `[host/<name>]` label mirrors the cloud path's
|
|
13
13
|
* `[cloud/<status>]` convention.
|
|
14
|
+
*
|
|
15
|
+
* `machine` is the dispatch host, not this box. Both the transcript and the
|
|
16
|
+
* version-isolated harness home live there; omitting it makes the SQLite upsert
|
|
17
|
+
* infer this box from the empty file path and sends recovery to the wrong home.
|
|
14
18
|
*/
|
|
15
19
|
import type { SessionMeta } from '../session/types.js';
|
|
16
20
|
import { type HostTask } from './tasks.js';
|