@phnx-labs/agents-cli 1.20.36 → 1.20.38
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/dist/commands/computer-actions.d.ts +10 -0
- package/dist/commands/computer-actions.js +47 -17
- package/dist/commands/doctor.js +48 -1
- package/dist/commands/go.d.ts +28 -0
- package/dist/commands/go.js +238 -0
- package/dist/commands/sessions-picker.d.ts +2 -0
- package/dist/commands/sessions-picker.js +10 -1
- package/dist/commands/sessions-sync.d.ts +3 -0
- package/dist/commands/sessions-sync.js +44 -4
- package/dist/commands/sessions.d.ts +8 -1
- package/dist/commands/sessions.js +155 -36
- package/dist/index.js +59 -68
- package/dist/lib/daemon.js +4 -2
- package/dist/lib/devices/resolve-target.d.ts +24 -0
- package/dist/lib/devices/resolve-target.js +80 -0
- package/dist/lib/session/active.d.ts +25 -0
- package/dist/lib/session/active.js +11 -5
- package/dist/lib/session/db.d.ts +2 -1
- package/dist/lib/session/db.js +41 -5
- package/dist/lib/session/discover.d.ts +2 -0
- package/dist/lib/session/discover.js +16 -1
- package/dist/lib/session/ghostty-tabs.d.ts +33 -0
- package/dist/lib/session/ghostty-tabs.js +126 -0
- package/dist/lib/session/relative-time.js +6 -2
- package/dist/lib/session/remote-active.js +4 -14
- package/dist/lib/session/remote-list.js +4 -12
- package/dist/lib/session/remote.js +4 -2
- package/dist/lib/session/sync/config.d.ts +13 -0
- package/dist/lib/session/sync/config.js +56 -0
- package/dist/lib/session/types.d.ts +6 -0
- package/dist/lib/shims.d.ts +65 -1
- package/dist/lib/shims.js +237 -20
- package/dist/lib/sync-umbrella.js +4 -4
- package/dist/lib/tmux/session.d.ts +10 -0
- package/dist/lib/tmux/session.js +31 -0
- package/package.json +1 -1
|
@@ -3,9 +3,65 @@
|
|
|
3
3
|
* machine's stable identity. Credentials come from the `r2.backups` secrets
|
|
4
4
|
* bundle (OS keychain on macOS, libsecret on Linux) — never from env or disk.
|
|
5
5
|
*/
|
|
6
|
+
import * as fs from 'fs';
|
|
7
|
+
import * as path from 'path';
|
|
6
8
|
import { readAndResolveBundleEnv } from '../../secrets/bundles.js';
|
|
9
|
+
import { getHistoryDir } from '../../state.js';
|
|
7
10
|
/** Secrets bundle holding the R2 credentials. */
|
|
8
11
|
export const SYNC_BUNDLE = 'r2.backups';
|
|
12
|
+
// ── Enable / disable switch ─────────────────────────────────────────────────
|
|
13
|
+
// Whether the daemon's automatic cross-machine sync (and `agents sync
|
|
14
|
+
// --sessions`) may run on THIS machine. Independent of credential presence
|
|
15
|
+
// (isSyncConfigured): a machine can hold valid R2 creds yet still opt out of the
|
|
16
|
+
// background push/pull — e.g. when on-demand `agents sessions --host` is
|
|
17
|
+
// preferred over the ad-hoc R2 mirror. Manual `agents sessions sync` is an
|
|
18
|
+
// explicit user action and is deliberately NOT gated by this switch.
|
|
19
|
+
//
|
|
20
|
+
// Resolution order: the AGENTS_SESSIONS_SYNC env var (a recognized on/off value
|
|
21
|
+
// wins outright, for ad-hoc overrides and tests), then a durable machine-local
|
|
22
|
+
// flag file, then the default (enabled). The flag lives in the durable
|
|
23
|
+
// ~/.agents/.history tree — NOT .cache — so a cache wipe can never silently
|
|
24
|
+
// re-enable a sync the operator turned off.
|
|
25
|
+
/** Env var that overrides the persisted enable flag (on/off/true/false/1/0/yes/no). */
|
|
26
|
+
export const SYNC_ENABLED_ENV = 'AGENTS_SESSIONS_SYNC';
|
|
27
|
+
const SYNC_ENABLED_FILE = 'sessions-sync.json';
|
|
28
|
+
const OFF_VALUES = new Set(['0', 'off', 'false', 'no', 'disabled']);
|
|
29
|
+
const ON_VALUES = new Set(['1', 'on', 'true', 'yes', 'enabled']);
|
|
30
|
+
/** Durable, machine-local path holding the sync enable flag. */
|
|
31
|
+
export function syncStateFilePath() {
|
|
32
|
+
return path.join(getHistoryDir(), SYNC_ENABLED_FILE);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Whether automatic session sync is enabled on this machine. Defaults to true;
|
|
36
|
+
* an unrecognized env value falls through to the file; an absent/unreadable file
|
|
37
|
+
* falls through to the default. Read fresh every call (no memoization) so a
|
|
38
|
+
* `--disable` takes effect on the daemon's next ~90s cycle without a restart.
|
|
39
|
+
*/
|
|
40
|
+
export function isSyncEnabled() {
|
|
41
|
+
const envRaw = process.env[SYNC_ENABLED_ENV]?.trim().toLowerCase();
|
|
42
|
+
if (envRaw) {
|
|
43
|
+
if (OFF_VALUES.has(envRaw))
|
|
44
|
+
return false;
|
|
45
|
+
if (ON_VALUES.has(envRaw))
|
|
46
|
+
return true;
|
|
47
|
+
// Unrecognized value: ignore and consult the persisted flag.
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
const parsed = JSON.parse(fs.readFileSync(syncStateFilePath(), 'utf-8'));
|
|
51
|
+
if (parsed && typeof parsed.enabled === 'boolean')
|
|
52
|
+
return parsed.enabled;
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// Absent or unreadable → default enabled.
|
|
56
|
+
}
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
/** Persist the machine-local sync enable flag (durable across cache wipes). */
|
|
60
|
+
export function setSyncEnabled(enabled) {
|
|
61
|
+
const p = syncStateFilePath();
|
|
62
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
63
|
+
fs.writeFileSync(p, JSON.stringify({ enabled }, null, 2) + '\n', 'utf-8');
|
|
64
|
+
}
|
|
9
65
|
/**
|
|
10
66
|
* Resolve R2 credentials from the `r2.backups` bundle. Throws a clear,
|
|
11
67
|
* actionable error if the bundle or any key is missing — sync cannot proceed
|
|
@@ -46,6 +46,12 @@ export interface SessionMeta {
|
|
|
46
46
|
shortId: string;
|
|
47
47
|
agent: SessionAgentId;
|
|
48
48
|
timestamp: string;
|
|
49
|
+
/**
|
|
50
|
+
* Last-activity time (ISO): the last message timestamp when a parser computed
|
|
51
|
+
* it, else file mtime, else `timestamp`. This is the recency signal the
|
|
52
|
+
* listing sorts and labels by; `timestamp` stays the creation time.
|
|
53
|
+
*/
|
|
54
|
+
lastActivity?: string;
|
|
49
55
|
project?: string;
|
|
50
56
|
cwd?: string;
|
|
51
57
|
filePath: string;
|
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 = 23;
|
|
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.
|
|
@@ -311,6 +311,70 @@ export declare function getPathShadowingExecutable(agent: AgentId): string | nul
|
|
|
311
311
|
export declare function removeLegacyUserShim(agent: AgentId, overrides?: {
|
|
312
312
|
homeDir?: string;
|
|
313
313
|
}): boolean;
|
|
314
|
+
/**
|
|
315
|
+
* Where an adopted launcher's provenance is recorded. Lives under durable
|
|
316
|
+
* `.history` (NOT the regenerable `.cache`) so the reverse pointer to the native
|
|
317
|
+
* binary survives a cache wipe — the shim reads it to fall through to the native
|
|
318
|
+
* binary by absolute path when no managed version resolves. Two lines:
|
|
319
|
+
* line 1 = original binary, line 2 = launcher path (for `--release`).
|
|
320
|
+
*/
|
|
321
|
+
export declare function getAdoptedRecordPath(agent: AgentId, historyDir?: string): string;
|
|
322
|
+
/**
|
|
323
|
+
* The launcher a harness's own installer drops in an early-PATH dir. Detection
|
|
324
|
+
* for adoption keys on the launcher *existing as a symlink resolving outside our
|
|
325
|
+
* shims dir* — NOT on current PATH order. That's deliberate: the shim only loses
|
|
326
|
+
* PATH races in non-interactive / GUI-launched shells, which an interactive
|
|
327
|
+
* `agents` run can't observe via its own PATH. Keying on the durable symlink lets
|
|
328
|
+
* auto-adoption fire for those users too. Returns the launcher path or null.
|
|
329
|
+
*/
|
|
330
|
+
export declare function findAdoptableLauncher(agent: AgentId, overrides?: {
|
|
331
|
+
homeDir?: string;
|
|
332
|
+
shimsDir?: string;
|
|
333
|
+
}): string | null;
|
|
334
|
+
export type AdoptResult = {
|
|
335
|
+
adopted: true;
|
|
336
|
+
launcher: string;
|
|
337
|
+
original: string;
|
|
338
|
+
} | {
|
|
339
|
+
adopted: false;
|
|
340
|
+
reason: 'no-shadow' | 'already-adopted' | 'not-a-symlink' | 'unsafe-target' | 'error';
|
|
341
|
+
launcher?: string;
|
|
342
|
+
};
|
|
343
|
+
/**
|
|
344
|
+
* Adopt the harness's own launcher that shadows our shim on PATH.
|
|
345
|
+
*
|
|
346
|
+
* PATH-ordering fixes (editing rc files) can never reliably win: `~/.local/bin`
|
|
347
|
+
* (where grok/droid/etc. self-install) is prepended in `.zshenv`/`.zprofile`
|
|
348
|
+
* for *every* shell, while our shims prepend only lands in `.zshrc`
|
|
349
|
+
* (interactive). No single rc file guarantees "last prepend wins" across zsh's
|
|
350
|
+
* whole sourcing chain, so the shim loses in non-interactive / GUI-launched
|
|
351
|
+
* contexts. Instead of fighting PATH order, we *become* the launcher: replace
|
|
352
|
+
* the shadowing symlink with one pointing at our shim, and record the real
|
|
353
|
+
* original so the shim falls through to it when no managed version is selected.
|
|
354
|
+
*
|
|
355
|
+
* Regression bounds:
|
|
356
|
+
* - Only ever touches a **symlink** (never renames/deletes a real binary).
|
|
357
|
+
* - Records the resolved original + launcher path for lossless restore
|
|
358
|
+
* (`releaseAdoptedLauncher`), in durable `.history` so a cache wipe can't
|
|
359
|
+
* orphan the reverse pointer.
|
|
360
|
+
* - Idempotent: a no-op once the launcher already points at our shim.
|
|
361
|
+
* - Never records our own shim as the "original" (would loop).
|
|
362
|
+
*/
|
|
363
|
+
export declare function adoptShadowingLauncher(agent: AgentId, overrides?: {
|
|
364
|
+
shadowedBy?: string;
|
|
365
|
+
shimsDir?: string;
|
|
366
|
+
historyDir?: string;
|
|
367
|
+
}): AdoptResult;
|
|
368
|
+
/**
|
|
369
|
+
* Undo `adoptShadowingLauncher`: repoint the launcher back at the recorded
|
|
370
|
+
* original and drop the record. Reversible escape hatch for users who want the
|
|
371
|
+
* native launcher to win. Returns the restored original path, or null if there
|
|
372
|
+
* was nothing to release.
|
|
373
|
+
*/
|
|
374
|
+
export declare function releaseAdoptedLauncher(agent: AgentId, overrides?: {
|
|
375
|
+
shimsDir?: string;
|
|
376
|
+
historyDir?: string;
|
|
377
|
+
}): string | null;
|
|
314
378
|
export declare function hasAliasShadowingShim(agent: AgentId, overrides?: {
|
|
315
379
|
homeDir?: string;
|
|
316
380
|
}): boolean;
|
package/dist/lib/shims.js
CHANGED
|
@@ -14,7 +14,7 @@ import * as os from 'os';
|
|
|
14
14
|
import { fileURLToPath } from 'url';
|
|
15
15
|
import { confirm, select } from '@inquirer/prompts';
|
|
16
16
|
import { IS_WINDOWS, prependToWindowsUserPath } from './platform/index.js';
|
|
17
|
-
import { getShimsDir, getVersionsDir, getBackupsDir, ensureAgentsDir } from './state.js';
|
|
17
|
+
import { getShimsDir, getVersionsDir, getBackupsDir, getHistoryDir, ensureAgentsDir } from './state.js';
|
|
18
18
|
export { getShimsDir };
|
|
19
19
|
import { AGENTS, agentConfigDirName } from './agents.js';
|
|
20
20
|
/**
|
|
@@ -211,7 +211,7 @@ 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
|
-
export const SHIM_SCHEMA_VERSION =
|
|
214
|
+
export const SHIM_SCHEMA_VERSION = 23;
|
|
215
215
|
/** Internal marker string used to embed the schema version in shim scripts. */
|
|
216
216
|
const SHIM_VERSION_MARKER = 'agents-shim-version:';
|
|
217
217
|
function shellQuote(value) {
|
|
@@ -295,6 +295,33 @@ if [ -z "$AGENTS_BIN" ] || [ ! -x "$AGENTS_BIN" ]; then
|
|
|
295
295
|
exit 127
|
|
296
296
|
fi
|
|
297
297
|
|
|
298
|
+
# When agents-cli "adopts" a harness's own launcher (symlinks the native binary
|
|
299
|
+
# in ~/.local/bin to this dispatcher so version management wins regardless of
|
|
300
|
+
# PATH order), it records the real original here. Durable (.history, not the
|
|
301
|
+
# regenerable .cache) so the reverse pointer survives a cache wipe. Line 1 is
|
|
302
|
+
# the original binary (what we fall through to); line 2 is the launcher path
|
|
303
|
+
# (used by --release). It is the only safe fall-through target: exec it by
|
|
304
|
+
# ABSOLUTE PATH so we never re-resolve through PATH (which now points back at
|
|
305
|
+
# this dispatcher → infinite re-exec loop).
|
|
306
|
+
ADOPTED_ORIGINAL="$AGENTS_USER_DIR/.history/adopted-launchers/$CLI_COMMAND"
|
|
307
|
+
# Print the recorded original binary iff it is an executable file, else nothing.
|
|
308
|
+
adopted_original_bin() {
|
|
309
|
+
[ -f "$ADOPTED_ORIGINAL" ] || return 1
|
|
310
|
+
local orig
|
|
311
|
+
# First line only — line 2 (launcher path) is for --release, not exec.
|
|
312
|
+
IFS= read -r orig < "$ADOPTED_ORIGINAL" 2>/dev/null || return 1
|
|
313
|
+
[ -n "$orig" ] && [ -x "$orig" ] || return 1
|
|
314
|
+
printf '%s' "$orig"
|
|
315
|
+
}
|
|
316
|
+
# Last-resort fall-through: if a managed version can't be resolved but we've
|
|
317
|
+
# adopted this command's native launcher, run the original so the user's command
|
|
318
|
+
# never breaks. Replaces the process; returns non-zero only when no usable record.
|
|
319
|
+
exec_adopted_original() {
|
|
320
|
+
local orig
|
|
321
|
+
orig=$(adopted_original_bin) || return 1
|
|
322
|
+
exec "$orig" "$@"
|
|
323
|
+
}
|
|
324
|
+
|
|
298
325
|
# Find project agents.yaml walking up from cwd (skip $HOME/.agents/agents.yaml)
|
|
299
326
|
find_project_version() {
|
|
300
327
|
local dir="$PWD"
|
|
@@ -378,15 +405,20 @@ if [ -z "$VERSION" ]; then
|
|
|
378
405
|
VERSION_SOURCE="default"
|
|
379
406
|
;;
|
|
380
407
|
*)
|
|
408
|
+
exec_adopted_original "$@"
|
|
381
409
|
echo " Run: agents use $AGENT <version>" >&2
|
|
382
410
|
exit 1
|
|
383
411
|
;;
|
|
384
412
|
esac
|
|
385
413
|
else
|
|
414
|
+
exec_adopted_original "$@"
|
|
386
415
|
echo " Run: agents use $AGENT <version>" >&2
|
|
387
416
|
exit 1
|
|
388
417
|
fi
|
|
389
418
|
else
|
|
419
|
+
# No managed version at all. If we adopted this command's native launcher,
|
|
420
|
+
# run it so the command keeps working; otherwise report it's unconfigured.
|
|
421
|
+
exec_adopted_original "$@"
|
|
390
422
|
echo "agents: no version of $AGENT configured" >&2
|
|
391
423
|
echo " Run: agents add $AGENT@<version>" >&2
|
|
392
424
|
exit 1
|
|
@@ -414,16 +446,18 @@ if [ "$AGENT" = "grok" ]; then
|
|
|
414
446
|
fi
|
|
415
447
|
fi
|
|
416
448
|
if [ -z "$BINARY" ] || [ ! -x "$BINARY" ]; then
|
|
417
|
-
# Last resort:
|
|
418
|
-
#
|
|
419
|
-
#
|
|
420
|
-
# exec-ing it would re-enter and spin
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
"$
|
|
426
|
-
|
|
449
|
+
# Last resort: the adopted native launcher (recorded absolute path) if we
|
|
450
|
+
# adopted grok, else whatever is on PATH. Prefer the adopted record — after
|
|
451
|
+
# adoption, "command -v grok" resolves to the ~/.local/bin symlink that now
|
|
452
|
+
# points at THIS dispatcher, so exec-ing it would re-enter and spin forever.
|
|
453
|
+
BINARY=$(adopted_original_bin || echo "")
|
|
454
|
+
if [ -z "$BINARY" ]; then
|
|
455
|
+
BINARY=$(command -v grok 2>/dev/null || echo "")
|
|
456
|
+
# Refuse anything that resolves into our own shims dir (the dispatcher).
|
|
457
|
+
case "$(command -v "$BINARY" 2>/dev/null; readlink -f "$BINARY" 2>/dev/null)" in
|
|
458
|
+
*"$AGENTS_USER_DIR/.cache/shims/"*) BINARY="" ;;
|
|
459
|
+
esac
|
|
460
|
+
fi
|
|
427
461
|
fi
|
|
428
462
|
# Kimi is a normal npm agent: "agents add kimi" npm-installs
|
|
429
463
|
# @moonshot-ai/kimi-code into the version dir and the binary lands at
|
|
@@ -440,14 +474,20 @@ if [ "$AGENT" = "grok" ]; then
|
|
|
440
474
|
# IS this dispatcher, so exec'ing it would re-enter and spin in an infinite
|
|
441
475
|
# re-exec loop (the bug this branch fixes).
|
|
442
476
|
elif [ "$AGENT" = "droid" ]; then
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
477
|
+
# Prefer the adopted record first: if droid's ~/.local/bin/droid launcher was
|
|
478
|
+
# adopted, that fixed path now points at THIS dispatcher, so using it directly
|
|
479
|
+
# would infinite-loop. The record holds the real original binary.
|
|
480
|
+
BINARY=$(adopted_original_bin || echo "")
|
|
481
|
+
if [ -z "$BINARY" ]; then
|
|
482
|
+
DROID_BINARY="$HOME/.local/bin/droid"
|
|
483
|
+
if [ -x "$DROID_BINARY" ] && [ "$(readlink -f "$DROID_BINARY" 2>/dev/null)" != "$(readlink -f "$AGENTS_USER_DIR/.cache/shims/$CLI_COMMAND" 2>/dev/null)" ]; then
|
|
484
|
+
BINARY="$DROID_BINARY"
|
|
485
|
+
else
|
|
486
|
+
BINARY=$(command -v droid 2>/dev/null || echo "")
|
|
487
|
+
case "$(readlink -f "$BINARY" 2>/dev/null)" in
|
|
488
|
+
"$AGENTS_USER_DIR/.cache/shims/"*) BINARY="" ;;
|
|
489
|
+
esac
|
|
490
|
+
fi
|
|
451
491
|
fi
|
|
452
492
|
else
|
|
453
493
|
BINARY="$VERSION_DIR/node_modules/.bin/$CLI_COMMAND"
|
|
@@ -481,6 +521,7 @@ if [ ! -x "$BINARY" ]; then
|
|
|
481
521
|
echo " ✔ Installed $AGENT@$VERSION" >&2
|
|
482
522
|
else
|
|
483
523
|
echo " ✗ Failed to install $AGENT@$VERSION" >&2
|
|
524
|
+
exec_adopted_original "$@"
|
|
484
525
|
exit 1
|
|
485
526
|
fi
|
|
486
527
|
else
|
|
@@ -498,15 +539,18 @@ if [ ! -x "$BINARY" ]; then
|
|
|
498
539
|
BINARY="$VERSION_DIR/node_modules/.bin/$CLI_COMMAND"
|
|
499
540
|
;;
|
|
500
541
|
*)
|
|
542
|
+
exec_adopted_original "$@"
|
|
501
543
|
echo " Run: agents add $AGENT@$VERSION" >&2
|
|
502
544
|
exit 1
|
|
503
545
|
;;
|
|
504
546
|
esac
|
|
505
547
|
else
|
|
548
|
+
exec_adopted_original "$@"
|
|
506
549
|
echo " Run: agents add $AGENT@$VERSION" >&2
|
|
507
550
|
exit 1
|
|
508
551
|
fi
|
|
509
552
|
else
|
|
553
|
+
exec_adopted_original "$@"
|
|
510
554
|
echo "agents: $AGENT@$VERSION not installed" >&2
|
|
511
555
|
echo " Run: agents add $AGENT@$VERSION" >&2
|
|
512
556
|
exit 1
|
|
@@ -1630,6 +1674,179 @@ export function removeLegacyUserShim(agent, overrides) {
|
|
|
1630
1674
|
return false;
|
|
1631
1675
|
}
|
|
1632
1676
|
}
|
|
1677
|
+
/**
|
|
1678
|
+
* Where an adopted launcher's provenance is recorded. Lives under durable
|
|
1679
|
+
* `.history` (NOT the regenerable `.cache`) so the reverse pointer to the native
|
|
1680
|
+
* binary survives a cache wipe — the shim reads it to fall through to the native
|
|
1681
|
+
* binary by absolute path when no managed version resolves. Two lines:
|
|
1682
|
+
* line 1 = original binary, line 2 = launcher path (for `--release`).
|
|
1683
|
+
*/
|
|
1684
|
+
export function getAdoptedRecordPath(agent, historyDir = getHistoryDir()) {
|
|
1685
|
+
return path.join(historyDir, 'adopted-launchers', AGENTS[agent].cliCommand);
|
|
1686
|
+
}
|
|
1687
|
+
/**
|
|
1688
|
+
* The launcher a harness's own installer drops in an early-PATH dir. Detection
|
|
1689
|
+
* for adoption keys on the launcher *existing as a symlink resolving outside our
|
|
1690
|
+
* shims dir* — NOT on current PATH order. That's deliberate: the shim only loses
|
|
1691
|
+
* PATH races in non-interactive / GUI-launched shells, which an interactive
|
|
1692
|
+
* `agents` run can't observe via its own PATH. Keying on the durable symlink lets
|
|
1693
|
+
* auto-adoption fire for those users too. Returns the launcher path or null.
|
|
1694
|
+
*/
|
|
1695
|
+
export function findAdoptableLauncher(agent, overrides) {
|
|
1696
|
+
const cliCommand = AGENTS[agent].cliCommand;
|
|
1697
|
+
const homeDir = overrides?.homeDir ?? os.homedir();
|
|
1698
|
+
const shimsDirReal = canonical(overrides?.shimsDir ?? getShimsDir());
|
|
1699
|
+
// ~/.local/bin is where grok/kimi/antigravity/claude/codex/droid self-install.
|
|
1700
|
+
const candidate = path.join(homeDir, '.local', 'bin', cliCommand);
|
|
1701
|
+
let stat;
|
|
1702
|
+
try {
|
|
1703
|
+
stat = fs.lstatSync(candidate);
|
|
1704
|
+
}
|
|
1705
|
+
catch {
|
|
1706
|
+
return null;
|
|
1707
|
+
}
|
|
1708
|
+
if (!stat.isSymbolicLink())
|
|
1709
|
+
return null; // real binaries are never auto-adopted
|
|
1710
|
+
let resolved;
|
|
1711
|
+
try {
|
|
1712
|
+
resolved = fs.realpathSync(candidate); // broken symlink throws → skip
|
|
1713
|
+
}
|
|
1714
|
+
catch {
|
|
1715
|
+
return null;
|
|
1716
|
+
}
|
|
1717
|
+
// Already ours, or resolves into our shims dir → not adoptable.
|
|
1718
|
+
if (resolved === shimsDirReal || resolved.startsWith(shimsDirReal + path.sep))
|
|
1719
|
+
return null;
|
|
1720
|
+
return candidate;
|
|
1721
|
+
}
|
|
1722
|
+
/** Canonical path for identity comparison — realpath when it exists (resolves
|
|
1723
|
+
* symlinks AND platform aliases like macOS /var → /private/var), else resolve. */
|
|
1724
|
+
function canonical(p) {
|
|
1725
|
+
try {
|
|
1726
|
+
return fs.realpathSync(p);
|
|
1727
|
+
}
|
|
1728
|
+
catch {
|
|
1729
|
+
return path.resolve(p);
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1732
|
+
/**
|
|
1733
|
+
* Adopt the harness's own launcher that shadows our shim on PATH.
|
|
1734
|
+
*
|
|
1735
|
+
* PATH-ordering fixes (editing rc files) can never reliably win: `~/.local/bin`
|
|
1736
|
+
* (where grok/droid/etc. self-install) is prepended in `.zshenv`/`.zprofile`
|
|
1737
|
+
* for *every* shell, while our shims prepend only lands in `.zshrc`
|
|
1738
|
+
* (interactive). No single rc file guarantees "last prepend wins" across zsh's
|
|
1739
|
+
* whole sourcing chain, so the shim loses in non-interactive / GUI-launched
|
|
1740
|
+
* contexts. Instead of fighting PATH order, we *become* the launcher: replace
|
|
1741
|
+
* the shadowing symlink with one pointing at our shim, and record the real
|
|
1742
|
+
* original so the shim falls through to it when no managed version is selected.
|
|
1743
|
+
*
|
|
1744
|
+
* Regression bounds:
|
|
1745
|
+
* - Only ever touches a **symlink** (never renames/deletes a real binary).
|
|
1746
|
+
* - Records the resolved original + launcher path for lossless restore
|
|
1747
|
+
* (`releaseAdoptedLauncher`), in durable `.history` so a cache wipe can't
|
|
1748
|
+
* orphan the reverse pointer.
|
|
1749
|
+
* - Idempotent: a no-op once the launcher already points at our shim.
|
|
1750
|
+
* - Never records our own shim as the "original" (would loop).
|
|
1751
|
+
*/
|
|
1752
|
+
export function adoptShadowingLauncher(agent, overrides) {
|
|
1753
|
+
const shimsDir = overrides?.shimsDir ?? getShimsDir();
|
|
1754
|
+
const shimPath = path.join(shimsDir, AGENTS[agent].cliCommand);
|
|
1755
|
+
const shimReal = canonical(shimPath);
|
|
1756
|
+
const shimsDirReal = canonical(shimsDir);
|
|
1757
|
+
const launcher = overrides?.shadowedBy ?? getPathShadowingExecutable(agent) ?? findAdoptableLauncher(agent, { shimsDir });
|
|
1758
|
+
if (!launcher)
|
|
1759
|
+
return { adopted: false, reason: 'no-shadow' };
|
|
1760
|
+
let stat;
|
|
1761
|
+
try {
|
|
1762
|
+
stat = fs.lstatSync(launcher);
|
|
1763
|
+
}
|
|
1764
|
+
catch {
|
|
1765
|
+
return { adopted: false, reason: 'error', launcher };
|
|
1766
|
+
}
|
|
1767
|
+
// Only adopt symlinks. A real binary in an early-PATH dir is left untouched —
|
|
1768
|
+
// renaming a multi-hundred-MB native binary is exactly the kind of surprise
|
|
1769
|
+
// this feature must avoid. (Its shim stays reachable via the versioned name.)
|
|
1770
|
+
if (!stat.isSymbolicLink()) {
|
|
1771
|
+
return { adopted: false, reason: 'not-a-symlink', launcher };
|
|
1772
|
+
}
|
|
1773
|
+
const resolved = canonical(launcher);
|
|
1774
|
+
// Already ours → nothing to do.
|
|
1775
|
+
if (resolved === shimReal) {
|
|
1776
|
+
return { adopted: false, reason: 'already-adopted', launcher };
|
|
1777
|
+
}
|
|
1778
|
+
// Never record a target that resolves back into our shims dir: exec-ing it
|
|
1779
|
+
// from the shim would re-enter this dispatcher and spin forever.
|
|
1780
|
+
if (resolved === shimsDirReal || resolved.startsWith(shimsDirReal + path.sep)) {
|
|
1781
|
+
return { adopted: false, reason: 'unsafe-target', launcher };
|
|
1782
|
+
}
|
|
1783
|
+
try {
|
|
1784
|
+
const recordPath = getAdoptedRecordPath(agent, overrides?.historyDir);
|
|
1785
|
+
fs.mkdirSync(path.dirname(recordPath), { recursive: true });
|
|
1786
|
+
// Line 1: original binary (shim fall-through target). Line 2: launcher path
|
|
1787
|
+
// (release restores this exact symlink, independent of PATH order at release
|
|
1788
|
+
// time — the M3 fix). Absolute launcher path so release never has to
|
|
1789
|
+
// re-derive it from a PATH scan that may miss.
|
|
1790
|
+
fs.writeFileSync(recordPath, `${resolved}\n${path.resolve(launcher)}\n`, 'utf-8');
|
|
1791
|
+
// Repoint the launcher at our shim. rm + symlink (not atomic rename) is fine
|
|
1792
|
+
// here: the record is already written, so a crash between the two leaves a
|
|
1793
|
+
// recoverable state and the next run re-adopts idempotently.
|
|
1794
|
+
fs.rmSync(launcher);
|
|
1795
|
+
fs.symlinkSync(shimPath, launcher);
|
|
1796
|
+
return { adopted: true, launcher, original: resolved };
|
|
1797
|
+
}
|
|
1798
|
+
catch {
|
|
1799
|
+
return { adopted: false, reason: 'error', launcher };
|
|
1800
|
+
}
|
|
1801
|
+
}
|
|
1802
|
+
/**
|
|
1803
|
+
* Undo `adoptShadowingLauncher`: repoint the launcher back at the recorded
|
|
1804
|
+
* original and drop the record. Reversible escape hatch for users who want the
|
|
1805
|
+
* native launcher to win. Returns the restored original path, or null if there
|
|
1806
|
+
* was nothing to release.
|
|
1807
|
+
*/
|
|
1808
|
+
export function releaseAdoptedLauncher(agent, overrides) {
|
|
1809
|
+
const shimsDir = overrides?.shimsDir ?? getShimsDir();
|
|
1810
|
+
const recordPath = getAdoptedRecordPath(agent, overrides?.historyDir);
|
|
1811
|
+
let lines;
|
|
1812
|
+
try {
|
|
1813
|
+
lines = fs.readFileSync(recordPath, 'utf-8').split('\n').map((l) => l.trim());
|
|
1814
|
+
}
|
|
1815
|
+
catch {
|
|
1816
|
+
return null;
|
|
1817
|
+
}
|
|
1818
|
+
const original = lines[0] ?? '';
|
|
1819
|
+
if (!original)
|
|
1820
|
+
return null;
|
|
1821
|
+
// Line 2 is the exact launcher we rewrote at adopt time. Restoring it directly
|
|
1822
|
+
// (rather than re-deriving from PATH) means release works regardless of the
|
|
1823
|
+
// current shell's PATH order — the M3 fix. Fall back to a PATH scan only for
|
|
1824
|
+
// records written before this format existed.
|
|
1825
|
+
const launcher = lines[1] || getPathShadowingExecutable(agent) || original;
|
|
1826
|
+
const shimReal = canonical(path.join(shimsDir, AGENTS[agent].cliCommand));
|
|
1827
|
+
try {
|
|
1828
|
+
// Only rewrite the launcher if it currently points at our shim (i.e. we own
|
|
1829
|
+
// it). If the user has since replaced it themselves, leave it alone.
|
|
1830
|
+
let pointsAtShim = false;
|
|
1831
|
+
try {
|
|
1832
|
+
pointsAtShim = fs.lstatSync(launcher).isSymbolicLink()
|
|
1833
|
+
&& canonical(launcher) === shimReal;
|
|
1834
|
+
}
|
|
1835
|
+
catch { /* launcher gone — recreate below */ }
|
|
1836
|
+
if (pointsAtShim || !fs.existsSync(launcher)) {
|
|
1837
|
+
try {
|
|
1838
|
+
fs.rmSync(launcher);
|
|
1839
|
+
}
|
|
1840
|
+
catch { /* may not exist */ }
|
|
1841
|
+
fs.symlinkSync(original, launcher);
|
|
1842
|
+
}
|
|
1843
|
+
fs.rmSync(recordPath);
|
|
1844
|
+
return original;
|
|
1845
|
+
}
|
|
1846
|
+
catch {
|
|
1847
|
+
return null;
|
|
1848
|
+
}
|
|
1849
|
+
}
|
|
1633
1850
|
/**
|
|
1634
1851
|
* Check if the agent's CLI command is shadowed by a shell alias.
|
|
1635
1852
|
*
|
|
@@ -111,10 +111,10 @@ export async function runUmbrellaSync(args) {
|
|
|
111
111
|
}
|
|
112
112
|
}
|
|
113
113
|
if (plan.fetchSessions) {
|
|
114
|
-
// Gate exactly like the daemon: a missing r2.backups bundle
|
|
115
|
-
// not an error that fails the whole sync.
|
|
116
|
-
const { isSyncConfigured } = await import('./session/sync/config.js');
|
|
117
|
-
if (isSyncConfigured()) {
|
|
114
|
+
// Gate exactly like the daemon: an off switch or a missing r2.backups bundle
|
|
115
|
+
// is a clean no-op, not an error that fails the whole sync.
|
|
116
|
+
const { isSyncConfigured, isSyncEnabled } = await import('./session/sync/config.js');
|
|
117
|
+
if (isSyncEnabled() && isSyncConfigured()) {
|
|
118
118
|
const { syncSessions } = await import('./session/sync/sync.js');
|
|
119
119
|
const r = await syncSessions();
|
|
120
120
|
result.sessions = { ran: true, pushed: r.pushed, pulled: r.pulled, merged: r.merged };
|
|
@@ -70,6 +70,16 @@ export declare function killSession(name: string, socket?: string): Promise<bool
|
|
|
70
70
|
* meta files. Wipes the socket so the next `new` starts from a clean slate.
|
|
71
71
|
*/
|
|
72
72
|
export declare function killAll(socket?: string): Promise<number>;
|
|
73
|
+
/**
|
|
74
|
+
* Map every pane id (`%N`) on a socket to its `session:window.pane` attach
|
|
75
|
+
* target, in one batched `tmux list-panes -a` call. `%116 -> main:2.0` is a
|
|
76
|
+
* valid `tmux attach -t main:2` / `tmux select-window -t main:2` target — a
|
|
77
|
+
* human jump target, unlike the bare `%pane` send-keys id. Because it walks
|
|
78
|
+
* every pane (not just one-per-session), it also surfaces multiple agents that
|
|
79
|
+
* share a session across windows. Best-effort: returns an empty map on any
|
|
80
|
+
* failure (tmux gone, foreign socket) so callers fall back to the raw pane id.
|
|
81
|
+
*/
|
|
82
|
+
export declare function mapPanesToTargets(socket?: string): Promise<Map<string, string>>;
|
|
73
83
|
/**
|
|
74
84
|
* List live sessions on the socket. Reconciles meta JSONs against tmux's view:
|
|
75
85
|
* - tmux session with no meta → returned without `meta` (external session)
|
package/dist/lib/tmux/session.js
CHANGED
|
@@ -156,6 +156,37 @@ export async function killAll(socket) {
|
|
|
156
156
|
catch { /* may not exist */ }
|
|
157
157
|
return count;
|
|
158
158
|
}
|
|
159
|
+
/**
|
|
160
|
+
* Map every pane id (`%N`) on a socket to its `session:window.pane` attach
|
|
161
|
+
* target, in one batched `tmux list-panes -a` call. `%116 -> main:2.0` is a
|
|
162
|
+
* valid `tmux attach -t main:2` / `tmux select-window -t main:2` target — a
|
|
163
|
+
* human jump target, unlike the bare `%pane` send-keys id. Because it walks
|
|
164
|
+
* every pane (not just one-per-session), it also surfaces multiple agents that
|
|
165
|
+
* share a session across windows. Best-effort: returns an empty map on any
|
|
166
|
+
* failure (tmux gone, foreign socket) so callers fall back to the raw pane id.
|
|
167
|
+
*/
|
|
168
|
+
export async function mapPanesToTargets(socket) {
|
|
169
|
+
const out = new Map();
|
|
170
|
+
let res;
|
|
171
|
+
try {
|
|
172
|
+
res = await runTmux({
|
|
173
|
+
socket,
|
|
174
|
+
args: ['list-panes', '-a', '-F', '#{pane_id} #{session_name}:#{window_index}.#{pane_index}'],
|
|
175
|
+
throwOnError: false,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
return out;
|
|
180
|
+
}
|
|
181
|
+
if (res.code !== 0)
|
|
182
|
+
return out;
|
|
183
|
+
for (const line of res.stdout.split('\n')) {
|
|
184
|
+
const sp = line.indexOf(' ');
|
|
185
|
+
if (sp > 0)
|
|
186
|
+
out.set(line.slice(0, sp), line.slice(sp + 1).trim());
|
|
187
|
+
}
|
|
188
|
+
return out;
|
|
189
|
+
}
|
|
159
190
|
/**
|
|
160
191
|
* List live sessions on the socket. Reconciles meta JSONs against tmux's view:
|
|
161
192
|
* - tmux session with no meta → returned without `meta` (external session)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@phnx-labs/agents-cli",
|
|
3
|
-
"version": "1.20.
|
|
3
|
+
"version": "1.20.38",
|
|
4
4
|
"description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|