@phnx-labs/agents-cli 1.20.43 → 1.20.44
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 +10 -0
- package/README.md +4 -3
- package/dist/commands/exec.js +30 -2
- package/dist/commands/hosts.js +5 -4
- package/dist/commands/logs.d.ts +4 -0
- package/dist/commands/logs.js +19 -13
- package/dist/commands/routines.d.ts +6 -0
- package/dist/commands/routines.js +70 -12
- package/dist/commands/sessions.d.ts +6 -5
- package/dist/commands/sessions.js +50 -22
- package/dist/commands/teams.js +43 -5
- package/dist/lib/daemon.js +34 -9
- package/dist/lib/exec.d.ts +8 -0
- package/dist/lib/exec.js +70 -6
- package/dist/lib/hosts/logs.d.ts +14 -5
- package/dist/lib/hosts/logs.js +39 -13
- package/dist/lib/redact.js +1 -0
- package/dist/lib/session/active.d.ts +6 -0
- package/dist/lib/session/active.js +10 -1
- package/dist/lib/shims.d.ts +1 -1
- package/dist/lib/shims.js +17 -3
- package/dist/lib/teams/agents.js +16 -7
- package/dist/lib/tmux/session.d.ts +40 -0
- package/dist/lib/tmux/session.js +92 -0
- package/dist/lib/versions.d.ts +54 -1
- package/dist/lib/versions.js +138 -1
- package/package.json +1 -1
package/dist/lib/daemon.js
CHANGED
|
@@ -19,6 +19,7 @@ import { detectOverdueJobs, notifyOverdue } from './overdue.js';
|
|
|
19
19
|
import { BrowserService } from './browser/service.js';
|
|
20
20
|
import { BrowserIPCServer } from './browser/ipc.js';
|
|
21
21
|
import { readAndResolveBundleEnv } from './secrets/bundles.js';
|
|
22
|
+
import { redactSecrets } from './redact.js';
|
|
22
23
|
const PID_FILE = 'daemon.pid';
|
|
23
24
|
const LOCK_FILE = 'daemon.lock';
|
|
24
25
|
const LOG_FILE = 'logs.jsonl';
|
|
@@ -207,15 +208,6 @@ export function reapStrayDaemons(keepPid = process.pid) {
|
|
|
207
208
|
}
|
|
208
209
|
return { reaped, details };
|
|
209
210
|
}
|
|
210
|
-
/** Redact values that look like tokens or credentials in a log message. */
|
|
211
|
-
function redactSecrets(message) {
|
|
212
|
-
let safe = message;
|
|
213
|
-
safe = safe.replace(/eyJ[A-Za-z0-9_-]{20,}/g, '[REDACTED_TOKEN]');
|
|
214
|
-
safe = safe.replace(/Bearer\s+\S+/gi, 'Bearer [REDACTED]');
|
|
215
|
-
safe = safe.replace(/(sk-[a-zA-Z0-9]{20,})/g, '[REDACTED_KEY]');
|
|
216
|
-
safe = safe.replace(/(ANTHROPIC_API_KEY|OPENAI_API_KEY|API_KEY|SECRET|TOKEN|PASSWORD)=\S+/gi, '$1=[REDACTED]');
|
|
217
|
-
return safe;
|
|
218
|
-
}
|
|
219
211
|
function rotateLogsIfNeeded(logPath) {
|
|
220
212
|
try {
|
|
221
213
|
const stat = fs.statSync(logPath);
|
|
@@ -428,6 +420,37 @@ export async function runDaemon() {
|
|
|
428
420
|
};
|
|
429
421
|
const deviceProbeInterval = setInterval(() => { void runDeviceProbe(); }, 3 * 60_000);
|
|
430
422
|
const deviceProbeKickoff = setTimeout(() => { void runDeviceProbe(); }, 15_000);
|
|
423
|
+
// tmux hook reconcile: retrofit the guarded `pane-died` hook onto managed
|
|
424
|
+
// `agents run` sessions a pre-fix binary left with the old unconditional hook
|
|
425
|
+
// (which detached the whole client — kicking the user out of the view — when
|
|
426
|
+
// they exited a split they'd opened). Non-destructive: set-hook only, never a
|
|
427
|
+
// kill or detach. A per-session schema marker makes steady-state a no-op, so
|
|
428
|
+
// this stays cheap at ~every 5 min, plus once ~20s after startup so a
|
|
429
|
+
// just-upgraded daemon heals still-running sessions without waiting for them to
|
|
430
|
+
// cycle or the shared server to be recycled.
|
|
431
|
+
let reconcilingTmux = false;
|
|
432
|
+
const runTmuxReconcile = async () => {
|
|
433
|
+
if (reconcilingTmux)
|
|
434
|
+
return;
|
|
435
|
+
reconcilingTmux = true;
|
|
436
|
+
try {
|
|
437
|
+
const { isTmuxInstalled } = await import('./tmux/binary.js');
|
|
438
|
+
if (!isTmuxInstalled())
|
|
439
|
+
return;
|
|
440
|
+
const { reconcileSessionHooks } = await import('./tmux/session.js');
|
|
441
|
+
const r = await reconcileSessionHooks();
|
|
442
|
+
if (r.reconciled > 0)
|
|
443
|
+
log('INFO', `tmux: retrofitted pane-died hook on ${r.reconciled} session(s)`);
|
|
444
|
+
}
|
|
445
|
+
catch (err) {
|
|
446
|
+
log('ERROR', `tmux reconcile failed: ${err.message}`);
|
|
447
|
+
}
|
|
448
|
+
finally {
|
|
449
|
+
reconcilingTmux = false;
|
|
450
|
+
}
|
|
451
|
+
};
|
|
452
|
+
const tmuxReconcileInterval = setInterval(() => { void runTmuxReconcile(); }, 5 * 60_000);
|
|
453
|
+
const tmuxReconcileKickoff = setTimeout(() => { void runTmuxReconcile(); }, 20_000);
|
|
431
454
|
const handleReload = () => {
|
|
432
455
|
log('INFO', 'Reloading jobs (SIGHUP)');
|
|
433
456
|
scheduler.reloadAll();
|
|
@@ -447,6 +470,8 @@ export async function runDaemon() {
|
|
|
447
470
|
clearTimeout(healKickoff);
|
|
448
471
|
clearInterval(deviceProbeInterval);
|
|
449
472
|
clearTimeout(deviceProbeKickoff);
|
|
473
|
+
clearInterval(tmuxReconcileInterval);
|
|
474
|
+
clearTimeout(tmuxReconcileKickoff);
|
|
450
475
|
removeDaemonPid();
|
|
451
476
|
process.exit(0);
|
|
452
477
|
};
|
package/dist/lib/exec.d.ts
CHANGED
|
@@ -272,6 +272,14 @@ export declare function shouldWrapInTmux(ctx: TmuxWrapContext): boolean;
|
|
|
272
272
|
* (`BASH_FUNC_*%%`) can't make `env` choke.
|
|
273
273
|
*/
|
|
274
274
|
export declare function buildTmuxAgentCommand(executable: string, args: string[], env: NodeJS.ProcessEnv): string;
|
|
275
|
+
/**
|
|
276
|
+
* Trim a raw `tmux capture-pane` dump to its last `maxLines` non-empty lines
|
|
277
|
+
* (right-stripping each). Used by runInTmux to recap a fast-failed agent's
|
|
278
|
+
* output into the caller's shell so a launch crash (e.g. a gutted install that
|
|
279
|
+
* dies with ENOENT the instant it spawns) isn't swallowed by the bare
|
|
280
|
+
* `[detached]` the pane-died hook otherwise leaves behind.
|
|
281
|
+
*/
|
|
282
|
+
export declare function formatPaneTail(raw: string, maxLines?: number): string;
|
|
275
283
|
/** Exit code spawnAgent resolves with when a run is killed for crossing a budget cap. */
|
|
276
284
|
export declare const BUDGET_KILL_EXIT_CODE = 7;
|
|
277
285
|
/**
|
package/dist/lib/exec.js
CHANGED
|
@@ -515,7 +515,13 @@ export function buildExecCommand(options) {
|
|
|
515
515
|
cmd[0] = absPath;
|
|
516
516
|
}
|
|
517
517
|
else {
|
|
518
|
-
|
|
518
|
+
// No versioned shim on disk. Prefer the version's REAL launch binary
|
|
519
|
+
// (node_modules/.bin/<cli>) over the bare `<cli>@<version>` name — that
|
|
520
|
+
// literal is not on PATH and spawns as ENOENT (the `kimi@0.19.2` failure).
|
|
521
|
+
// Fall back to the literal only if the binary is absent (the run path's
|
|
522
|
+
// ensureAgentRunnable normally repairs/creates the alias before we reach here).
|
|
523
|
+
const realBinary = options.agent ? getBinaryPath(options.agent, options.version) : undefined;
|
|
524
|
+
cmd[0] = realBinary && fs.existsSync(realBinary) ? realBinary : versionedName;
|
|
519
525
|
}
|
|
520
526
|
}
|
|
521
527
|
// Add reasoning effort flags (before mode flags for codex -c positioning)
|
|
@@ -814,6 +820,21 @@ export function buildTmuxAgentCommand(executable, args, env) {
|
|
|
814
820
|
const agentCmd = [executable, ...args].map(shellQuote).join(' ');
|
|
815
821
|
return `exec env ${envPrefix} ${agentCmd}`;
|
|
816
822
|
}
|
|
823
|
+
/**
|
|
824
|
+
* Trim a raw `tmux capture-pane` dump to its last `maxLines` non-empty lines
|
|
825
|
+
* (right-stripping each). Used by runInTmux to recap a fast-failed agent's
|
|
826
|
+
* output into the caller's shell so a launch crash (e.g. a gutted install that
|
|
827
|
+
* dies with ENOENT the instant it spawns) isn't swallowed by the bare
|
|
828
|
+
* `[detached]` the pane-died hook otherwise leaves behind.
|
|
829
|
+
*/
|
|
830
|
+
export function formatPaneTail(raw, maxLines = 30) {
|
|
831
|
+
return raw
|
|
832
|
+
.split('\n')
|
|
833
|
+
.map(l => l.replace(/\s+$/, ''))
|
|
834
|
+
.filter(l => l.length > 0)
|
|
835
|
+
.slice(-maxLines)
|
|
836
|
+
.join('\n');
|
|
837
|
+
}
|
|
817
838
|
/**
|
|
818
839
|
* Run an interactive agent inside a detached tmux session on the shared socket,
|
|
819
840
|
* attach the current TTY, and propagate the wrapped agent's exit code.
|
|
@@ -834,7 +855,7 @@ export function buildTmuxAgentCommand(executable, args, env) {
|
|
|
834
855
|
* (Ctrl-b d) — return 0 and LEAVE the session for `agents focus` to re-attach.
|
|
835
856
|
*/
|
|
836
857
|
async function runInTmux(options, executable, args) {
|
|
837
|
-
const { createSession, killSession, paneExitStatus, setSessionHook, slugifyName } = await import('./tmux/session.js');
|
|
858
|
+
const { createSession, killSession, paneExitStatus, setSessionHook, slugifyName, agentPaneDiedHook, markSessionHookSchema } = await import('./tmux/session.js');
|
|
838
859
|
const { getDefaultSocketPath } = await import('./tmux/paths.js');
|
|
839
860
|
const { attachTmux, runTmux } = await import('./tmux/binary.js');
|
|
840
861
|
const socket = getDefaultSocketPath();
|
|
@@ -855,7 +876,10 @@ async function runInTmux(options, executable, args) {
|
|
|
855
876
|
// that split in place instead of detaching everyone (the pane-died hook runs
|
|
856
877
|
// in the dead pane's context, so bare `kill-pane` targets it). Without the
|
|
857
878
|
// guard, exiting any split kicked the user clean out of tmux.
|
|
858
|
-
await setSessionHook(name, 'pane-died',
|
|
879
|
+
await setSessionHook(name, 'pane-died', agentPaneDiedHook(name, pane), socket);
|
|
880
|
+
// Stamp the schema marker so the daemon reconcile (which retrofits older
|
|
881
|
+
// sessions) recognizes this one as already current and skips it.
|
|
882
|
+
await markSessionHookSchema(name, socket);
|
|
859
883
|
// Record the agent's OS pid (the pane leaf, thanks to `exec`) WITH its tmux
|
|
860
884
|
// pane so the active-scan attributes it exactly and shows the %pane.
|
|
861
885
|
let panePid = 0;
|
|
@@ -873,14 +897,54 @@ async function runInTmux(options, executable, args) {
|
|
|
873
897
|
startedAtMs: Date.now(),
|
|
874
898
|
});
|
|
875
899
|
}
|
|
900
|
+
// Recap a dead pane's tail into THIS shell's stderr. The pane-died hook
|
|
901
|
+
// detaches the client the instant the agent exits, so a fast failure (a
|
|
902
|
+
// gutted install that dies with ENOENT, a bad flag, a crash on startup) would
|
|
903
|
+
// otherwise leave only a bare `[detached]` with no clue why. Must run BEFORE
|
|
904
|
+
// killSession — capture-pane needs the session still alive (remain-on-exit
|
|
905
|
+
// keeps the dead pane readable until we tear it down). Best-effort throughout.
|
|
906
|
+
const surfacePaneFailure = async (status, headline) => {
|
|
907
|
+
if (!pane)
|
|
908
|
+
return;
|
|
909
|
+
let tail = '';
|
|
910
|
+
try {
|
|
911
|
+
const r = await runTmux({ socket, args: ['capture-pane', '-p', '-t', pane, '-S', '-200'], throwOnError: false });
|
|
912
|
+
if (r.code === 0)
|
|
913
|
+
tail = formatPaneTail(r.stdout);
|
|
914
|
+
}
|
|
915
|
+
catch { /* best-effort — a missing pane just means no recap */ }
|
|
916
|
+
const RED = '\x1b[31m', GRAY = '\x1b[90m', OFF = '\x1b[0m';
|
|
917
|
+
process.stderr.write(`\n${RED}agents: ${headline} (exit ${status ?? 1}).${OFF}\n`);
|
|
918
|
+
if (tail) {
|
|
919
|
+
process.stderr.write(`${GRAY} ── last output from ${options.agent} ──${OFF}\n`);
|
|
920
|
+
process.stderr.write(tail.replace(/^/gm, ' ') + '\n');
|
|
921
|
+
process.stderr.write(`${GRAY} ${'─'.repeat(30)}${OFF}\n`);
|
|
922
|
+
}
|
|
923
|
+
process.stderr.write(`${GRAY} Tip: re-run with --no-tmux to launch the agent directly and see its full output.${OFF}\n\n`);
|
|
924
|
+
};
|
|
876
925
|
// The agent could exit before we attach (fast failure). Don't attach to an
|
|
877
|
-
// already-dead pane —
|
|
926
|
+
// already-dead pane — surface its output + status directly and tear down.
|
|
878
927
|
const before = pane ? await paneExitStatus(pane, socket) : { dead: false };
|
|
879
|
-
if (
|
|
880
|
-
|
|
928
|
+
if (before.dead) {
|
|
929
|
+
// Only recap a FAILURE. A clean (0) exit before we attached is a successful
|
|
930
|
+
// quick run, not a crash — a red banner there would be spurious (mirrors the
|
|
931
|
+
// post-attach guard below).
|
|
932
|
+
if ((before.status ?? 0) !== 0) {
|
|
933
|
+
await surfacePaneFailure(before.status, `${options.agent} exited before it could start`);
|
|
934
|
+
}
|
|
935
|
+
await killSession(name, socket).catch(() => { });
|
|
936
|
+
return { exitCode: before.status ?? 0, stderr: '' };
|
|
881
937
|
}
|
|
938
|
+
await attachTmux({ socket, args: ['attach-session', '-t', name] });
|
|
882
939
|
const after = pane ? await paneExitStatus(pane, socket) : { dead: false };
|
|
883
940
|
if (after.dead) {
|
|
941
|
+
// Nonzero exit after attach → the agent crashed rather than the user
|
|
942
|
+
// detaching cleanly (a clean detach leaves the pane ALIVE, handled below).
|
|
943
|
+
// The pane-died hook may have yanked the view before the error was readable,
|
|
944
|
+
// so recap it into the shell. A clean (0) exit stays quiet — nothing to say.
|
|
945
|
+
if ((after.status ?? 0) !== 0) {
|
|
946
|
+
await surfacePaneFailure(after.status, `${options.agent} exited`);
|
|
947
|
+
}
|
|
884
948
|
await killSession(name, socket).catch(() => { });
|
|
885
949
|
return { exitCode: after.status ?? 0, stderr: '' };
|
|
886
950
|
}
|
package/dist/lib/hosts/logs.d.ts
CHANGED
|
@@ -2,9 +2,13 @@
|
|
|
2
2
|
* Shared host-task log viewer — the show-or-follow core behind both
|
|
3
3
|
* `agents hosts logs <id>` and the top-level `agents logs <id>`.
|
|
4
4
|
*
|
|
5
|
-
* A running task with follow re-enters the offset-tail (`followHostTask`)
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* A running task with follow re-enters the offset-tail (`followHostTask`).
|
|
6
|
+
* Otherwise the view is **concise by default**: a bounded tail of the captured
|
|
7
|
+
* combined-stdout, so an agent glancing at a dispatched run never pulls the whole
|
|
8
|
+
* log. `full` opts into the entire raw log. (A host run's real transcript lives
|
|
9
|
+
* on the remote, not the local index — surfacing its rich summary needs remote
|
|
10
|
+
* runs to be discoverable there first; until then the bounded tail is the safe
|
|
11
|
+
* concise default.) Kept in one place so the two commands can never drift.
|
|
8
12
|
*/
|
|
9
13
|
export interface HostLogResult {
|
|
10
14
|
/** False when no host task with this id exists (caller may fall through to sessions). */
|
|
@@ -12,5 +16,10 @@ export interface HostLogResult {
|
|
|
12
16
|
/** Process exit code to adopt when the task was shown/followed. */
|
|
13
17
|
exitCode?: number;
|
|
14
18
|
}
|
|
15
|
-
/**
|
|
16
|
-
|
|
19
|
+
/**
|
|
20
|
+
* Show (or follow, when running) a dispatched host task. Bounded-tail summary by
|
|
21
|
+
* default; `full` dumps the entire raw combined-stdout log.
|
|
22
|
+
*/
|
|
23
|
+
export declare function showHostTaskLog(id: string, follow: boolean, full?: boolean): Promise<HostLogResult>;
|
|
24
|
+
/** Last `n` lines of `text`, prefixed with an elision note when truncated. */
|
|
25
|
+
export declare function tailLines(text: string, n: number): string;
|
package/dist/lib/hosts/logs.js
CHANGED
|
@@ -2,9 +2,13 @@
|
|
|
2
2
|
* Shared host-task log viewer — the show-or-follow core behind both
|
|
3
3
|
* `agents hosts logs <id>` and the top-level `agents logs <id>`.
|
|
4
4
|
*
|
|
5
|
-
* A running task with follow re-enters the offset-tail (`followHostTask`)
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* A running task with follow re-enters the offset-tail (`followHostTask`).
|
|
6
|
+
* Otherwise the view is **concise by default**: a bounded tail of the captured
|
|
7
|
+
* combined-stdout, so an agent glancing at a dispatched run never pulls the whole
|
|
8
|
+
* log. `full` opts into the entire raw log. (A host run's real transcript lives
|
|
9
|
+
* on the remote, not the local index — surfacing its rich summary needs remote
|
|
10
|
+
* runs to be discoverable there first; until then the bounded tail is the safe
|
|
11
|
+
* concise default.) Kept in one place so the two commands can never drift.
|
|
8
12
|
*/
|
|
9
13
|
import * as fs from 'fs';
|
|
10
14
|
import chalk from 'chalk';
|
|
@@ -12,8 +16,13 @@ import { loadTask, localLogPath, updateTask, terminalPatch } from './tasks.js';
|
|
|
12
16
|
import { followHostTask } from './progress.js';
|
|
13
17
|
import { reconcileTask } from './reconcile.js';
|
|
14
18
|
import { sshExecRaw } from '../ssh-exec.js';
|
|
15
|
-
/**
|
|
16
|
-
|
|
19
|
+
/** Lines of raw combined-stdout to show in the concise (non-`full`) view. */
|
|
20
|
+
const HOST_LOG_TAIL_LINES = 40;
|
|
21
|
+
/**
|
|
22
|
+
* Show (or follow, when running) a dispatched host task. Bounded-tail summary by
|
|
23
|
+
* default; `full` dumps the entire raw combined-stdout log.
|
|
24
|
+
*/
|
|
25
|
+
export async function showHostTaskLog(id, follow, full = false) {
|
|
17
26
|
const task = loadTask(id);
|
|
18
27
|
if (!task)
|
|
19
28
|
return { found: false };
|
|
@@ -36,21 +45,38 @@ export async function showHostTaskLog(id, follow) {
|
|
|
36
45
|
// plain `logs <id>` also unsticks a task whose follower was killed. No-op (no
|
|
37
46
|
// ssh) once the record is already terminal.
|
|
38
47
|
reconcileTask(task);
|
|
48
|
+
// Raw combined-stdout: the whole log with `full`, else a bounded tail.
|
|
49
|
+
const raw = readTaskLog(task);
|
|
50
|
+
if (raw === null) {
|
|
51
|
+
process.stdout.write(chalk.gray('(no local log captured for this task)\n'));
|
|
52
|
+
return { found: true, exitCode: 0 };
|
|
53
|
+
}
|
|
54
|
+
process.stdout.write(full ? raw : tailLines(raw, HOST_LOG_TAIL_LINES));
|
|
55
|
+
return { found: true, exitCode: 0 };
|
|
56
|
+
}
|
|
57
|
+
/** Read the task's combined-stdout — local mirror first, else fetch+cache remote. */
|
|
58
|
+
function readTaskLog(task) {
|
|
39
59
|
try {
|
|
40
|
-
|
|
60
|
+
return fs.readFileSync(localLogPath(task.id), 'utf-8');
|
|
41
61
|
}
|
|
42
62
|
catch {
|
|
43
63
|
// No local log — task was dispatched with --no-follow. Fetch from the remote
|
|
44
64
|
// on demand and cache locally so subsequent calls are instant.
|
|
45
65
|
const remote = fetchAndCacheRemoteLog(task);
|
|
46
|
-
|
|
47
|
-
process.stdout.write(remote);
|
|
48
|
-
}
|
|
49
|
-
else {
|
|
50
|
-
process.stdout.write(chalk.gray('(no local log captured for this task)\n'));
|
|
51
|
-
}
|
|
66
|
+
return remote !== null ? remote.toString('utf-8') : null;
|
|
52
67
|
}
|
|
53
|
-
|
|
68
|
+
}
|
|
69
|
+
/** Last `n` lines of `text`, prefixed with an elision note when truncated. */
|
|
70
|
+
export function tailLines(text, n) {
|
|
71
|
+
const lines = text.split('\n');
|
|
72
|
+
// A trailing newline yields a final empty element — drop it from the count.
|
|
73
|
+
if (lines.length > 0 && lines[lines.length - 1] === '')
|
|
74
|
+
lines.pop();
|
|
75
|
+
if (lines.length <= n)
|
|
76
|
+
return lines.join('\n') + '\n';
|
|
77
|
+
const hidden = lines.length - n;
|
|
78
|
+
const note = chalk.gray(`… ${hidden} earlier line${hidden === 1 ? '' : 's'} hidden — pass --full for the whole log\n`);
|
|
79
|
+
return note + lines.slice(-n).join('\n') + '\n';
|
|
54
80
|
}
|
|
55
81
|
/**
|
|
56
82
|
* Fetch a task's remote log over SSH, write it to the local mirror path (for
|
package/dist/lib/redact.js
CHANGED
|
@@ -7,6 +7,7 @@ const SECRET_PATTERNS = [
|
|
|
7
7
|
[/\bsk-[A-Za-z0-9]{20,}\b/g, '[REDACTED_API_KEY]'],
|
|
8
8
|
[/\bnpm_[A-Za-z0-9]{36}\b/g, '[REDACTED_NPM_TOKEN]'],
|
|
9
9
|
[/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, '[REDACTED_JWT]'],
|
|
10
|
+
[/Bearer\s+\S+/gi, 'Bearer [REDACTED]'],
|
|
10
11
|
[/\b([A-Z0-9_]*(?:TOKEN|KEY|SECRET|PASSWORD)[A-Z0-9_]*)=("[^"]*"|'[^']*'|\S+)/gi, '$1=[REDACTED]'],
|
|
11
12
|
];
|
|
12
13
|
export function redactSecrets(text) {
|
|
@@ -102,6 +102,12 @@ export interface ActiveQueryOptions {
|
|
|
102
102
|
/** Skip the `ps` scan for ad-hoc headless agents. */
|
|
103
103
|
skipHeadless?: boolean;
|
|
104
104
|
}
|
|
105
|
+
/**
|
|
106
|
+
* Resolve an agent kind from a process's reported executable. `comm` may be an
|
|
107
|
+
* absolute path (shim-launched agents), and Windows image names carry an
|
|
108
|
+
* `.exe` suffix (`claude.exe`), so basename + suffix-strip before the lookup.
|
|
109
|
+
*/
|
|
110
|
+
export declare function agentKindFromComm(commRaw: string): string | undefined;
|
|
105
111
|
/**
|
|
106
112
|
* Pick a Claude transcript file within a project dir.
|
|
107
113
|
*
|
|
@@ -64,7 +64,16 @@ const AGENT_CLI_NAMES = {
|
|
|
64
64
|
* absolute path (shim-launched agents), and Windows image names carry an
|
|
65
65
|
* `.exe` suffix (`claude.exe`), so basename + suffix-strip before the lookup.
|
|
66
66
|
*/
|
|
67
|
-
function agentKindFromComm(commRaw) {
|
|
67
|
+
export function agentKindFromComm(commRaw) {
|
|
68
|
+
// A GUI desktop app can bundle a binary with the SAME name as an agent CLI: the
|
|
69
|
+
// Codex desktop app ships `/Applications/Codex.app/Contents/Resources/codex` (its
|
|
70
|
+
// `app-server`), whose basename `codex` would otherwise match the codex CLI and
|
|
71
|
+
// surface the app's background server as a phantom agent session — running at cwd
|
|
72
|
+
// '/', so it shows up unattributed in the feed. A real agent CLI is never inside a
|
|
73
|
+
// `.app` bundle, so exclude those. (The Claude desktop app is a separate case,
|
|
74
|
+
// already excluded by name below: its process is 'Claude', not the CLI's 'claude'.)
|
|
75
|
+
if (commRaw.includes('.app/Contents/'))
|
|
76
|
+
return undefined;
|
|
68
77
|
const base = path.basename(commRaw);
|
|
69
78
|
const stripped = base.replace(/\.exe$/i, '');
|
|
70
79
|
// Windows image names compare case-insensitively; POSIX comms stay exact —
|
package/dist/lib/shims.d.ts
CHANGED
|
@@ -77,7 +77,7 @@ export interface ConflictInfo {
|
|
|
77
77
|
* top-level entry add/remove — deep edits to plugin contents won't
|
|
78
78
|
* trigger auto-resync, run `agents sync` for that.
|
|
79
79
|
*/
|
|
80
|
-
export declare const SHIM_SCHEMA_VERSION =
|
|
80
|
+
export declare const SHIM_SCHEMA_VERSION = 25;
|
|
81
81
|
/**
|
|
82
82
|
* Generate the full bash shim script for the given agent. The returned string
|
|
83
83
|
* is written to ~/.agents/shims/{cliCommand} and made executable.
|
package/dist/lib/shims.js
CHANGED
|
@@ -211,7 +211,10 @@ async function promptConflictStrategy(conflictInfos) {
|
|
|
211
211
|
// v22 — export DISABLE_AUTOUPDATER=1 for claude shims so a pinned per-version
|
|
212
212
|
// install can't self-mutate: Claude Code's background auto-updater would
|
|
213
213
|
// otherwise rewrite the pinned binary in place. Explicit user value wins.
|
|
214
|
-
|
|
214
|
+
// v25 — dispatcher self-recovery: if the baked AGENTS_BIN is gone (a removed/moved
|
|
215
|
+
// dev build that generated the shim), resolve `agents` on PATH instead of
|
|
216
|
+
// exiting 127, so a stale/vanished dev build can't brick every launch.
|
|
217
|
+
export const SHIM_SCHEMA_VERSION = 25;
|
|
215
218
|
/** Internal marker string used to embed the schema version in shim scripts. */
|
|
216
219
|
const SHIM_VERSION_MARKER = 'agents-shim-version:';
|
|
217
220
|
function shellQuote(value) {
|
|
@@ -291,8 +294,19 @@ AGENT="${agent}"
|
|
|
291
294
|
CLI_COMMAND="${cliCommand}"
|
|
292
295
|
|
|
293
296
|
if [ -z "$AGENTS_BIN" ] || [ ! -x "$AGENTS_BIN" ]; then
|
|
294
|
-
|
|
295
|
-
|
|
297
|
+
# The baked dispatcher is gone — e.g. the build that generated this shim (often
|
|
298
|
+
# a dev build under ~/.local/agents-cli-dev) was removed, moved, or its version
|
|
299
|
+
# dir rotated. Self-recover to whatever 'agents' now resolves to on PATH instead
|
|
300
|
+
# of bricking every managed launch. 'agents' is the CLI itself, never a per-agent
|
|
301
|
+
# shim, so this cannot re-enter this dispatcher.
|
|
302
|
+
RECOVERED_BIN="$(command -v agents 2>/dev/null || true)"
|
|
303
|
+
if [ -n "$RECOVERED_BIN" ] && [ -x "$RECOVERED_BIN" ]; then
|
|
304
|
+
AGENTS_BIN="$RECOVERED_BIN"
|
|
305
|
+
else
|
|
306
|
+
echo "agents: agents-cli entrypoint missing or not executable: $AGENTS_BIN" >&2
|
|
307
|
+
echo "agents: could not resolve 'agents' on PATH to recover. Reinstall: npm i -g @phnx-labs/agents-cli" >&2
|
|
308
|
+
exit 127
|
|
309
|
+
fi
|
|
296
310
|
fi
|
|
297
311
|
|
|
298
312
|
# When agents-cli "adopts" a harness's own launcher (symlinks the native binary
|
package/dist/lib/teams/agents.js
CHANGED
|
@@ -20,6 +20,7 @@ import { debug } from './debug.js';
|
|
|
20
20
|
import { setGeminiAutoUpdateDisabled, updateGeminiSettings } from '../gemini-settings.js';
|
|
21
21
|
import { getAgentsDir as getSystemAgentsDir, getShimsDir } from '../state.js';
|
|
22
22
|
import { AGENTS, getAccountInfo } from '../agents.js';
|
|
23
|
+
import { resolveVersion, isVersionInstalled } from '../versions.js';
|
|
23
24
|
import { sanitizeProcessEnv } from '../secrets/bundles.js';
|
|
24
25
|
let lastMemoryWarnAt = 0;
|
|
25
26
|
// On macOS, os.freemem() returns only the truly-free pool and ignores the
|
|
@@ -326,18 +327,26 @@ export async function ensureGeminiPlanMode() {
|
|
|
326
327
|
* (for CLIs the user installed outside agents-cli).
|
|
327
328
|
*/
|
|
328
329
|
export function checkCliAvailable(agentType) {
|
|
329
|
-
const
|
|
330
|
+
const agent = agentType;
|
|
331
|
+
const executable = AGENTS[agent]?.cliCommand;
|
|
330
332
|
if (!executable) {
|
|
331
333
|
return [false, `Unknown agent type: ${agentType}`];
|
|
332
334
|
}
|
|
333
335
|
const shimPath = path.join(getShimsDir(), executable);
|
|
334
|
-
|
|
335
|
-
|
|
336
|
+
const dispatch = fsSync.existsSync(shimPath) ? shimPath : findExecutable(executable);
|
|
337
|
+
if (!dispatch) {
|
|
338
|
+
return [false, `CLI tool '${executable}' not found in PATH. Install it first.`];
|
|
336
339
|
}
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
340
|
+
// A shim file (or a PATH entry) existing does NOT mean the agent is runnable:
|
|
341
|
+
// the managed default version's binary can be a stub or gutted (a partial/raced
|
|
342
|
+
// npm extract leaves the version dir + JS wrapper but no real binary). Verify
|
|
343
|
+
// the resolved default version is actually installed so `teams doctor` reports
|
|
344
|
+
// the truth instead of a false `installed: true` that ENOENTs at spawn.
|
|
345
|
+
const version = resolveVersion(agent);
|
|
346
|
+
if (version && !isVersionInstalled(agent, version)) {
|
|
347
|
+
return [false, `${executable}@${version} is not runnable — its binary is missing/incomplete. Repair: agents add ${agent}@${version}`];
|
|
348
|
+
}
|
|
349
|
+
return [true, dispatch];
|
|
341
350
|
}
|
|
342
351
|
/** Check availability of all known agent CLIs. Returns a map of agent type to install status. */
|
|
343
352
|
export function checkAllClis() {
|
|
@@ -126,6 +126,46 @@ export declare function paneExitStatus(pane: string, socket?: string): Promise<P
|
|
|
126
126
|
* Best-effort — a failed hook just means the user Ctrl-b d's out manually.
|
|
127
127
|
*/
|
|
128
128
|
export declare function setSessionHook(name: string, hook: string, command: string, socket?: string): Promise<void>;
|
|
129
|
+
/**
|
|
130
|
+
* Schema version of the `pane-died` hook installed on managed `agents run`
|
|
131
|
+
* sessions. Bump whenever the hook's SHAPE changes so the daemon reconcile
|
|
132
|
+
* (reconcileSessionHooks) knows to re-stamp live sessions a prior binary left on
|
|
133
|
+
* an older shape.
|
|
134
|
+
* v1 — the original unconditional `detach-client`: ANY pane death (including a
|
|
135
|
+
* user exiting a split they opened) tore down the whole client.
|
|
136
|
+
* v2 — `#{hook_pane}`-guarded: only the AGENT pane dying detaches; a user
|
|
137
|
+
* split's death runs `kill-pane`, closing just that split.
|
|
138
|
+
*/
|
|
139
|
+
export declare const AGENT_HOOK_SCHEMA = 2;
|
|
140
|
+
/**
|
|
141
|
+
* The guarded `pane-died` hook. Detach the client ONLY when the agent pane dies
|
|
142
|
+
* (so the blocking attach in runInTmux returns and the exit status can be read);
|
|
143
|
+
* a user split's death falls through to `kill-pane`, which — because the hook
|
|
144
|
+
* runs in the dead pane's context — closes that split in place. Single source of
|
|
145
|
+
* truth: both the spawn-wrap (exec.ts) and the daemon reconcile build the hook
|
|
146
|
+
* here, so the two can never drift.
|
|
147
|
+
*/
|
|
148
|
+
export declare function agentPaneDiedHook(sessionName: string, agentPane: string): string;
|
|
149
|
+
/** Stamp a session's hook-schema marker to the current version. */
|
|
150
|
+
export declare function markSessionHookSchema(name: string, socket?: string): Promise<void>;
|
|
151
|
+
/**
|
|
152
|
+
* Retrofit the current guarded `pane-died` hook onto every managed `agents run`
|
|
153
|
+
* session whose hook predates AGENT_HOOK_SCHEMA. Idempotent and NON-DESTRUCTIVE:
|
|
154
|
+
* it only `set-hook`s (never kills a pane or detaches a client), so a long-lived
|
|
155
|
+
* shared server started by a pre-fix binary — whose still-running sessions carry
|
|
156
|
+
* the old unconditional hook that kicked the user out of the whole view when they
|
|
157
|
+
* exited a split — self-heals in place, without waiting for those agents to exit
|
|
158
|
+
* or for the server to be recycled.
|
|
159
|
+
*
|
|
160
|
+
* The daemon calls this on a light interval. The per-session `@ag_hook_schema`
|
|
161
|
+
* marker makes steady-state a cheap no-op: a session already at the current
|
|
162
|
+
* schema is skipped. Only run-wrapped sessions (`ag-` prefix) are touched — an
|
|
163
|
+
* externally-created session on the socket keeps whatever hook it set.
|
|
164
|
+
*/
|
|
165
|
+
export declare function reconcileSessionHooks(socket?: string): Promise<{
|
|
166
|
+
scanned: number;
|
|
167
|
+
reconciled: number;
|
|
168
|
+
}>;
|
|
129
169
|
/**
|
|
130
170
|
* List live sessions on the socket. Reconciles meta JSONs against tmux's view:
|
|
131
171
|
* - tmux session with no meta → returned without `meta` (external session)
|
package/dist/lib/tmux/session.js
CHANGED
|
@@ -269,6 +269,98 @@ export async function setSessionHook(name, hook, command, socket) {
|
|
|
269
269
|
const sock = socket ?? getDefaultSocketPath();
|
|
270
270
|
await runTmux({ socket: sock, args: ['set-hook', '-t', name, hook, command], throwOnError: false }).catch(() => { });
|
|
271
271
|
}
|
|
272
|
+
/**
|
|
273
|
+
* Schema version of the `pane-died` hook installed on managed `agents run`
|
|
274
|
+
* sessions. Bump whenever the hook's SHAPE changes so the daemon reconcile
|
|
275
|
+
* (reconcileSessionHooks) knows to re-stamp live sessions a prior binary left on
|
|
276
|
+
* an older shape.
|
|
277
|
+
* v1 — the original unconditional `detach-client`: ANY pane death (including a
|
|
278
|
+
* user exiting a split they opened) tore down the whole client.
|
|
279
|
+
* v2 — `#{hook_pane}`-guarded: only the AGENT pane dying detaches; a user
|
|
280
|
+
* split's death runs `kill-pane`, closing just that split.
|
|
281
|
+
*/
|
|
282
|
+
export const AGENT_HOOK_SCHEMA = 2;
|
|
283
|
+
/** Per-session tmux user-option that records which AGENT_HOOK_SCHEMA a session's hook is at. */
|
|
284
|
+
const HOOK_SCHEMA_OPTION = '@ag_hook_schema';
|
|
285
|
+
/**
|
|
286
|
+
* The guarded `pane-died` hook. Detach the client ONLY when the agent pane dies
|
|
287
|
+
* (so the blocking attach in runInTmux returns and the exit status can be read);
|
|
288
|
+
* a user split's death falls through to `kill-pane`, which — because the hook
|
|
289
|
+
* runs in the dead pane's context — closes that split in place. Single source of
|
|
290
|
+
* truth: both the spawn-wrap (exec.ts) and the daemon reconcile build the hook
|
|
291
|
+
* here, so the two can never drift.
|
|
292
|
+
*/
|
|
293
|
+
export function agentPaneDiedHook(sessionName, agentPane) {
|
|
294
|
+
return `if -F '#{==:#{hook_pane},${agentPane}}' 'detach-client -s =${sessionName}' 'kill-pane'`;
|
|
295
|
+
}
|
|
296
|
+
/** Stamp a session's hook-schema marker to the current version. */
|
|
297
|
+
export async function markSessionHookSchema(name, socket) {
|
|
298
|
+
const sock = socket ?? getDefaultSocketPath();
|
|
299
|
+
await runTmux({ socket: sock, args: ['set-option', '-t', name, HOOK_SCHEMA_OPTION, String(AGENT_HOOK_SCHEMA)], throwOnError: false }).catch(() => { });
|
|
300
|
+
}
|
|
301
|
+
/** Read a session's hook-schema marker; undefined when unset (pre-marker sessions). */
|
|
302
|
+
async function readHookSchema(name, socket) {
|
|
303
|
+
const res = await runTmux({ socket, args: ['show-options', '-v', '-t', name, HOOK_SCHEMA_OPTION], throwOnError: false }).catch(() => null);
|
|
304
|
+
if (!res || res.code !== 0)
|
|
305
|
+
return undefined;
|
|
306
|
+
const v = res.stdout.trim();
|
|
307
|
+
return v === '' ? undefined : v;
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Lowest pane id (`%N`) in a session — the first pane created, i.e. the agent
|
|
311
|
+
* pane, since user splits are always created later and get higher ids. Fallback
|
|
312
|
+
* for sessions whose SessionMeta (which records the agent pane) predates meta
|
|
313
|
+
* persistence. Undefined when the session has no panes (already torn down).
|
|
314
|
+
*/
|
|
315
|
+
async function lowestPaneId(name, socket) {
|
|
316
|
+
const res = await runTmux({ socket, args: ['list-panes', '-t', name, '-F', '#{pane_id}'], throwOnError: false }).catch(() => null);
|
|
317
|
+
if (!res || res.code !== 0)
|
|
318
|
+
return undefined;
|
|
319
|
+
const ids = res.stdout.split('\n').map(l => l.trim()).filter(id => /^%\d+$/.test(id));
|
|
320
|
+
if (!ids.length)
|
|
321
|
+
return undefined;
|
|
322
|
+
return ids.reduce((lo, id) => (parseInt(id.slice(1), 10) < parseInt(lo.slice(1), 10) ? id : lo));
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Retrofit the current guarded `pane-died` hook onto every managed `agents run`
|
|
326
|
+
* session whose hook predates AGENT_HOOK_SCHEMA. Idempotent and NON-DESTRUCTIVE:
|
|
327
|
+
* it only `set-hook`s (never kills a pane or detaches a client), so a long-lived
|
|
328
|
+
* shared server started by a pre-fix binary — whose still-running sessions carry
|
|
329
|
+
* the old unconditional hook that kicked the user out of the whole view when they
|
|
330
|
+
* exited a split — self-heals in place, without waiting for those agents to exit
|
|
331
|
+
* or for the server to be recycled.
|
|
332
|
+
*
|
|
333
|
+
* The daemon calls this on a light interval. The per-session `@ag_hook_schema`
|
|
334
|
+
* marker makes steady-state a cheap no-op: a session already at the current
|
|
335
|
+
* schema is skipped. Only run-wrapped sessions (`ag-` prefix) are touched — an
|
|
336
|
+
* externally-created session on the socket keeps whatever hook it set.
|
|
337
|
+
*/
|
|
338
|
+
export async function reconcileSessionHooks(socket) {
|
|
339
|
+
const sock = socket ?? getDefaultSocketPath();
|
|
340
|
+
if (!fs.existsSync(sock))
|
|
341
|
+
return { scanned: 0, reconciled: 0 };
|
|
342
|
+
let sessions;
|
|
343
|
+
try {
|
|
344
|
+
sessions = await listSessions({ socket: sock });
|
|
345
|
+
}
|
|
346
|
+
catch {
|
|
347
|
+
return { scanned: 0, reconciled: 0 };
|
|
348
|
+
}
|
|
349
|
+
let reconciled = 0;
|
|
350
|
+
for (const s of sessions) {
|
|
351
|
+
if (!s.name.startsWith('ag-'))
|
|
352
|
+
continue; // only run-wrapped sessions
|
|
353
|
+
if (await readHookSchema(s.name, sock) === String(AGENT_HOOK_SCHEMA))
|
|
354
|
+
continue;
|
|
355
|
+
const agentPane = s.meta?.pane ?? await lowestPaneId(s.name, sock);
|
|
356
|
+
if (!agentPane)
|
|
357
|
+
continue;
|
|
358
|
+
await setSessionHook(s.name, 'pane-died', agentPaneDiedHook(s.name, agentPane), sock);
|
|
359
|
+
await markSessionHookSchema(s.name, sock);
|
|
360
|
+
reconciled++;
|
|
361
|
+
}
|
|
362
|
+
return { scanned: sessions.length, reconciled };
|
|
363
|
+
}
|
|
272
364
|
/**
|
|
273
365
|
* List live sessions on the socket. Reconciles meta JSONs against tmux's view:
|
|
274
366
|
* - tmux session with no meta → returned without `meta` (external session)
|