@phnx-labs/agents-cli 1.20.34 → 1.20.36
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 +113 -0
- package/LICENSE +185 -21
- package/README.md +13 -5
- package/dist/commands/audit.d.ts +14 -0
- package/dist/commands/audit.js +68 -0
- package/dist/commands/browser.js +82 -8
- package/dist/commands/check.d.ts +15 -0
- package/dist/commands/check.js +84 -0
- package/dist/commands/cloud.js +143 -3
- package/dist/commands/computer-actions.d.ts +15 -0
- package/dist/commands/computer-actions.js +120 -22
- package/dist/commands/computer.js +81 -0
- package/dist/commands/cost.js +17 -12
- package/dist/commands/daemon.js +4 -1
- package/dist/commands/doctor.d.ts +1 -0
- package/dist/commands/doctor.js +54 -97
- package/dist/commands/events.d.ts +16 -0
- package/dist/commands/events.js +173 -0
- package/dist/commands/exec.d.ts +19 -0
- package/dist/commands/exec.js +283 -12
- package/dist/commands/hosts.js +37 -29
- package/dist/commands/inspect.d.ts +2 -0
- package/dist/commands/inspect.js +62 -16
- package/dist/commands/lock.d.ts +12 -0
- package/dist/commands/lock.js +70 -0
- package/dist/commands/logs.d.ts +17 -0
- package/dist/commands/logs.js +139 -0
- package/dist/commands/message.d.ts +15 -0
- package/dist/commands/message.js +56 -0
- package/dist/commands/models.d.ts +3 -0
- package/dist/commands/models.js +27 -8
- package/dist/commands/permissions.js +9 -2
- package/dist/commands/repo.d.ts +34 -0
- package/dist/commands/repo.js +243 -65
- package/dist/commands/resource-view.d.ts +20 -0
- package/dist/commands/resource-view.js +90 -28
- package/dist/commands/routines.js +101 -5
- package/dist/commands/secrets-migrate.js +106 -57
- package/dist/commands/secrets.d.ts +31 -18
- package/dist/commands/secrets.js +196 -91
- package/dist/commands/serve.d.ts +10 -0
- package/dist/commands/serve.js +37 -0
- package/dist/commands/sessions-inject.d.ts +14 -0
- package/dist/commands/sessions-inject.js +111 -0
- package/dist/commands/sessions-picker.d.ts +2 -0
- package/dist/commands/sessions-picker.js +24 -3
- package/dist/commands/sessions-resume.d.ts +2 -0
- package/dist/commands/sessions-resume.js +221 -0
- package/dist/commands/sessions-tail.d.ts +10 -0
- package/dist/commands/sessions-tail.js +11 -0
- package/dist/commands/sessions.d.ts +143 -1
- package/dist/commands/sessions.js +628 -80
- package/dist/commands/setup.js +4 -2
- package/dist/commands/sync.d.ts +3 -1
- package/dist/commands/sync.js +156 -4
- package/dist/commands/teams.js +217 -0
- package/dist/commands/versions.js +2 -4
- package/dist/commands/view.d.ts +3 -0
- package/dist/commands/view.js +19 -8
- package/dist/commands/wallet.d.ts +6 -0
- package/dist/commands/wallet.js +22 -5
- package/dist/commands/watchdog.d.ts +18 -0
- package/dist/commands/watchdog.js +238 -0
- package/dist/index.js +82 -32
- package/dist/lib/agents.js +30 -9
- package/dist/lib/audit/log.d.ts +92 -0
- package/dist/lib/audit/log.js +177 -0
- package/dist/lib/auto-pull.js +2 -1
- package/dist/lib/browser/cdp.js +5 -1
- package/dist/lib/browser/chrome.d.ts +10 -0
- package/dist/lib/browser/chrome.js +18 -7
- package/dist/lib/browser/drivers/ssh.d.ts +8 -0
- package/dist/lib/browser/drivers/ssh.js +77 -16
- package/dist/lib/browser/har.d.ts +84 -0
- package/dist/lib/browser/har.js +77 -0
- package/dist/lib/browser/ipc.d.ts +3 -0
- package/dist/lib/browser/ipc.js +37 -12
- package/dist/lib/browser/profiles.d.ts +1 -1
- package/dist/lib/browser/profiles.js +8 -10
- package/dist/lib/browser/refs.d.ts +65 -0
- package/dist/lib/browser/refs.js +73 -1
- package/dist/lib/browser/runtime-state.js +1 -0
- package/dist/lib/browser/service.d.ts +38 -2
- package/dist/lib/browser/service.js +183 -23
- package/dist/lib/browser/types.d.ts +14 -1
- package/dist/lib/budget/live-cloud.d.ts +42 -0
- package/dist/lib/budget/live-cloud.js +79 -0
- package/dist/lib/budget/live-team.d.ts +31 -0
- package/dist/lib/budget/live-team.js +115 -0
- package/dist/lib/cloud/codex.js +4 -0
- package/dist/lib/cloud/rush.d.ts +12 -1
- package/dist/lib/cloud/rush.js +13 -3
- package/dist/lib/cloud/types.d.ts +9 -0
- package/dist/lib/computer/dispatch.d.ts +8 -0
- package/dist/lib/computer/dispatch.js +125 -0
- package/dist/lib/computer/loop.d.ts +62 -0
- package/dist/lib/computer/loop.js +98 -0
- package/dist/lib/computer/model.d.ts +44 -0
- package/dist/lib/computer/model.js +157 -0
- package/dist/lib/concurrency.d.ts +19 -0
- package/dist/lib/concurrency.js +33 -0
- package/dist/lib/daemon.d.ts +92 -0
- package/dist/lib/daemon.js +222 -18
- package/dist/lib/devices/connect.d.ts +3 -2
- package/dist/lib/devices/connect.js +5 -3
- package/dist/lib/devices/registry.d.ts +7 -0
- package/dist/lib/devices/registry.js +24 -0
- package/dist/lib/devices/tailscale.js +1 -1
- package/dist/lib/drift.d.ts +52 -0
- package/dist/lib/drift.js +112 -0
- package/dist/lib/events.d.ts +10 -3
- package/dist/lib/events.js +101 -24
- package/dist/lib/exec.d.ts +17 -0
- package/dist/lib/exec.js +100 -12
- package/dist/lib/git.d.ts +27 -0
- package/dist/lib/git.js +56 -1
- package/dist/lib/hooks/cache.d.ts +6 -0
- package/dist/lib/hooks/cache.js +54 -12
- package/dist/lib/hooks.d.ts +27 -0
- package/dist/lib/hooks.js +135 -10
- package/dist/lib/hosts/dispatch.d.ts +15 -0
- package/dist/lib/hosts/dispatch.js +45 -13
- package/dist/lib/hosts/logs.d.ts +16 -0
- package/dist/lib/hosts/logs.js +74 -0
- package/dist/lib/hosts/option.js +1 -1
- package/dist/lib/hosts/passthrough.js +3 -1
- package/dist/lib/hosts/progress.d.ts +66 -0
- package/dist/lib/hosts/progress.js +125 -17
- package/dist/lib/hosts/ready.d.ts +51 -7
- package/dist/lib/hosts/ready.js +98 -25
- package/dist/lib/hosts/reconcile.d.ts +53 -0
- package/dist/lib/hosts/reconcile.js +81 -0
- package/dist/lib/hosts/registry.d.ts +19 -2
- package/dist/lib/hosts/registry.js +58 -2
- package/dist/lib/hosts/remote-cmd.d.ts +62 -1
- package/dist/lib/hosts/remote-cmd.js +70 -1
- package/dist/lib/hosts/remote-os.d.ts +17 -0
- package/dist/lib/hosts/remote-os.js +30 -0
- package/dist/lib/hosts/session-index.d.ts +34 -0
- package/dist/lib/hosts/session-index.js +56 -0
- package/dist/lib/hosts/tasks.d.ts +22 -0
- package/dist/lib/hosts/tasks.js +29 -0
- package/dist/lib/lock.d.ts +93 -0
- package/dist/lib/lock.js +207 -0
- package/dist/lib/loop.js +16 -1
- package/dist/lib/machine-id.d.ts +21 -0
- package/dist/lib/machine-id.js +26 -0
- package/dist/lib/mailbox-target.d.ts +36 -0
- package/dist/lib/mailbox-target.js +45 -0
- package/dist/lib/mailbox.d.ts +47 -0
- package/dist/lib/mailbox.js +194 -0
- package/dist/lib/mcp.d.ts +5 -0
- package/dist/lib/mcp.js +24 -8
- package/dist/lib/migrate.d.ts +19 -0
- package/dist/lib/migrate.js +134 -26
- package/dist/lib/overdue.js +3 -0
- package/dist/lib/permissions.d.ts +4 -0
- package/dist/lib/permissions.js +35 -0
- package/dist/lib/picker.d.ts +26 -0
- package/dist/lib/picker.js +127 -0
- package/dist/lib/platform/exec.d.ts +46 -0
- package/dist/lib/platform/exec.js +74 -0
- package/dist/lib/platform/process.d.ts +31 -0
- package/dist/lib/platform/process.js +34 -1
- package/dist/lib/platform/winpath.js +2 -0
- package/dist/lib/plugin-marketplace.d.ts +30 -0
- package/dist/lib/plugin-marketplace.js +215 -2
- package/dist/lib/plugins.d.ts +5 -0
- package/dist/lib/plugins.js +61 -10
- package/dist/lib/profiles.d.ts +25 -0
- package/dist/lib/profiles.js +22 -6
- package/dist/lib/pty-client.js +2 -1
- package/dist/lib/rotate.d.ts +61 -0
- package/dist/lib/rotate.js +52 -0
- package/dist/lib/routines.d.ts +40 -2
- package/dist/lib/routines.js +66 -8
- package/dist/lib/runner.d.ts +11 -2
- package/dist/lib/runner.js +49 -7
- package/dist/lib/scheduler.js +6 -1
- package/dist/lib/secrets/bundles.d.ts +60 -4
- package/dist/lib/secrets/bundles.js +131 -12
- package/dist/lib/secrets/filestore.d.ts +3 -0
- package/dist/lib/secrets/filestore.js +42 -16
- package/dist/lib/secrets/index.d.ts +43 -2
- package/dist/lib/secrets/index.js +102 -3
- package/dist/lib/secrets/mcp.d.ts +93 -0
- package/dist/lib/secrets/mcp.js +205 -0
- package/dist/lib/secrets/remote.js +12 -5
- package/dist/lib/secrets/sync.js +83 -4
- package/dist/lib/secrets/windows.js +14 -3
- package/dist/lib/serve/data.d.ts +81 -0
- package/dist/lib/serve/data.js +91 -0
- package/dist/lib/serve/page.d.ts +7 -0
- package/dist/lib/serve/page.js +140 -0
- package/dist/lib/serve/server.d.ts +46 -0
- package/dist/lib/serve/server.js +115 -0
- package/dist/lib/session/active.d.ts +61 -0
- package/dist/lib/session/active.js +196 -17
- package/dist/lib/session/discover.d.ts +37 -0
- package/dist/lib/session/discover.js +204 -27
- package/dist/lib/session/inject.d.ts +18 -0
- package/dist/lib/session/inject.js +21 -0
- package/dist/lib/session/parse.d.ts +6 -0
- package/dist/lib/session/parse.js +286 -2
- package/dist/lib/session/pid-registry.d.ts +22 -0
- package/dist/lib/session/pid-registry.js +104 -0
- package/dist/lib/session/provenance.d.ts +14 -2
- package/dist/lib/session/provenance.js +39 -8
- package/dist/lib/session/remote-active.d.ts +26 -0
- package/dist/lib/session/remote-active.js +153 -0
- package/dist/lib/session/remote-list.d.ts +51 -0
- package/dist/lib/session/remote-list.js +213 -0
- package/dist/lib/session/remote.d.ts +7 -1
- package/dist/lib/session/remote.js +18 -8
- package/dist/lib/session/render.js +1 -1
- package/dist/lib/session/sync/config.d.ts +1 -7
- package/dist/lib/session/sync/config.js +4 -11
- package/dist/lib/session/types.d.ts +18 -1
- package/dist/lib/session/types.js +1 -1
- package/dist/lib/shims.d.ts +44 -6
- package/dist/lib/shims.js +143 -57
- package/dist/lib/ssh-exec.d.ts +26 -3
- package/dist/lib/ssh-exec.js +47 -3
- package/dist/lib/ssh-tunnel.d.ts +24 -5
- package/dist/lib/ssh-tunnel.js +62 -63
- package/dist/lib/staleness/writers/hooks.js +1 -1
- package/dist/lib/startup/command-registry.d.ts +8 -0
- package/dist/lib/startup/command-registry.js +17 -1
- package/dist/lib/state.d.ts +13 -0
- package/dist/lib/state.js +103 -9
- package/dist/lib/sync-umbrella.d.ts +14 -7
- package/dist/lib/sync-umbrella.js +17 -9
- package/dist/lib/teams/forEach.d.ts +110 -0
- package/dist/lib/teams/forEach.js +186 -0
- package/dist/lib/teams/index.d.ts +1 -0
- package/dist/lib/teams/index.js +1 -0
- package/dist/lib/teams/pr-watch.d.ts +226 -0
- package/dist/lib/teams/pr-watch.js +371 -0
- package/dist/lib/teams/registry.js +25 -9
- package/dist/lib/teams/supervisor.d.ts +14 -1
- package/dist/lib/teams/supervisor.js +19 -0
- package/dist/lib/teams/worktree.d.ts +9 -0
- package/dist/lib/teams/worktree.js +32 -0
- package/dist/lib/terminal/backends/ghostty.d.ts +6 -0
- package/dist/lib/terminal/backends/ghostty.js +69 -0
- package/dist/lib/terminal/backends/index.d.ts +19 -0
- package/dist/lib/terminal/backends/index.js +31 -0
- package/dist/lib/terminal/backends/iterm.d.ts +6 -0
- package/dist/lib/terminal/backends/iterm.js +62 -0
- package/dist/lib/terminal/backends/tmux.d.ts +14 -0
- package/dist/lib/terminal/backends/tmux.js +23 -0
- package/dist/lib/terminal/backends/vscodium-agent.d.ts +31 -0
- package/dist/lib/terminal/backends/vscodium-agent.js +72 -0
- package/dist/lib/terminal/engine.d.ts +39 -0
- package/dist/lib/terminal/engine.js +54 -0
- package/dist/lib/terminal/index.d.ts +17 -0
- package/dist/lib/terminal/index.js +8 -0
- package/dist/lib/terminal/inject.d.ts +204 -0
- package/dist/lib/terminal/inject.js +247 -0
- package/dist/lib/terminal/policy.d.ts +11 -0
- package/dist/lib/terminal/policy.js +11 -0
- package/dist/lib/terminal/quote.d.ts +11 -0
- package/dist/lib/terminal/quote.js +13 -0
- package/dist/lib/terminal/resolve.d.ts +64 -0
- package/dist/lib/terminal/resolve.js +90 -0
- package/dist/lib/terminal/shell.d.ts +6 -0
- package/dist/lib/terminal/shell.js +23 -0
- package/dist/lib/terminal/transport.d.ts +15 -0
- package/dist/lib/terminal/transport.js +40 -0
- package/dist/lib/terminal/types.d.ts +59 -0
- package/dist/lib/terminal/types.js +13 -0
- package/dist/lib/triggers/webhook.d.ts +85 -0
- package/dist/lib/triggers/webhook.js +141 -0
- package/dist/lib/types.d.ts +7 -0
- package/dist/lib/versions.d.ts +30 -3
- package/dist/lib/versions.js +129 -18
- package/dist/lib/watchdog/index.d.ts +3 -0
- package/dist/lib/watchdog/index.js +5 -0
- package/dist/lib/watchdog/read.d.ts +35 -0
- package/dist/lib/watchdog/read.js +149 -0
- package/dist/lib/watchdog/runner.d.ts +127 -0
- package/dist/lib/watchdog/runner.js +322 -0
- package/dist/lib/watchdog/watchdog.d.ts +40 -0
- package/dist/lib/watchdog/watchdog.js +166 -0
- package/dist/lib/watchdog/watchdogTail.d.ts +5 -0
- package/dist/lib/watchdog/watchdogTail.js +154 -0
- package/dist/lib/whats-new.d.ts +9 -0
- package/dist/lib/whats-new.js +35 -0
- package/dist/lib/workflows.d.ts +166 -0
- package/dist/lib/workflows.js +193 -0
- package/package.json +5 -4
package/dist/lib/git.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import simpleGit from 'simple-git';
|
|
9
9
|
import * as fs from 'fs';
|
|
10
|
+
import * as os from 'os';
|
|
10
11
|
import * as path from 'path';
|
|
11
12
|
import { IS_WINDOWS, isWindowsAbsolutePath } from './platform/index.js';
|
|
12
13
|
import { getPackageLocalPath } from './state.js';
|
|
@@ -494,6 +495,15 @@ export async function hasLocalChanges(dir) {
|
|
|
494
495
|
return false;
|
|
495
496
|
}
|
|
496
497
|
}
|
|
498
|
+
/**
|
|
499
|
+
* Render an absolute path in ~-relative form with forward slashes, matching the
|
|
500
|
+
* way the rest of the CLI prints home-anchored paths (e.g. `~/.agents/.system`).
|
|
501
|
+
*/
|
|
502
|
+
export function displayHomePath(dir) {
|
|
503
|
+
const home = os.homedir();
|
|
504
|
+
const rel = dir.startsWith(home) ? '~' + dir.slice(home.length) : dir;
|
|
505
|
+
return rel.replace(/\\/g, '/');
|
|
506
|
+
}
|
|
497
507
|
/**
|
|
498
508
|
* Pull changes in an existing repo.
|
|
499
509
|
* Refuses to pull if the working tree is dirty -- user must commit or discard changes first.
|
|
@@ -506,7 +516,7 @@ export async function pullRepo(dir) {
|
|
|
506
516
|
return {
|
|
507
517
|
success: false,
|
|
508
518
|
commit: '',
|
|
509
|
-
error:
|
|
519
|
+
error: `Working tree has uncommitted changes. Commit or discard them before pulling.\n\n cd ${displayHomePath(dir)} && git status`,
|
|
510
520
|
};
|
|
511
521
|
}
|
|
512
522
|
await git.fetch();
|
|
@@ -522,6 +532,51 @@ export async function pullRepo(dir) {
|
|
|
522
532
|
return { success: false, commit: '', error: err.message };
|
|
523
533
|
}
|
|
524
534
|
}
|
|
535
|
+
/**
|
|
536
|
+
* Rebase a repo onto its remote, optionally pushing local commits back up.
|
|
537
|
+
*
|
|
538
|
+
* The one-repo counterpart to `pullRepo` used by `agents sync <repo>`:
|
|
539
|
+
* 1. Refuse if the working tree is dirty (commit or discard first).
|
|
540
|
+
* 2. `git fetch origin` then `git pull --rebase origin <branch>` — rebase, not
|
|
541
|
+
* merge, so a local commit lands cleanly on top of upstream with no merge
|
|
542
|
+
* bubble.
|
|
543
|
+
* 3. When `push` is set, `git push origin <branch>` to send local commits up.
|
|
544
|
+
*
|
|
545
|
+
* The branch is read from the repo's current HEAD (falls back to `main`) rather
|
|
546
|
+
* than hardcoded. System repos pass `push: false` — they are pull-only mirrors
|
|
547
|
+
* of the npm-shipped upstream.
|
|
548
|
+
*/
|
|
549
|
+
export async function syncRepoGit(dir, opts) {
|
|
550
|
+
try {
|
|
551
|
+
if (!isGitRepo(dir)) {
|
|
552
|
+
return { success: false, commit: '', pushed: false, error: `Not a git repo: ${dir}` };
|
|
553
|
+
}
|
|
554
|
+
const git = simpleGit(dir);
|
|
555
|
+
const status = await git.status();
|
|
556
|
+
if (!status.isClean()) {
|
|
557
|
+
return {
|
|
558
|
+
success: false,
|
|
559
|
+
commit: '',
|
|
560
|
+
pushed: false,
|
|
561
|
+
error: `Working tree has uncommitted changes. Commit or discard them first.\n\n cd ${dir} && git status`,
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
const branch = status.current || 'main';
|
|
565
|
+
await git.fetch('origin');
|
|
566
|
+
await git.pull('origin', branch, { '--rebase': 'true' });
|
|
567
|
+
installGithooksSymlinks(dir);
|
|
568
|
+
let pushed = false;
|
|
569
|
+
if (opts.push) {
|
|
570
|
+
await git.push('origin', branch);
|
|
571
|
+
pushed = true;
|
|
572
|
+
}
|
|
573
|
+
const log = await git.log({ maxCount: 1 });
|
|
574
|
+
return { success: true, commit: log.latest?.hash.slice(0, 8) || 'unknown', pushed };
|
|
575
|
+
}
|
|
576
|
+
catch (err) {
|
|
577
|
+
return { success: false, commit: '', pushed: false, error: err.message };
|
|
578
|
+
}
|
|
579
|
+
}
|
|
525
580
|
/** Compute the sync status of a git repo, optionally scoped to a subdirectory. */
|
|
526
581
|
export async function getGitSyncStatus(dir, subdir) {
|
|
527
582
|
if (!isGitRepo(dir)) {
|
|
@@ -7,6 +7,12 @@ import type { HookCache, HookCacheConfig } from '../types.js';
|
|
|
7
7
|
export declare function parseCacheConfig(raw: HookCache | undefined): HookCacheConfig | null;
|
|
8
8
|
/** Parse "30s" | "5m" | "1h" | plain seconds. Returns seconds, or null on failure. */
|
|
9
9
|
export declare function parseDuration(d: number | string | undefined): number | null;
|
|
10
|
+
/**
|
|
11
|
+
* Reject hook names that could escape the shims directory when interpolated
|
|
12
|
+
* into a filename. Mirrors the containment gate on hook script resolution in
|
|
13
|
+
* hooks.ts (`resolveContainedHookPath`).
|
|
14
|
+
*/
|
|
15
|
+
export declare function isValidHookShimName(name: string): boolean;
|
|
10
16
|
/** Absolute path of the generated shim for a hook name. */
|
|
11
17
|
export declare function getHookShimPath(name: string): string;
|
|
12
18
|
/**
|
package/dist/lib/hooks/cache.js
CHANGED
|
@@ -70,9 +70,35 @@ export function parseDuration(d) {
|
|
|
70
70
|
return value * 60;
|
|
71
71
|
return value;
|
|
72
72
|
}
|
|
73
|
+
/**
|
|
74
|
+
* Reject hook names that could escape the shims directory when interpolated
|
|
75
|
+
* into a filename. Mirrors the containment gate on hook script resolution in
|
|
76
|
+
* hooks.ts (`resolveContainedHookPath`).
|
|
77
|
+
*/
|
|
78
|
+
export function isValidHookShimName(name) {
|
|
79
|
+
return (!!name &&
|
|
80
|
+
name !== '.' &&
|
|
81
|
+
name !== '..' &&
|
|
82
|
+
!name.startsWith('-') &&
|
|
83
|
+
!/[\/\\\x00]/.test(name) &&
|
|
84
|
+
name.length <= 255);
|
|
85
|
+
}
|
|
86
|
+
/** Resolve shimsDir + `${name}.sh` and assert the result stays inside shimsDir. */
|
|
87
|
+
function resolveContainedHookShimPath(shimsDir, name) {
|
|
88
|
+
if (!isValidHookShimName(name)) {
|
|
89
|
+
throw new Error(`Invalid hook shim name: ${name}`);
|
|
90
|
+
}
|
|
91
|
+
const resolvedRoot = path.resolve(shimsDir);
|
|
92
|
+
const candidate = path.join(shimsDir, `${name}.sh`);
|
|
93
|
+
const resolved = path.resolve(candidate);
|
|
94
|
+
if (!resolved.startsWith(resolvedRoot + path.sep)) {
|
|
95
|
+
throw new Error(`Invalid hook shim name: ${name}`);
|
|
96
|
+
}
|
|
97
|
+
return resolved;
|
|
98
|
+
}
|
|
73
99
|
/** Absolute path of the generated shim for a hook name. */
|
|
74
100
|
export function getHookShimPath(name) {
|
|
75
|
-
return
|
|
101
|
+
return resolveContainedHookShimPath(getHookShimsDir(), name);
|
|
76
102
|
}
|
|
77
103
|
/**
|
|
78
104
|
* Generate (or refresh) the shim script for a hook. Idempotent — only writes
|
|
@@ -82,7 +108,7 @@ export function generateHookShim(args) {
|
|
|
82
108
|
const shimsDir = args.paths?.shimsDir ?? getHookShimsDir();
|
|
83
109
|
const cacheDir = args.paths?.cacheDir ?? getHookCacheDir();
|
|
84
110
|
const logsDir = args.paths?.logsDir ?? getLogsDir();
|
|
85
|
-
const shimPath =
|
|
111
|
+
const shimPath = resolveContainedHookShimPath(shimsDir, args.name);
|
|
86
112
|
const content = renderShim(args.name, args.scriptPath, args.cache, { cacheDir, logsDir });
|
|
87
113
|
fs.mkdirSync(shimsDir, { recursive: true });
|
|
88
114
|
let existing = null;
|
|
@@ -105,9 +131,10 @@ export function generateHookShim(args) {
|
|
|
105
131
|
return shimPath;
|
|
106
132
|
}
|
|
107
133
|
/**
|
|
108
|
-
* Render the bash shim. Bash 3.2-compatible (macOS default). Uses
|
|
109
|
-
* monotonic-ish nanosecond timing
|
|
110
|
-
*
|
|
134
|
+
* Render the bash shim. Bash 3.2-compatible (macOS default). Uses Python for
|
|
135
|
+
* hashing + monotonic-ish nanosecond timing + portable mtime, resolved at
|
|
136
|
+
* runtime (python3, then python) so a Windows Microsoft Store `python3` alias
|
|
137
|
+
* stub — which exits non-zero without running — doesn't silently break caching.
|
|
111
138
|
*/
|
|
112
139
|
function renderShim(name, scriptPath, cache, paths) {
|
|
113
140
|
const ttl = typeof cache.ttl === 'number' ? cache.ttl : (parseDuration(cache.ttl) ?? 0);
|
|
@@ -133,12 +160,25 @@ KEY_MODE=${q(key)}
|
|
|
133
160
|
|
|
134
161
|
mkdir -p "$CACHE_DIR" "$LOGS_DIR"
|
|
135
162
|
|
|
163
|
+
# Resolve a real Python. On Windows, bare python3 is often a Microsoft Store
|
|
164
|
+
# app-execution alias stub that prints to stderr and exits non-zero (0 bytes on
|
|
165
|
+
# stdout) -- command -v finds it but it cannot run, which silently empties the
|
|
166
|
+
# hash + mtime primitives below and makes EVERY call a cache miss (the hook
|
|
167
|
+
# re-runs every time). Probe by executing, not by lookup, and fall back to python.
|
|
168
|
+
PY=""
|
|
169
|
+
for _cand in python3 python; do
|
|
170
|
+
if command -v "$_cand" >/dev/null 2>&1 && "$_cand" -c 'import sys' >/dev/null 2>&1; then
|
|
171
|
+
PY="$_cand"; break
|
|
172
|
+
fi
|
|
173
|
+
done
|
|
174
|
+
[ -z "$PY" ] && PY=python3
|
|
175
|
+
|
|
136
176
|
# Read stdin once (Claude/Codex/Gemini pass JSON on stdin to every hook).
|
|
137
177
|
STDIN_PAYLOAD="$(cat || true)"
|
|
138
178
|
|
|
139
179
|
# Portable sha1 — \`shasum\` is Perl, missing on minimal Linux images;
|
|
140
180
|
# \`sha1sum\` is coreutils, missing on macOS. Truncate to 12 hex chars.
|
|
141
|
-
sha1_12() {
|
|
181
|
+
sha1_12() { "$PY" -c 'import hashlib,sys; print(hashlib.sha1(sys.stdin.read().encode()).hexdigest()[:12])'; }
|
|
142
182
|
|
|
143
183
|
# Derive cache key suffix from KEY_MODE. All untrusted inputs (cwd, session_id,
|
|
144
184
|
# project path) are hashed before going into the filename so a malicious stdin
|
|
@@ -146,14 +186,14 @@ sha1_12() { python3 -c 'import hashlib,sys; print(hashlib.sha1(sys.stdin.read().
|
|
|
146
186
|
cache_suffix=""
|
|
147
187
|
case "$KEY_MODE" in
|
|
148
188
|
per-cwd)
|
|
149
|
-
cwd_val="$(printf '%s' "$STDIN_PAYLOAD" |
|
|
189
|
+
cwd_val="$(printf '%s' "$STDIN_PAYLOAD" | "$PY" -c 'import json,sys
|
|
150
190
|
try: print(json.load(sys.stdin).get("cwd","") or "")
|
|
151
191
|
except Exception: pass' 2>/dev/null || true)"
|
|
152
192
|
[ -z "$cwd_val" ] && cwd_val="$PWD"
|
|
153
193
|
cache_suffix=".$(printf '%s' "$cwd_val" | sha1_12)"
|
|
154
194
|
;;
|
|
155
195
|
per-session)
|
|
156
|
-
sid_val="$(printf '%s' "$STDIN_PAYLOAD" |
|
|
196
|
+
sid_val="$(printf '%s' "$STDIN_PAYLOAD" | "$PY" -c 'import json,sys
|
|
157
197
|
try: print(json.load(sys.stdin).get("session_id","") or "")
|
|
158
198
|
except Exception: pass' 2>/dev/null || true)"
|
|
159
199
|
# Hash + fall back to a sentinel so missing-session doesn't silently
|
|
@@ -173,7 +213,7 @@ esac
|
|
|
173
213
|
CACHE_FILE="$CACHE_DIR/$HOOK_NAME$cache_suffix.out"
|
|
174
214
|
|
|
175
215
|
# Monotonic-ish nanosecond timer (macOS \`date\` has no %N).
|
|
176
|
-
now_ns() {
|
|
216
|
+
now_ns() { "$PY" -c 'import time; print(int(time.time()*1e9))'; }
|
|
177
217
|
START_NS=$(now_ns)
|
|
178
218
|
|
|
179
219
|
CACHE_STATUS=miss
|
|
@@ -181,10 +221,10 @@ CACHE_AGE=-1
|
|
|
181
221
|
EXIT=0
|
|
182
222
|
|
|
183
223
|
if [ -f "$CACHE_FILE" ]; then
|
|
184
|
-
#
|
|
224
|
+
# $PY is already resolved (used for now_ns) and gives portable mtime
|
|
185
225
|
# without the macOS-vs-Linux \`stat\` flag divergence (-f %m vs -c %Y) that
|
|
186
226
|
# blew up under \`set -u\` when the wrong flag produced literal "%m".
|
|
187
|
-
mtime=$(
|
|
227
|
+
mtime=$("$PY" -c 'import os,sys; print(int(os.path.getmtime(sys.argv[1])))' "$CACHE_FILE" 2>/dev/null)
|
|
188
228
|
mtime=\${mtime:-0}
|
|
189
229
|
now_s=$(date +%s)
|
|
190
230
|
CACHE_AGE=$((now_s - mtime))
|
|
@@ -231,8 +271,10 @@ exit "$EXIT"
|
|
|
231
271
|
* hook is renamed/deleted or has its `cache:` field removed.
|
|
232
272
|
*/
|
|
233
273
|
export function removeHookShim(name, shimsDir) {
|
|
274
|
+
if (!isValidHookShimName(name))
|
|
275
|
+
return;
|
|
234
276
|
const dir = shimsDir ?? getHookShimsDir();
|
|
235
|
-
const shimPath =
|
|
277
|
+
const shimPath = resolveContainedHookShimPath(dir, name);
|
|
236
278
|
if (fs.existsSync(shimPath)) {
|
|
237
279
|
try {
|
|
238
280
|
fs.unlinkSync(shimPath);
|
package/dist/lib/hooks.d.ts
CHANGED
|
@@ -8,6 +8,19 @@
|
|
|
8
8
|
* and syncing them across version switches.
|
|
9
9
|
*/
|
|
10
10
|
export declare function resolveHookScriptPath(script: string): string | null;
|
|
11
|
+
/**
|
|
12
|
+
* Convert an absolute path under HOME to a portable ~/... form with forward
|
|
13
|
+
* slashes. Hook commands stored this way work on both macOS and Windows:
|
|
14
|
+
* absolute Windows paths break in bash because backslashes are stripped as
|
|
15
|
+
* escape characters, whereas ~/... paths expand correctly via the ~/.claude
|
|
16
|
+
* symlink/junction on both platforms.
|
|
17
|
+
*
|
|
18
|
+
* `home` and `sep` are injectable so the Windows behavior (backslash sep,
|
|
19
|
+
* drive-letter home) is unit-testable on a POSIX CI host — pass sep='\\' to
|
|
20
|
+
* simulate Windows. With the defaults this is byte-identical to reading
|
|
21
|
+
* os.homedir()/path.sep at the call site.
|
|
22
|
+
*/
|
|
23
|
+
export declare function toPortableCommand(absPath: string, home?: string, sep?: string): string;
|
|
11
24
|
import type { AgentId, InstalledHook, ManifestHook } from './types.js';
|
|
12
25
|
export type HookEntry = {
|
|
13
26
|
name: string;
|
|
@@ -165,3 +178,17 @@ export declare function registerHooksToSettings(agentId: AgentId, versionHome: s
|
|
|
165
178
|
registered: string[];
|
|
166
179
|
errors: string[];
|
|
167
180
|
};
|
|
181
|
+
/**
|
|
182
|
+
* Prune every Claude-family (`settings.json`) hook entry whose command lives
|
|
183
|
+
* under a removed version's home
|
|
184
|
+
* (`~/.agents/.history/versions/<agent>/<removedVersion>/home/…`).
|
|
185
|
+
*
|
|
186
|
+
* `agents remove <agent>@<version>` soft-deletes the version's files but leaves
|
|
187
|
+
* the hook entries other version homes registered against it — dead hooks that
|
|
188
|
+
* error on every tool call ("No such file or directory") until the next sync.
|
|
189
|
+
* This clears them from a remaining version's settings immediately. Only the
|
|
190
|
+
* removed version's entries are touched; the current version's entries, system
|
|
191
|
+
* hooks, and the user's own custom hooks are left intact. Returns the number of
|
|
192
|
+
* entries removed.
|
|
193
|
+
*/
|
|
194
|
+
export declare function pruneVersionHomeHookEntriesFromSettings(settingsPath: string, agent: AgentId, removedVersion: string): number;
|
package/dist/lib/hooks.js
CHANGED
|
@@ -68,11 +68,15 @@ function getManagedHookPrefixes() {
|
|
|
68
68
|
* absolute Windows paths break in bash because backslashes are stripped as
|
|
69
69
|
* escape characters, whereas ~/... paths expand correctly via the ~/.claude
|
|
70
70
|
* symlink/junction on both platforms.
|
|
71
|
+
*
|
|
72
|
+
* `home` and `sep` are injectable so the Windows behavior (backslash sep,
|
|
73
|
+
* drive-letter home) is unit-testable on a POSIX CI host — pass sep='\\' to
|
|
74
|
+
* simulate Windows. With the defaults this is byte-identical to reading
|
|
75
|
+
* os.homedir()/path.sep at the call site.
|
|
71
76
|
*/
|
|
72
|
-
function toPortableCommand(absPath) {
|
|
73
|
-
const
|
|
74
|
-
const
|
|
75
|
-
const homeNorm = home.split(path.sep).join('/');
|
|
77
|
+
export function toPortableCommand(absPath, home = os.homedir(), sep = path.sep) {
|
|
78
|
+
const normalized = absPath.split(sep).join('/');
|
|
79
|
+
const homeNorm = home.split(sep).join('/');
|
|
76
80
|
if (normalized.startsWith(homeNorm + '/')) {
|
|
77
81
|
return '~/' + normalized.slice(homeNorm.length + 1);
|
|
78
82
|
}
|
|
@@ -113,8 +117,38 @@ function isManagedHookCommand(command, prefixes) {
|
|
|
113
117
|
}
|
|
114
118
|
return false;
|
|
115
119
|
}
|
|
120
|
+
/**
|
|
121
|
+
* Per-version-home command detection. Sync copies each hook script into the
|
|
122
|
+
* active version's home and registers the command by that version-scoped path
|
|
123
|
+
* (`~/.agents/.history/versions/<agent>/<version>/home/…`). Because the path
|
|
124
|
+
* embeds the version number, a later version's sync appends a fresh set whose
|
|
125
|
+
* paths never string-match (and thus never prune) the prior version's entries —
|
|
126
|
+
* so entries for every version installed over time pile up in one settings
|
|
127
|
+
* file, and once a version is removed its entries become dead hooks that error
|
|
128
|
+
* on every tool call. `versionHomeIdentity` extracts the `<agent>/<version>` a
|
|
129
|
+
* command (or home path) belongs to so stale sibling-version entries can be
|
|
130
|
+
* pruned. Returns null for any path outside a per-version home (system hooks,
|
|
131
|
+
* the user's own custom hooks) — those are never a prune target.
|
|
132
|
+
*/
|
|
133
|
+
const VERSION_HOME_SEGMENT_RE = /\.history\/versions\/([^/]+)\/([^/]+)\/home(?:\/|$)/;
|
|
134
|
+
function versionHomeIdentity(commandOrPath) {
|
|
135
|
+
const norm = commandOrPath.split(/[\\/]/).join('/');
|
|
136
|
+
const m = VERSION_HOME_SEGMENT_RE.exec(norm);
|
|
137
|
+
return m ? { agent: m[1], version: m[2] } : null;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* True when `command` points into a DIFFERENT version home of the same agent as
|
|
141
|
+
* `current` — i.e. a stale entry left behind by an earlier version's sync.
|
|
142
|
+
* Non-version-home commands (system + user-custom hooks) always return false.
|
|
143
|
+
*/
|
|
144
|
+
function isStaleSiblingVersionCommand(command, current) {
|
|
145
|
+
if (!current)
|
|
146
|
+
return false;
|
|
147
|
+
const id = versionHomeIdentity(command);
|
|
148
|
+
return id !== null && id.agent === current.agent && id.version !== current.version;
|
|
149
|
+
}
|
|
116
150
|
import { getEffectiveHome, getVersionHomePath, listInstalledVersions } from './versions.js';
|
|
117
|
-
import { generateHookShim, parseCacheConfig, removeHookShim } from './hooks/cache.js';
|
|
151
|
+
import { generateHookShim, isValidHookShimName, parseCacheConfig, removeHookShim } from './hooks/cache.js';
|
|
118
152
|
import { getHookShimsDir } from './state.js';
|
|
119
153
|
/**
|
|
120
154
|
* Resolve the command path to register for a hook.
|
|
@@ -128,6 +162,8 @@ function resolveHookCommand(name, hookDef, resolveScript) {
|
|
|
128
162
|
const scriptPath = resolveScript(hookDef.script);
|
|
129
163
|
if (!scriptPath)
|
|
130
164
|
return null;
|
|
165
|
+
if (!isValidHookShimName(name))
|
|
166
|
+
return null;
|
|
131
167
|
const cache = parseCacheConfig(hookDef.cache);
|
|
132
168
|
if (!cache) {
|
|
133
169
|
// No caching opted in — make sure a previously generated shim from an
|
|
@@ -715,6 +751,24 @@ export function parseHookManifest(opts = {}) {
|
|
|
715
751
|
}
|
|
716
752
|
catch { /* skip unreadable manifest */ }
|
|
717
753
|
}
|
|
754
|
+
// Extra-repo layer: hooks: section of each enabled extra repo's agents.yaml.
|
|
755
|
+
// Sits above system but below user, mirroring resolveHookScriptPath's
|
|
756
|
+
// first-found order (user > extra > system). Without this layer the script
|
|
757
|
+
// path of an extra-repo hook resolves but its events never register (#602).
|
|
758
|
+
// Earlier extras win over later ones, so iterate in reverse: the last write
|
|
759
|
+
// for a given name comes from the earliest-registered repo.
|
|
760
|
+
for (const { dir } of [...getEnabledExtraRepos()].reverse()) {
|
|
761
|
+
const extraMetaPath = path.join(dir, 'agents.yaml');
|
|
762
|
+
if (!fs.existsSync(extraMetaPath))
|
|
763
|
+
continue;
|
|
764
|
+
try {
|
|
765
|
+
const meta = yaml.parse(fs.readFileSync(extraMetaPath, 'utf-8'));
|
|
766
|
+
if (meta?.hooks)
|
|
767
|
+
for (const [name, def] of Object.entries(meta.hooks))
|
|
768
|
+
merged[name] = def;
|
|
769
|
+
}
|
|
770
|
+
catch { /* skip unreadable extra-repo manifest */ }
|
|
771
|
+
}
|
|
718
772
|
// User layer: hooks: section of agents.yaml.
|
|
719
773
|
const userMetaPath = path.join(getUserAgentsDir(), 'agents.yaml');
|
|
720
774
|
if (fs.existsSync(userMetaPath)) {
|
|
@@ -910,6 +964,12 @@ export function registerHooksToSettings(agentId, versionHome, hookManifest, agen
|
|
|
910
964
|
if (agentId === 'claude') {
|
|
911
965
|
return registerHooksForClaude(versionHome, manifest, resolveScript, managedPrefixes);
|
|
912
966
|
}
|
|
967
|
+
if (agentId === 'droid') {
|
|
968
|
+
// Droid's settings.json hooks schema is identical to Claude's (top-level
|
|
969
|
+
// `hooks` object → event → matcher-group array), so reuse the Claude
|
|
970
|
+
// registrar targeting `.factory/settings.json` (agentConfigDirName('droid')).
|
|
971
|
+
return registerHooksForClaude(versionHome, manifest, resolveScript, managedPrefixes, agentConfigDirName('droid'));
|
|
972
|
+
}
|
|
913
973
|
if (agentId === 'codex') {
|
|
914
974
|
return registerHooksForCodex(versionHome, manifest, resolveScript, managedPrefixes);
|
|
915
975
|
}
|
|
@@ -953,10 +1013,10 @@ const ANTIGRAVITY_EVENT_MAP = {
|
|
|
953
1013
|
const GEMINI_EVENT_MAP = {
|
|
954
1014
|
UserPromptSubmit: 'BeforeAgent',
|
|
955
1015
|
};
|
|
956
|
-
function registerHooksForClaude(versionHome, manifest, resolveScript, managedPrefixes) {
|
|
1016
|
+
function registerHooksForClaude(versionHome, manifest, resolveScript, managedPrefixes, configDirName = '.claude') {
|
|
957
1017
|
const registered = [];
|
|
958
1018
|
const errors = [];
|
|
959
|
-
const configDir = path.join(versionHome,
|
|
1019
|
+
const configDir = path.join(versionHome, configDirName);
|
|
960
1020
|
const settingsPath = path.join(configDir, 'settings.json');
|
|
961
1021
|
let config = {};
|
|
962
1022
|
if (fs.existsSync(settingsPath)) {
|
|
@@ -983,15 +1043,21 @@ function registerHooksForClaude(versionHome, manifest, resolveScript, managedPre
|
|
|
983
1043
|
if (resolved)
|
|
984
1044
|
currentManifestPaths.add(resolved);
|
|
985
1045
|
}
|
|
986
|
-
//
|
|
987
|
-
//
|
|
1046
|
+
// Identity of the version home being synced. Used to prune entries that point
|
|
1047
|
+
// at a DIFFERENT version home of the same agent (see isStaleSiblingVersionCommand).
|
|
1048
|
+
const currentVh = versionHomeIdentity(versionHome);
|
|
1049
|
+
// Remove stale entries: any hook command under a managed root that isn't in
|
|
1050
|
+
// the current manifest is a leftover from a renamed/deleted hook script; any
|
|
1051
|
+
// command pointing at a sibling version's home is a leftover from that
|
|
1052
|
+
// version's sync (its version-scoped path never matches the current set).
|
|
988
1053
|
for (const eventEntries of Object.values(hooks)) {
|
|
989
1054
|
if (!Array.isArray(eventEntries))
|
|
990
1055
|
continue;
|
|
991
1056
|
for (const group of eventEntries) {
|
|
992
1057
|
if (!group.hooks)
|
|
993
1058
|
continue;
|
|
994
|
-
group.hooks = group.hooks.filter((h) => !isManagedHookCommand(h.command, managedPrefixes) || currentManifestPaths.has(h.command))
|
|
1059
|
+
group.hooks = group.hooks.filter((h) => (!isManagedHookCommand(h.command, managedPrefixes) || currentManifestPaths.has(h.command)) &&
|
|
1060
|
+
!isStaleSiblingVersionCommand(h.command, currentVh));
|
|
995
1061
|
}
|
|
996
1062
|
}
|
|
997
1063
|
// Remove empty matcher groups left after cleanup
|
|
@@ -1043,6 +1109,65 @@ function registerHooksForClaude(versionHome, manifest, resolveScript, managedPre
|
|
|
1043
1109
|
}
|
|
1044
1110
|
return { registered, errors };
|
|
1045
1111
|
}
|
|
1112
|
+
/**
|
|
1113
|
+
* Prune every Claude-family (`settings.json`) hook entry whose command lives
|
|
1114
|
+
* under a removed version's home
|
|
1115
|
+
* (`~/.agents/.history/versions/<agent>/<removedVersion>/home/…`).
|
|
1116
|
+
*
|
|
1117
|
+
* `agents remove <agent>@<version>` soft-deletes the version's files but leaves
|
|
1118
|
+
* the hook entries other version homes registered against it — dead hooks that
|
|
1119
|
+
* error on every tool call ("No such file or directory") until the next sync.
|
|
1120
|
+
* This clears them from a remaining version's settings immediately. Only the
|
|
1121
|
+
* removed version's entries are touched; the current version's entries, system
|
|
1122
|
+
* hooks, and the user's own custom hooks are left intact. Returns the number of
|
|
1123
|
+
* entries removed.
|
|
1124
|
+
*/
|
|
1125
|
+
export function pruneVersionHomeHookEntriesFromSettings(settingsPath, agent, removedVersion) {
|
|
1126
|
+
if (!fs.existsSync(settingsPath))
|
|
1127
|
+
return 0;
|
|
1128
|
+
let config;
|
|
1129
|
+
try {
|
|
1130
|
+
config = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
|
|
1131
|
+
}
|
|
1132
|
+
catch {
|
|
1133
|
+
return 0;
|
|
1134
|
+
}
|
|
1135
|
+
if (!config.hooks || typeof config.hooks !== 'object')
|
|
1136
|
+
return 0;
|
|
1137
|
+
const hooks = config.hooks;
|
|
1138
|
+
let removed = 0;
|
|
1139
|
+
for (const eventEntries of Object.values(hooks)) {
|
|
1140
|
+
if (!Array.isArray(eventEntries))
|
|
1141
|
+
continue;
|
|
1142
|
+
for (const group of eventEntries) {
|
|
1143
|
+
if (!group.hooks)
|
|
1144
|
+
continue;
|
|
1145
|
+
const before = group.hooks.length;
|
|
1146
|
+
group.hooks = group.hooks.filter((h) => {
|
|
1147
|
+
const id = versionHomeIdentity(h.command);
|
|
1148
|
+
return !(id !== null && id.agent === agent && id.version === removedVersion);
|
|
1149
|
+
});
|
|
1150
|
+
removed += before - group.hooks.length;
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
if (removed === 0)
|
|
1154
|
+
return 0;
|
|
1155
|
+
// Drop matcher groups left empty by the prune.
|
|
1156
|
+
for (const [event, eventEntries] of Object.entries(hooks)) {
|
|
1157
|
+
if (!Array.isArray(eventEntries))
|
|
1158
|
+
continue;
|
|
1159
|
+
hooks[event] = eventEntries.filter((g) => g.hooks && g.hooks.length > 0);
|
|
1160
|
+
}
|
|
1161
|
+
try {
|
|
1162
|
+
fs.writeFileSync(settingsPath, JSON.stringify(config, null, 2), 'utf-8');
|
|
1163
|
+
}
|
|
1164
|
+
catch {
|
|
1165
|
+
// Best-effort cleanup: a write failure leaves the dead entry to be pruned
|
|
1166
|
+
// on the next sync (see isStaleSiblingVersionCommand).
|
|
1167
|
+
return 0;
|
|
1168
|
+
}
|
|
1169
|
+
return removed;
|
|
1170
|
+
}
|
|
1046
1171
|
function registerHooksForCodex(versionHome, manifest, resolveScript, managedPrefixes) {
|
|
1047
1172
|
const registered = [];
|
|
1048
1173
|
const errors = [];
|
|
@@ -21,10 +21,25 @@ export interface DispatchOptions {
|
|
|
21
21
|
mode?: string;
|
|
22
22
|
model?: string;
|
|
23
23
|
remoteCwd?: string;
|
|
24
|
+
/**
|
|
25
|
+
* Force the remote run's NEW session to use this exact id (Claude only, via
|
|
26
|
+
* `agents run --session-id`). Captured on the task record so the run is
|
|
27
|
+
* resumable by id. Mutually exclusive with `resume`.
|
|
28
|
+
*/
|
|
29
|
+
sessionId?: string;
|
|
30
|
+
/** Resume an existing session on the host by id (via `agents run --resume`). */
|
|
31
|
+
resume?: string;
|
|
24
32
|
/** Stream progress and block until completion (default true). */
|
|
25
33
|
follow?: boolean;
|
|
26
34
|
timeoutMs?: number;
|
|
27
35
|
}
|
|
36
|
+
/**
|
|
37
|
+
* Build the remote `agents run …` argv for a host dispatch. Pure so the
|
|
38
|
+
* session-id / resume flag wiring is unit-testable without an SSH round-trip.
|
|
39
|
+
* `--session-id` and `--resume` are mutually exclusive (the CLI rejects both);
|
|
40
|
+
* resume wins when — defensively — both are set.
|
|
41
|
+
*/
|
|
42
|
+
export declare function buildRunForwardedArgs(opts: DispatchOptions): string[];
|
|
28
43
|
/** Dispatch an `agents run <agent> "<prompt>"` onto a host (the `run --host` path). */
|
|
29
44
|
export declare function dispatchToHost(host: Host, opts: DispatchOptions): Promise<DispatchResult>;
|
|
30
45
|
export interface CommandDispatchOptions {
|
|
@@ -12,7 +12,9 @@ import { randomUUID } from 'crypto';
|
|
|
12
12
|
import { sshExec, shellQuote } from '../ssh-exec.js';
|
|
13
13
|
import { sshTargetFor } from './types.js';
|
|
14
14
|
import { ensureHostReady } from './ready.js';
|
|
15
|
-
import {
|
|
15
|
+
import { remoteShellFor } from './remote-cmd.js';
|
|
16
|
+
import { resolveRemoteOsSync } from './remote-os.js';
|
|
17
|
+
import { saveTask, updateTask, terminalPatch } from './tasks.js';
|
|
16
18
|
import { followHostTask } from './progress.js';
|
|
17
19
|
// Use $HOME (not ~) so the path is correct whether or not it's quoted and
|
|
18
20
|
// regardless of the run's cwd. Task ids are 8 hex chars, so these paths are
|
|
@@ -22,8 +24,22 @@ const REMOTE_DIR = '$HOME/.agents/.cache/hosts';
|
|
|
22
24
|
* The launch + task-record + optional follow core. Both `dispatchToHost` (run)
|
|
23
25
|
* and `dispatchAgentsCommand` (teams) build their `forwardedArgs` and call here,
|
|
24
26
|
* so the nohup/exit-file/offset-tail machinery lives in exactly one place.
|
|
27
|
+
*
|
|
28
|
+
* Windows remotes are refused up front: the detached launch is only half the
|
|
29
|
+
* contract — the follow/reconcile layer (`progress.ts`/`reconcile.ts`) offset-
|
|
30
|
+
* tails the log with POSIX `tail -c`/`printf`/`cat`/`stat`, which do not exist
|
|
31
|
+
* in cmd.exe/PowerShell. Shipping the launch alone would leave `run --host
|
|
32
|
+
* <windows>` dispatching but hanging on follow forever, so we fail fast with an
|
|
33
|
+
* actionable message instead. Read-only `--host` commands (view/sessions/…) run
|
|
34
|
+
* a single round-trip with no follow protocol and DO work against Windows.
|
|
25
35
|
*/
|
|
26
36
|
async function launchDetached(host, target, opts) {
|
|
37
|
+
if (remoteShellFor(resolveRemoteOsSync(host.name)) === 'powershell') {
|
|
38
|
+
throw new Error(`Detached dispatch to Windows host "${host.name}" is not supported yet — the run ` +
|
|
39
|
+
`follow/reconcile layer is POSIX-only (offset-tails the remote log with tail/cat/stat). ` +
|
|
40
|
+
`Read-only --host commands (view, sessions, usage, cost, doctor, list, teams) do work ` +
|
|
41
|
+
`against Windows.`);
|
|
42
|
+
}
|
|
27
43
|
const id = randomUUID().slice(0, 8);
|
|
28
44
|
const remoteLog = `${REMOTE_DIR}/${id}.log`;
|
|
29
45
|
const remoteExit = `${REMOTE_DIR}/${id}.exit`;
|
|
@@ -45,6 +61,7 @@ async function launchDetached(host, target, opts) {
|
|
|
45
61
|
agent: opts.agentLabel,
|
|
46
62
|
prompt: opts.promptLabel,
|
|
47
63
|
pid: Number.isFinite(pid) ? pid : undefined,
|
|
64
|
+
sessionId: opts.sessionId,
|
|
48
65
|
remoteLog,
|
|
49
66
|
remoteExit,
|
|
50
67
|
status: 'running',
|
|
@@ -61,12 +78,29 @@ async function launchDetached(host, target, opts) {
|
|
|
61
78
|
echo: true,
|
|
62
79
|
timeoutMs: opts.timeoutMs,
|
|
63
80
|
});
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
}
|
|
69
|
-
|
|
81
|
+
// -1 = the follow window closed while the run continues on the host. Leave the
|
|
82
|
+
// record 'running' (do NOT freeze it terminal) so a later `hosts ps`/`logs`
|
|
83
|
+
// reconcile against the remote `.exit` resolves the true final status.
|
|
84
|
+
const finished = exitCode === -1 ? task : (updateTask(id, terminalPatch(exitCode)) ?? task);
|
|
85
|
+
return { task: finished, exitCode };
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Build the remote `agents run …` argv for a host dispatch. Pure so the
|
|
89
|
+
* session-id / resume flag wiring is unit-testable without an SSH round-trip.
|
|
90
|
+
* `--session-id` and `--resume` are mutually exclusive (the CLI rejects both);
|
|
91
|
+
* resume wins when — defensively — both are set.
|
|
92
|
+
*/
|
|
93
|
+
export function buildRunForwardedArgs(opts) {
|
|
94
|
+
const args = ['run', opts.agent, opts.prompt, '--quiet'];
|
|
95
|
+
if (opts.mode)
|
|
96
|
+
args.push('--mode', opts.mode);
|
|
97
|
+
if (opts.model)
|
|
98
|
+
args.push('--model', opts.model);
|
|
99
|
+
if (opts.resume)
|
|
100
|
+
args.push('--resume', opts.resume);
|
|
101
|
+
else if (opts.sessionId)
|
|
102
|
+
args.push('--session-id', opts.sessionId);
|
|
103
|
+
return args;
|
|
70
104
|
}
|
|
71
105
|
/** Dispatch an `agents run <agent> "<prompt>"` onto a host (the `run --host` path). */
|
|
72
106
|
export async function dispatchToHost(host, opts) {
|
|
@@ -74,18 +108,16 @@ export async function dispatchToHost(host, opts) {
|
|
|
74
108
|
const { warnings } = ensureHostReady(host, { agent: opts.agent });
|
|
75
109
|
for (const w of warnings)
|
|
76
110
|
process.stderr.write(`[hosts] warning: ${w}\n`);
|
|
77
|
-
const forwardedArgs = ['run', opts.agent, opts.prompt, '--quiet'];
|
|
78
|
-
if (opts.mode)
|
|
79
|
-
forwardedArgs.push('--mode', opts.mode);
|
|
80
|
-
if (opts.model)
|
|
81
|
-
forwardedArgs.push('--model', opts.model);
|
|
82
111
|
return launchDetached(host, target, {
|
|
83
|
-
forwardedArgs,
|
|
112
|
+
forwardedArgs: buildRunForwardedArgs(opts),
|
|
84
113
|
remoteCwd: opts.remoteCwd,
|
|
85
114
|
follow: opts.follow,
|
|
86
115
|
timeoutMs: opts.timeoutMs,
|
|
87
116
|
agentLabel: opts.agent,
|
|
88
117
|
promptLabel: opts.prompt,
|
|
118
|
+
// On resume the remote session keeps its existing id; record that id so the
|
|
119
|
+
// task stays mapped to the same session.
|
|
120
|
+
sessionId: opts.resume ?? opts.sessionId,
|
|
89
121
|
});
|
|
90
122
|
}
|
|
91
123
|
/**
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared host-task log viewer — the show-or-follow core behind both
|
|
3
|
+
* `agents hosts logs <id>` and the top-level `agents logs <id>`.
|
|
4
|
+
*
|
|
5
|
+
* A running task with follow re-enters the offset-tail (`followHostTask`);
|
|
6
|
+
* otherwise the captured local mirror (`localLogPath`) is printed. Kept in one
|
|
7
|
+
* place so the two commands can never drift.
|
|
8
|
+
*/
|
|
9
|
+
export interface HostLogResult {
|
|
10
|
+
/** False when no host task with this id exists (caller may fall through to sessions). */
|
|
11
|
+
found: boolean;
|
|
12
|
+
/** Process exit code to adopt when the task was shown/followed. */
|
|
13
|
+
exitCode?: number;
|
|
14
|
+
}
|
|
15
|
+
/** Show (or follow, when running) a dispatched host task's combined-stdout log. */
|
|
16
|
+
export declare function showHostTaskLog(id: string, follow: boolean): Promise<HostLogResult>;
|