@phnx-labs/agents-cli 1.20.27 → 1.20.29
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 +3 -0
- package/dist/commands/doctor.js +57 -4
- package/dist/commands/exec.d.ts +1 -1
- package/dist/commands/exec.js +198 -15
- package/dist/commands/hosts.d.ts +11 -0
- package/dist/commands/hosts.js +229 -0
- package/dist/commands/repo.d.ts +29 -0
- package/dist/commands/repo.js +174 -38
- package/dist/commands/secrets.d.ts +2 -7
- package/dist/commands/secrets.js +108 -29
- package/dist/commands/sessions.d.ts +2 -0
- package/dist/commands/sessions.js +8 -24
- package/dist/commands/ssh.d.ts +14 -0
- package/dist/commands/ssh.js +263 -0
- package/dist/commands/sync.d.ts +2 -0
- package/dist/commands/sync.js +22 -5
- package/dist/commands/view.js +27 -11
- package/dist/index.js +3 -1
- package/dist/lib/agents.d.ts +1 -0
- package/dist/lib/agents.js +44 -4
- package/dist/lib/browser/drivers/ssh.d.ts +47 -2
- package/dist/lib/browser/drivers/ssh.js +113 -24
- package/dist/lib/browser/profiles.js +28 -1
- package/dist/lib/browser/runtime-state.js +28 -8
- package/dist/lib/browser/types.d.ts +10 -1
- package/dist/lib/cli-resources.js +10 -1
- package/dist/lib/devices/connect.d.ts +34 -0
- package/dist/lib/devices/connect.js +101 -0
- package/dist/lib/devices/registry.d.ts +78 -0
- package/dist/lib/devices/registry.js +168 -0
- package/dist/lib/devices/ssh-config.d.ts +21 -0
- package/dist/lib/devices/ssh-config.js +33 -0
- package/dist/lib/devices/tailscale.d.ts +31 -0
- package/dist/lib/devices/tailscale.js +126 -0
- package/dist/lib/doctor-diff.d.ts +12 -0
- package/dist/lib/doctor-diff.js +89 -2
- package/dist/lib/exec.d.ts +27 -0
- package/dist/lib/exec.js +62 -19
- package/dist/lib/hooks.d.ts +17 -0
- package/dist/lib/hooks.js +127 -3
- package/dist/lib/hosts/dispatch.d.ts +26 -0
- package/dist/lib/hosts/dispatch.js +71 -0
- package/dist/lib/hosts/progress.d.ts +21 -0
- package/dist/lib/hosts/progress.js +49 -0
- package/dist/lib/hosts/providers/local.d.ts +17 -0
- package/dist/lib/hosts/providers/local.js +81 -0
- package/dist/lib/hosts/ready.d.ts +37 -0
- package/dist/lib/hosts/ready.js +88 -0
- package/dist/lib/hosts/registry.d.ts +22 -0
- package/dist/lib/hosts/registry.js +65 -0
- package/dist/lib/hosts/ssh-config.d.ts +37 -0
- package/dist/lib/hosts/ssh-config.js +157 -0
- package/dist/lib/hosts/tasks.d.ts +32 -0
- package/dist/lib/hosts/tasks.js +58 -0
- package/dist/lib/hosts/types.d.ts +51 -0
- package/dist/lib/hosts/types.js +21 -0
- package/dist/lib/loop.d.ts +9 -0
- package/dist/lib/loop.js +13 -1
- package/dist/lib/mcp.js +12 -3
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/migrate.js +9 -5
- package/dist/lib/platform/exec.d.ts +10 -0
- package/dist/lib/platform/exec.js +17 -0
- package/dist/lib/platform/index.d.ts +1 -0
- package/dist/lib/platform/index.js +1 -0
- package/dist/lib/platform/links.d.ts +15 -0
- package/dist/lib/platform/links.js +42 -0
- package/dist/lib/platform/paths.d.ts +18 -0
- package/dist/lib/platform/paths.js +22 -0
- package/dist/lib/platform/posixpath.d.ts +28 -0
- package/dist/lib/platform/posixpath.js +153 -0
- package/dist/lib/plugins.d.ts +10 -0
- package/dist/lib/plugins.js +1 -1
- package/dist/lib/project-launch.js +6 -3
- package/dist/lib/sandbox.js +5 -2
- package/dist/lib/secrets/remote.d.ts +67 -0
- package/dist/lib/secrets/remote.js +133 -0
- package/dist/lib/self-update.js +7 -2
- package/dist/lib/session/db.d.ts +24 -0
- package/dist/lib/session/db.js +80 -5
- package/dist/lib/session/discover.d.ts +28 -0
- package/dist/lib/session/discover.js +303 -4
- package/dist/lib/session/parse.d.ts +7 -0
- package/dist/lib/session/parse.js +110 -0
- package/dist/lib/session/relative-time.d.ts +7 -0
- package/dist/lib/session/relative-time.js +28 -0
- package/dist/lib/session/remote.d.ts +31 -3
- package/dist/lib/session/remote.js +121 -14
- package/dist/lib/session/types.d.ts +1 -1
- package/dist/lib/session/types.js +1 -1
- package/dist/lib/ssh-exec.d.ts +45 -0
- package/dist/lib/ssh-exec.js +61 -0
- package/dist/lib/startup/command-registry.d.ts +2 -0
- package/dist/lib/startup/command-registry.js +5 -0
- package/dist/lib/state.d.ts +2 -0
- package/dist/lib/state.js +2 -0
- package/dist/lib/types.d.ts +21 -0
- package/dist/lib/versions.d.ts +6 -2
- package/dist/lib/versions.js +8 -4
- package/package.json +1 -1
- package/scripts/postinstall.js +62 -0
package/dist/lib/exec.js
CHANGED
|
@@ -261,10 +261,12 @@ export const AGENT_COMMANDS = {
|
|
|
261
261
|
modelFlag: '--model',
|
|
262
262
|
printFlags: ['--print'],
|
|
263
263
|
verboseFlag: '--verbose',
|
|
264
|
+
resume: { flag: '--resume' },
|
|
264
265
|
},
|
|
265
266
|
codex: {
|
|
266
267
|
base: ['codex', 'exec'],
|
|
267
268
|
promptFlag: 'positional',
|
|
269
|
+
resume: { subcommand: 'resume' },
|
|
268
270
|
modeFlags: {
|
|
269
271
|
// NOTE: codex has no read-only mode in --sandbox; 'plan' here means
|
|
270
272
|
// "workspace-write but no auto-approval" — closer to plan-as-restraint.
|
|
@@ -302,6 +304,9 @@ export const AGENT_COMMANDS = {
|
|
|
302
304
|
opencode: {
|
|
303
305
|
base: ['opencode', 'run'],
|
|
304
306
|
promptFlag: 'positional',
|
|
307
|
+
// opencode's native resume is `opencode --session <id>` (NOT under `run`), so
|
|
308
|
+
// it does not compose with this headless `run` base. Until that's verified on
|
|
309
|
+
// a box with opencode installed, opencode resumes via Tier-2 `/continue`.
|
|
305
310
|
modeFlags: {
|
|
306
311
|
plan: ['--agent', 'plan'],
|
|
307
312
|
edit: ['--agent', 'build'],
|
|
@@ -436,6 +441,14 @@ export const AGENT_COMMANDS = {
|
|
|
436
441
|
modelFlag: '-m',
|
|
437
442
|
},
|
|
438
443
|
};
|
|
444
|
+
/**
|
|
445
|
+
* Whether `agent` has a native resume form (Tier 1). Derived solely from the
|
|
446
|
+
* command template's `resume` field — the single source of truth. Agents that
|
|
447
|
+
* return false resume via the universal Tier-2 `/continue` replay instead.
|
|
448
|
+
*/
|
|
449
|
+
export function nativeResume(agent) {
|
|
450
|
+
return AGENT_COMMANDS[agent]?.resume !== undefined;
|
|
451
|
+
}
|
|
439
452
|
/** Assemble the full CLI argument array for an agent invocation. */
|
|
440
453
|
export function buildExecCommand(options) {
|
|
441
454
|
const template = AGENT_COMMANDS[options.agent];
|
|
@@ -453,30 +466,36 @@ export function buildExecCommand(options) {
|
|
|
453
466
|
cmd.splice(1, 1);
|
|
454
467
|
}
|
|
455
468
|
}
|
|
469
|
+
// Native resume with a `{ subcommand }` shape (codex) appends the resume verb
|
|
470
|
+
// to the base: `codex exec` -> `codex exec resume` (headless) and, after the
|
|
471
|
+
// interactive drop above, `codex` -> `codex resume` (TUI). The session id is
|
|
472
|
+
// pushed later as the first positional (before any prompt). `{ flag }` agents
|
|
473
|
+
// (claude) need no base change — the flag is appended with the id below.
|
|
474
|
+
const resumeSpec = options.resume ? template.resume : undefined;
|
|
475
|
+
if (resumeSpec && 'subcommand' in resumeSpec) {
|
|
476
|
+
cmd.push(resumeSpec.subcommand);
|
|
477
|
+
}
|
|
456
478
|
// Use versioned alias if a specific version was requested (e.g., claude@2.1.98).
|
|
457
479
|
// Resolve to the absolute path of the shim so spawn doesn't depend on PATH —
|
|
458
480
|
// on Linux installs where the shims dir isn't on PATH, spawning the bare
|
|
459
481
|
// versioned name fails with ENOENT even though `agents view` shows the agent.
|
|
460
482
|
//
|
|
461
|
-
// On Windows
|
|
462
|
-
//
|
|
463
|
-
//
|
|
464
|
-
//
|
|
483
|
+
// On Windows the shims dir holds a `.cmd` companion next to the bash alias
|
|
484
|
+
// (see createVersionedAlias); prefer it so spawn() can launch it (the bash
|
|
485
|
+
// script is not directly executable by cmd.exe). When no shim exists on disk
|
|
486
|
+
// we fall back to the bare versioned name, which spawnAgent() resolves via
|
|
487
|
+
// PATH (+ PATHEXT/shell on Windows).
|
|
465
488
|
if (options.version && cmd.length > 0) {
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
cmd[0] = binaryPath;
|
|
474
|
-
}
|
|
489
|
+
const versionedName = `${cmd[0]}@${options.version}`;
|
|
490
|
+
const absPath = path.join(getShimsDir(), versionedName);
|
|
491
|
+
if (process.platform === 'win32' && fs.existsSync(absPath + '.cmd')) {
|
|
492
|
+
cmd[0] = absPath + '.cmd';
|
|
493
|
+
}
|
|
494
|
+
else if (fs.existsSync(absPath)) {
|
|
495
|
+
cmd[0] = absPath;
|
|
475
496
|
}
|
|
476
497
|
else {
|
|
477
|
-
|
|
478
|
-
const absPath = path.join(getShimsDir(), versionedName);
|
|
479
|
-
cmd[0] = fs.existsSync(absPath) ? absPath : versionedName;
|
|
498
|
+
cmd[0] = versionedName;
|
|
480
499
|
}
|
|
481
500
|
}
|
|
482
501
|
// Add reasoning effort flags (before mode flags for codex -c positioning)
|
|
@@ -505,15 +524,39 @@ export function buildExecCommand(options) {
|
|
|
505
524
|
// AGENT_COMMANDS.modeFlags drifted apart. Tests assert they agree.
|
|
506
525
|
throw new Error(`Internal error: ${options.agent} declares '${resolvedMode}' in capabilities.modes but has no entry in AGENT_COMMANDS.modeFlags.${resolvedMode}.`);
|
|
507
526
|
}
|
|
508
|
-
|
|
527
|
+
if (resumeSpec && 'subcommand' in resumeSpec) {
|
|
528
|
+
// codex `exec resume` / `resume` does NOT accept `--sandbox <mode>` (only the
|
|
529
|
+
// bypass flag, verified against `codex exec resume --help`). So the standard
|
|
530
|
+
// codex modeFlags can't be reused on resume. A non-plan HEADLESS resume needs
|
|
531
|
+
// the bypass or it stalls on approval prompts; plan and interactive resume
|
|
532
|
+
// inherit codex's default sandbox and pass no flag.
|
|
533
|
+
if (!interactive && resolvedMode !== 'plan') {
|
|
534
|
+
cmd.push('--dangerously-bypass-approvals-and-sandbox');
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
else {
|
|
538
|
+
cmd.push(...modeFlags);
|
|
539
|
+
}
|
|
509
540
|
// Add print/headless flags only when a prompt is provided. Without a prompt
|
|
510
541
|
// the caller wants an interactive REPL -- passing --print would immediately
|
|
511
542
|
// wait on stdin and never render the TUI.
|
|
512
543
|
if (!interactive && options.headless && template.printFlags) {
|
|
513
544
|
cmd.push(...template.printFlags);
|
|
514
545
|
}
|
|
515
|
-
//
|
|
516
|
-
|
|
546
|
+
// Resume vs. create. With `resume`, emit the agent's NATIVE resume reference:
|
|
547
|
+
// `{ flag }` agents append `<flag> <id>` (claude `--resume <id>`); `{ subcommand }`
|
|
548
|
+
// agents (codex) already pushed the verb above, so the id is the first
|
|
549
|
+
// positional here — placed before the prompt positional appended later. Without
|
|
550
|
+
// `resume`, the legacy claude-only `--session-id` CREATES a session with that id.
|
|
551
|
+
if (options.resume && options.sessionId && resumeSpec) {
|
|
552
|
+
if ('flag' in resumeSpec) {
|
|
553
|
+
cmd.push(resumeSpec.flag, options.sessionId);
|
|
554
|
+
}
|
|
555
|
+
else {
|
|
556
|
+
cmd.push(options.sessionId);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
else if (options.sessionId && options.agent === 'claude') {
|
|
517
560
|
cmd.push('--session-id', options.sessionId);
|
|
518
561
|
}
|
|
519
562
|
// Add model (only if explicitly provided by user)
|
package/dist/lib/hooks.d.ts
CHANGED
|
@@ -144,6 +144,23 @@ export declare function unmanagedHookNames(installedHookNames: string[], manifes
|
|
|
144
144
|
* shadow/override warnings the registrar path prints.
|
|
145
145
|
*/
|
|
146
146
|
export declare function listUnmanagedHooksInVersionHome(agent: AgentId, version: string): string[];
|
|
147
|
+
/**
|
|
148
|
+
* Compute the trust hash Codex expects for a single command hook handler, so
|
|
149
|
+
* agents-cli can pre-trust the hooks it registers. Without a matching
|
|
150
|
+
* trusted_hash in [hooks.state], Codex classifies the hook Untrusted and
|
|
151
|
+
* silently drops it in non-interactive (`codex exec`) mode where there is no
|
|
152
|
+
* TUI prompt to approve it.
|
|
153
|
+
*
|
|
154
|
+
* Mirrors command_hook_hash() in codex-rs/hooks/src/engine/discovery.rs +
|
|
155
|
+
* version_for_toml() in codex-rs/config/src/fingerprint.rs:
|
|
156
|
+
* sha256( canonicalJson( NormalizedHookIdentity ) ) prefixed with "sha256:".
|
|
157
|
+
*
|
|
158
|
+
* The identity passes through TOML on the Codex side, which drops None fields
|
|
159
|
+
* (commandWindows, statusMessage, and matcher when absent). `async` is always
|
|
160
|
+
* false (async hooks are not yet supported) and is always present. `timeout`
|
|
161
|
+
* is normalized to >= 1 (Codex: unwrap_or(600).max(1)).
|
|
162
|
+
*/
|
|
163
|
+
export declare function computeCodexHookTrustHash(eventKeyLabel: string, command: string, timeout: number, matcher: string | undefined): string;
|
|
147
164
|
export declare function registerHooksToSettings(agentId: AgentId, versionHome: string, hookManifest?: Record<string, ManifestHook>, agentsDirOverride?: string): {
|
|
148
165
|
registered: string[];
|
|
149
166
|
errors: string[];
|
package/dist/lib/hooks.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* parsing those manifests, registering hooks into agent-native settings files,
|
|
8
8
|
* and syncing them across version switches.
|
|
9
9
|
*/
|
|
10
|
+
import * as crypto from 'crypto';
|
|
10
11
|
import * as fs from 'fs';
|
|
11
12
|
import * as os from 'os';
|
|
12
13
|
import * as path from 'path';
|
|
@@ -148,6 +149,12 @@ const NON_SCRIPT_EXTENSIONS = new Set([
|
|
|
148
149
|
'.md', '.markdown', '.rst', '.txt',
|
|
149
150
|
'.yaml', '.yml', '.json', '.toml', '.ini', '.conf',
|
|
150
151
|
]);
|
|
152
|
+
// Documentation siblings of a hook (e.g. `git-guard.md` next to `git-guard.sh`)
|
|
153
|
+
// are human-readable docs the hook never reads at runtime — NOT a data sidecar.
|
|
154
|
+
// Treating them as the hook's `dataFile` made the installer's correct omission
|
|
155
|
+
// of docs look like perpetual drift in `agents doctor` that no sync could fix.
|
|
156
|
+
// Structured siblings (.yaml/.json/.toml/...) remain valid data files.
|
|
157
|
+
const DOC_EXTENSIONS = new Set(['.md', '.markdown', '.rst']);
|
|
151
158
|
const SCRIPT_EXTENSIONS = new Set([
|
|
152
159
|
'.sh',
|
|
153
160
|
'.bash',
|
|
@@ -261,7 +268,7 @@ export function listHookEntriesFromDir(dir) {
|
|
|
261
268
|
group.find((f) => f.isExec && !NON_SCRIPT_EXTENSIONS.has(f.ext.toLowerCase()));
|
|
262
269
|
if (!script)
|
|
263
270
|
continue;
|
|
264
|
-
const data = group.find((f) => f !== script);
|
|
271
|
+
const data = group.find((f) => f !== script && !DOC_EXTENSIONS.has(f.ext.toLowerCase()));
|
|
265
272
|
entries.push({
|
|
266
273
|
name: base,
|
|
267
274
|
scriptPath: script.fullPath,
|
|
@@ -763,6 +770,71 @@ export function listUnmanagedHooksInVersionHome(agent, version) {
|
|
|
763
770
|
// Codex events that support a matcher field (matches tool name or session type).
|
|
764
771
|
// UserPromptSubmit and Stop never include a matcher.
|
|
765
772
|
const CODEX_MATCHER_EVENTS = new Set(['PreToolUse', 'PostToolUse', 'SessionStart']);
|
|
773
|
+
// Maps PascalCase hook event names (as written in hooks.json) to the
|
|
774
|
+
// snake_case labels Codex uses in its persisted [hooks.state] keys.
|
|
775
|
+
// Mirrors hook_event_key_label() in codex-rs/hooks/src/lib.rs.
|
|
776
|
+
const CODEX_EVENT_KEY_LABELS = {
|
|
777
|
+
PreToolUse: 'pre_tool_use',
|
|
778
|
+
PermissionRequest: 'permission_request',
|
|
779
|
+
PostToolUse: 'post_tool_use',
|
|
780
|
+
PreCompact: 'pre_compact',
|
|
781
|
+
PostCompact: 'post_compact',
|
|
782
|
+
SessionStart: 'session_start',
|
|
783
|
+
UserPromptSubmit: 'user_prompt_submit',
|
|
784
|
+
SubagentStart: 'subagent_start',
|
|
785
|
+
SubagentStop: 'subagent_stop',
|
|
786
|
+
Stop: 'stop',
|
|
787
|
+
};
|
|
788
|
+
// Recursively sort object keys alphabetically at every level, mirroring
|
|
789
|
+
// canonical_json() in codex-rs/config/src/fingerprint.rs. Codex hashes the
|
|
790
|
+
// canonical JSON form so trust survives key-order differences.
|
|
791
|
+
function canonicalizeForHash(value) {
|
|
792
|
+
if (Array.isArray(value)) {
|
|
793
|
+
return value.map(canonicalizeForHash);
|
|
794
|
+
}
|
|
795
|
+
if (value && typeof value === 'object') {
|
|
796
|
+
const sorted = {};
|
|
797
|
+
for (const key of Object.keys(value).sort()) {
|
|
798
|
+
sorted[key] = canonicalizeForHash(value[key]);
|
|
799
|
+
}
|
|
800
|
+
return sorted;
|
|
801
|
+
}
|
|
802
|
+
return value;
|
|
803
|
+
}
|
|
804
|
+
/**
|
|
805
|
+
* Compute the trust hash Codex expects for a single command hook handler, so
|
|
806
|
+
* agents-cli can pre-trust the hooks it registers. Without a matching
|
|
807
|
+
* trusted_hash in [hooks.state], Codex classifies the hook Untrusted and
|
|
808
|
+
* silently drops it in non-interactive (`codex exec`) mode where there is no
|
|
809
|
+
* TUI prompt to approve it.
|
|
810
|
+
*
|
|
811
|
+
* Mirrors command_hook_hash() in codex-rs/hooks/src/engine/discovery.rs +
|
|
812
|
+
* version_for_toml() in codex-rs/config/src/fingerprint.rs:
|
|
813
|
+
* sha256( canonicalJson( NormalizedHookIdentity ) ) prefixed with "sha256:".
|
|
814
|
+
*
|
|
815
|
+
* The identity passes through TOML on the Codex side, which drops None fields
|
|
816
|
+
* (commandWindows, statusMessage, and matcher when absent). `async` is always
|
|
817
|
+
* false (async hooks are not yet supported) and is always present. `timeout`
|
|
818
|
+
* is normalized to >= 1 (Codex: unwrap_or(600).max(1)).
|
|
819
|
+
*/
|
|
820
|
+
export function computeCodexHookTrustHash(eventKeyLabel, command, timeout, matcher) {
|
|
821
|
+
const handler = {
|
|
822
|
+
type: 'command',
|
|
823
|
+
command,
|
|
824
|
+
timeout: Math.max(timeout, 1),
|
|
825
|
+
async: false,
|
|
826
|
+
};
|
|
827
|
+
const identity = {
|
|
828
|
+
event_name: eventKeyLabel,
|
|
829
|
+
hooks: [handler],
|
|
830
|
+
};
|
|
831
|
+
if (matcher !== undefined && matcher !== '') {
|
|
832
|
+
identity.matcher = matcher;
|
|
833
|
+
}
|
|
834
|
+
const canonical = canonicalizeForHash(identity);
|
|
835
|
+
const hex = crypto.createHash('sha256').update(JSON.stringify(canonical), 'utf-8').digest('hex');
|
|
836
|
+
return `sha256:${hex}`;
|
|
837
|
+
}
|
|
766
838
|
/**
|
|
767
839
|
* Register hooks as lifecycle events in an agent's config.
|
|
768
840
|
* Reads hooks.yaml manifest, merges into the agent's config file(s).
|
|
@@ -1075,7 +1147,11 @@ function registerHooksForCodex(versionHome, manifest, resolveScript, managedPref
|
|
|
1075
1147
|
errors.push(`Failed to write hooks.json: ${err.message}`);
|
|
1076
1148
|
return { registered, errors };
|
|
1077
1149
|
}
|
|
1078
|
-
// Ensure [features]
|
|
1150
|
+
// Ensure [features] hooks = true and pre-trust every registered hook in
|
|
1151
|
+
// config.toml. Codex only runs hooks that are enabled AND trusted; in
|
|
1152
|
+
// non-interactive (`codex exec`) mode there is no TUI prompt to approve
|
|
1153
|
+
// them, so an untrusted hook is silently dropped. We compute the same
|
|
1154
|
+
// trust hash Codex would and persist it under [hooks.state].
|
|
1079
1155
|
try {
|
|
1080
1156
|
let tomlConfig = {};
|
|
1081
1157
|
if (fs.existsSync(configPath)) {
|
|
@@ -1087,7 +1163,55 @@ function registerHooksForCodex(versionHome, manifest, resolveScript, managedPref
|
|
|
1087
1163
|
if (!tomlConfig.features || typeof tomlConfig.features !== 'object') {
|
|
1088
1164
|
tomlConfig.features = {};
|
|
1089
1165
|
}
|
|
1090
|
-
|
|
1166
|
+
// Codex 0.116+ feature flag is `hooks` (the legacy `codex_hooks` name is
|
|
1167
|
+
// an unrecognized key that triggers a deprecation error and is ignored).
|
|
1168
|
+
const features = tomlConfig.features;
|
|
1169
|
+
delete features.codex_hooks;
|
|
1170
|
+
features.hooks = true;
|
|
1171
|
+
// Pre-trust hooks. The [hooks.state] key is keyed by the hooks.json path
|
|
1172
|
+
// exactly as Codex resolves it (the absolute CODEX_HOME path), the
|
|
1173
|
+
// snake_case event label, and the per-event group/handler indices — which
|
|
1174
|
+
// must match Codex's parse order, so we iterate the just-written
|
|
1175
|
+
// hooksFile structure in array order.
|
|
1176
|
+
if (!tomlConfig.hooks || typeof tomlConfig.hooks !== 'object') {
|
|
1177
|
+
tomlConfig.hooks = {};
|
|
1178
|
+
}
|
|
1179
|
+
const hooksTable = tomlConfig.hooks;
|
|
1180
|
+
const existingState = hooksTable.state && typeof hooksTable.state === 'object'
|
|
1181
|
+
? hooksTable.state
|
|
1182
|
+
: {};
|
|
1183
|
+
const hookState = {};
|
|
1184
|
+
for (const [event, eventGroups] of Object.entries(hooksFile.hooks)) {
|
|
1185
|
+
const eventKeyLabel = CODEX_EVENT_KEY_LABELS[event];
|
|
1186
|
+
if (!eventKeyLabel)
|
|
1187
|
+
continue;
|
|
1188
|
+
eventGroups.forEach((group, groupIdx) => {
|
|
1189
|
+
if (!group.hooks)
|
|
1190
|
+
return;
|
|
1191
|
+
group.hooks.forEach((handler, handlerIdx) => {
|
|
1192
|
+
if (handler.type !== 'command')
|
|
1193
|
+
return;
|
|
1194
|
+
const key = `${hooksPath}:${eventKeyLabel}:${groupIdx}:${handlerIdx}`;
|
|
1195
|
+
const trustedHash = computeCodexHookTrustHash(eventKeyLabel, handler.command, handler.timeout, group.matcher);
|
|
1196
|
+
// Preserve a user's explicit `enabled = false` for this exact hook;
|
|
1197
|
+
// only (re)write the trust hash.
|
|
1198
|
+
const prior = existingState[key];
|
|
1199
|
+
const entry = { trusted_hash: trustedHash };
|
|
1200
|
+
if (prior && prior.enabled === false) {
|
|
1201
|
+
entry.enabled = false;
|
|
1202
|
+
}
|
|
1203
|
+
hookState[key] = entry;
|
|
1204
|
+
});
|
|
1205
|
+
});
|
|
1206
|
+
}
|
|
1207
|
+
// Carry forward trust state for any hooks we did not (re)register this
|
|
1208
|
+
// pass — e.g. user-added hooks under a different command path.
|
|
1209
|
+
for (const [key, entry] of Object.entries(existingState)) {
|
|
1210
|
+
if (!(key in hookState)) {
|
|
1211
|
+
hookState[key] = entry;
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
hooksTable.state = hookState;
|
|
1091
1215
|
fs.writeFileSync(configPath, TOML.stringify(tomlConfig), 'utf-8');
|
|
1092
1216
|
}
|
|
1093
1217
|
catch (err) {
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dispatch a headless agent run onto a host over SSH.
|
|
3
|
+
*
|
|
4
|
+
* The run is launched detached (`nohup … &`) writing combined output to a remote
|
|
5
|
+
* log and its exit code to a sibling `.exit` file, so progress survives a dropped
|
|
6
|
+
* connection (followed via offset-tail in progress.ts). This is the offload win:
|
|
7
|
+
* the agent's process/thread/file fan-out happens on the host, not the laptop.
|
|
8
|
+
*/
|
|
9
|
+
import type { Host } from './types.js';
|
|
10
|
+
import { type HostTask } from './tasks.js';
|
|
11
|
+
export interface DispatchOptions {
|
|
12
|
+
agent: string;
|
|
13
|
+
prompt: string;
|
|
14
|
+
mode?: string;
|
|
15
|
+
model?: string;
|
|
16
|
+
remoteCwd?: string;
|
|
17
|
+
/** Stream progress and block until completion (default true). */
|
|
18
|
+
follow?: boolean;
|
|
19
|
+
timeoutMs?: number;
|
|
20
|
+
}
|
|
21
|
+
export interface DispatchResult {
|
|
22
|
+
task: HostTask;
|
|
23
|
+
/** Exit code when followed; undefined when detached (--no-follow). */
|
|
24
|
+
exitCode?: number;
|
|
25
|
+
}
|
|
26
|
+
export declare function dispatchToHost(host: Host, opts: DispatchOptions): Promise<DispatchResult>;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dispatch a headless agent run onto a host over SSH.
|
|
3
|
+
*
|
|
4
|
+
* The run is launched detached (`nohup … &`) writing combined output to a remote
|
|
5
|
+
* log and its exit code to a sibling `.exit` file, so progress survives a dropped
|
|
6
|
+
* connection (followed via offset-tail in progress.ts). This is the offload win:
|
|
7
|
+
* the agent's process/thread/file fan-out happens on the host, not the laptop.
|
|
8
|
+
*/
|
|
9
|
+
import { randomUUID } from 'crypto';
|
|
10
|
+
import { sshExec, shellQuote } from '../ssh-exec.js';
|
|
11
|
+
import { sshTargetFor } from './types.js';
|
|
12
|
+
import { ensureHostReady } from './ready.js';
|
|
13
|
+
import { saveTask, updateTask } from './tasks.js';
|
|
14
|
+
import { followHostTask } from './progress.js';
|
|
15
|
+
// Use $HOME (not ~) so the path is correct whether or not it's quoted and
|
|
16
|
+
// regardless of the run's cwd. Task ids are 8 hex chars, so these paths are
|
|
17
|
+
// injection-safe to interpolate unquoted into remote commands.
|
|
18
|
+
const REMOTE_DIR = '$HOME/.agents/.cache/hosts';
|
|
19
|
+
export async function dispatchToHost(host, opts) {
|
|
20
|
+
const target = sshTargetFor(host);
|
|
21
|
+
const { warnings } = ensureHostReady(host, { agent: opts.agent });
|
|
22
|
+
for (const w of warnings)
|
|
23
|
+
process.stderr.write(`[hosts] warning: ${w}\n`);
|
|
24
|
+
const id = randomUUID().slice(0, 8);
|
|
25
|
+
const remoteLog = `${REMOTE_DIR}/${id}.log`;
|
|
26
|
+
const remoteExit = `${REMOTE_DIR}/${id}.exit`;
|
|
27
|
+
// Inner command run under a login shell so PATH resolves `agents`.
|
|
28
|
+
const runParts = ['agents', 'run', shellQuote(opts.agent), shellQuote(opts.prompt), '--quiet'];
|
|
29
|
+
if (opts.mode)
|
|
30
|
+
runParts.push('--mode', shellQuote(opts.mode));
|
|
31
|
+
if (opts.model)
|
|
32
|
+
runParts.push('--model', shellQuote(opts.model));
|
|
33
|
+
const cwd = opts.remoteCwd ? `cd ${shellQuote(opts.remoteCwd)} && ` : '';
|
|
34
|
+
const inner = `${cwd}${runParts.join(' ')} > ${remoteLog} 2>&1; echo $? > ${remoteExit}`;
|
|
35
|
+
// Outer: ensure dir, launch detached under bash -lc, print the PID.
|
|
36
|
+
const launch = `mkdir -p ${REMOTE_DIR}; nohup bash -lc ${shellQuote(inner)} >/dev/null 2>&1 & echo $!`;
|
|
37
|
+
const res = sshExec(target, launch, { timeoutMs: 30000 });
|
|
38
|
+
if (res.code !== 0) {
|
|
39
|
+
throw new Error(`Failed to launch on "${host.name}": ${(res.stderr || res.stdout).trim() || 'ssh error'}`);
|
|
40
|
+
}
|
|
41
|
+
const pid = parseInt(res.stdout.trim().split('\n').pop() ?? '', 10);
|
|
42
|
+
const task = {
|
|
43
|
+
id,
|
|
44
|
+
host: host.name,
|
|
45
|
+
target,
|
|
46
|
+
agent: opts.agent,
|
|
47
|
+
prompt: opts.prompt,
|
|
48
|
+
pid: Number.isFinite(pid) ? pid : undefined,
|
|
49
|
+
remoteLog,
|
|
50
|
+
remoteExit,
|
|
51
|
+
status: 'running',
|
|
52
|
+
createdAt: new Date().toISOString(),
|
|
53
|
+
};
|
|
54
|
+
saveTask(task);
|
|
55
|
+
if (opts.follow === false) {
|
|
56
|
+
return { task };
|
|
57
|
+
}
|
|
58
|
+
const exitCode = await followHostTask(target, {
|
|
59
|
+
remoteLog,
|
|
60
|
+
remoteExit,
|
|
61
|
+
taskId: id,
|
|
62
|
+
echo: true,
|
|
63
|
+
timeoutMs: opts.timeoutMs,
|
|
64
|
+
});
|
|
65
|
+
const finished = updateTask(id, {
|
|
66
|
+
status: exitCode === 0 ? 'completed' : exitCode === -1 ? 'unknown' : 'failed',
|
|
67
|
+
exitCode: exitCode === -1 ? undefined : exitCode,
|
|
68
|
+
finishedAt: new Date().toISOString(),
|
|
69
|
+
});
|
|
70
|
+
return { task: finished ?? task, exitCode };
|
|
71
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Follow a dispatched host run by offset-tailing its remote log.
|
|
3
|
+
*
|
|
4
|
+
* The run writes combined output to a log file on the host and its exit code to
|
|
5
|
+
* a sibling `.exit` file. We poll `tail -c +<offset>` (durable, offset-tracked —
|
|
6
|
+
* a dropped connection resumes from the saved offset) and finish when `.exit`
|
|
7
|
+
* appears. Rich transcript-parser rendering is a fast-follow.
|
|
8
|
+
*/
|
|
9
|
+
export interface FollowOptions {
|
|
10
|
+
remoteLog: string;
|
|
11
|
+
remoteExit: string;
|
|
12
|
+
/** Mirror remote output into this task's local log too. */
|
|
13
|
+
taskId: string;
|
|
14
|
+
/** Print streamed output to stdout. */
|
|
15
|
+
echo?: boolean;
|
|
16
|
+
/** Overall wall-clock cap; returns -1 on timeout. */
|
|
17
|
+
timeoutMs?: number;
|
|
18
|
+
pollMs?: number;
|
|
19
|
+
}
|
|
20
|
+
/** Tail the remote log to stdout until the run finishes; return its exit code. */
|
|
21
|
+
export declare function followHostTask(target: string, opts: FollowOptions): Promise<number>;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Follow a dispatched host run by offset-tailing its remote log.
|
|
3
|
+
*
|
|
4
|
+
* The run writes combined output to a log file on the host and its exit code to
|
|
5
|
+
* a sibling `.exit` file. We poll `tail -c +<offset>` (durable, offset-tracked —
|
|
6
|
+
* a dropped connection resumes from the saved offset) and finish when `.exit`
|
|
7
|
+
* appears. Rich transcript-parser rendering is a fast-follow.
|
|
8
|
+
*/
|
|
9
|
+
import * as fs from 'fs';
|
|
10
|
+
import { sshExec } from '../ssh-exec.js';
|
|
11
|
+
import { localLogPath } from './tasks.js';
|
|
12
|
+
function sleep(ms) {
|
|
13
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
14
|
+
}
|
|
15
|
+
/** Tail the remote log to stdout until the run finishes; return its exit code. */
|
|
16
|
+
export async function followHostTask(target, opts) {
|
|
17
|
+
const pollMs = opts.pollMs ?? 1500;
|
|
18
|
+
const deadline = Date.now() + (opts.timeoutMs ?? 3600_000);
|
|
19
|
+
const local = localLogPath(opts.taskId);
|
|
20
|
+
let offset = 0;
|
|
21
|
+
const drain = () => {
|
|
22
|
+
// remoteLog is a $HOME-prefixed path with a safe (hex) basename — intentionally
|
|
23
|
+
// unquoted so the remote shell expands $HOME.
|
|
24
|
+
const chunk = sshExec(target, `tail -c +${offset + 1} ${opts.remoteLog} 2>/dev/null`, { timeoutMs: 20000 });
|
|
25
|
+
if (chunk.stdout) {
|
|
26
|
+
if (opts.echo)
|
|
27
|
+
process.stdout.write(chunk.stdout);
|
|
28
|
+
try {
|
|
29
|
+
fs.appendFileSync(local, chunk.stdout);
|
|
30
|
+
}
|
|
31
|
+
catch { /* best-effort */ }
|
|
32
|
+
offset += Buffer.byteLength(chunk.stdout, 'utf8');
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
for (;;) {
|
|
36
|
+
drain();
|
|
37
|
+
const exit = sshExec(target, `cat ${opts.remoteExit} 2>/dev/null`, { timeoutMs: 12000 });
|
|
38
|
+
if (exit.code === 0 && exit.stdout.trim() !== '') {
|
|
39
|
+
drain(); // final flush
|
|
40
|
+
const code = parseInt(exit.stdout.trim(), 10);
|
|
41
|
+
return Number.isFinite(code) ? code : 0;
|
|
42
|
+
}
|
|
43
|
+
if (Date.now() > deadline) {
|
|
44
|
+
process.stderr.write('\n[hosts] follow timed out; the run continues on the host. Reattach with: agents hosts logs ' + opts.taskId + ' -f\n');
|
|
45
|
+
return -1;
|
|
46
|
+
}
|
|
47
|
+
await sleep(pollMs);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local host provider: the v1 directory.
|
|
3
|
+
*
|
|
4
|
+
* `list()` is the union of ssh-config `Host` stanzas (read-only, connection
|
|
5
|
+
* details owned by ssh) and inline entries the user registered in agents.yaml.
|
|
6
|
+
* The `Meta.hosts` overlay (caps/os, keyed by name) is merged onto both. We
|
|
7
|
+
* never copy or rewrite ssh config.
|
|
8
|
+
*/
|
|
9
|
+
import type { Host, HostProvider, HostProviderCapabilities } from '../types.js';
|
|
10
|
+
export declare class LocalHostProvider implements HostProvider {
|
|
11
|
+
readonly id: "local";
|
|
12
|
+
capabilities(): HostProviderCapabilities;
|
|
13
|
+
list(): Promise<Host[]>;
|
|
14
|
+
resolve(name: string): Promise<Host | null>;
|
|
15
|
+
register(spec: Host): Promise<Host>;
|
|
16
|
+
remove(name: string): Promise<void>;
|
|
17
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local host provider: the v1 directory.
|
|
3
|
+
*
|
|
4
|
+
* `list()` is the union of ssh-config `Host` stanzas (read-only, connection
|
|
5
|
+
* details owned by ssh) and inline entries the user registered in agents.yaml.
|
|
6
|
+
* The `Meta.hosts` overlay (caps/os, keyed by name) is merged onto both. We
|
|
7
|
+
* never copy or rewrite ssh config.
|
|
8
|
+
*/
|
|
9
|
+
import { readMeta, updateMeta } from '../../state.js';
|
|
10
|
+
import { listSshConfigHosts, isSshConfigHost } from '../ssh-config.js';
|
|
11
|
+
function entries() {
|
|
12
|
+
return readMeta().hosts ?? {};
|
|
13
|
+
}
|
|
14
|
+
function toHost(name, entry, enrolled) {
|
|
15
|
+
return {
|
|
16
|
+
name,
|
|
17
|
+
provider: 'local',
|
|
18
|
+
enrolled,
|
|
19
|
+
source: entry.source,
|
|
20
|
+
address: entry.address,
|
|
21
|
+
user: entry.user,
|
|
22
|
+
os: entry.os,
|
|
23
|
+
caps: entry.caps,
|
|
24
|
+
addedAt: entry.addedAt,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
export class LocalHostProvider {
|
|
28
|
+
id = 'local';
|
|
29
|
+
capabilities() {
|
|
30
|
+
return { directory: true, mutate: true, presence: false, relay: false, lease: false };
|
|
31
|
+
}
|
|
32
|
+
async list() {
|
|
33
|
+
const overlay = entries();
|
|
34
|
+
const out = [];
|
|
35
|
+
const seen = new Set();
|
|
36
|
+
// Inline + overlaid hosts from the registry.
|
|
37
|
+
for (const [name, entry] of Object.entries(overlay)) {
|
|
38
|
+
out.push(toHost(name, entry, true));
|
|
39
|
+
seen.add(name);
|
|
40
|
+
}
|
|
41
|
+
// ssh-config hosts not already carrying an overlay → available, not enrolled.
|
|
42
|
+
for (const name of listSshConfigHosts()) {
|
|
43
|
+
if (seen.has(name))
|
|
44
|
+
continue;
|
|
45
|
+
out.push(toHost(name, { source: 'ssh-config' }, false));
|
|
46
|
+
seen.add(name);
|
|
47
|
+
}
|
|
48
|
+
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
49
|
+
}
|
|
50
|
+
async resolve(name) {
|
|
51
|
+
const entry = entries()[name];
|
|
52
|
+
if (entry)
|
|
53
|
+
return toHost(name, entry, true);
|
|
54
|
+
if (isSshConfigHost(name))
|
|
55
|
+
return toHost(name, { source: 'ssh-config' }, false);
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
async register(spec) {
|
|
59
|
+
const entry = {
|
|
60
|
+
source: spec.source,
|
|
61
|
+
...(spec.source === 'inline' ? { address: spec.address, user: spec.user } : {}),
|
|
62
|
+
...(spec.os ? { os: spec.os } : {}),
|
|
63
|
+
...(spec.caps && spec.caps.length ? { caps: spec.caps } : {}),
|
|
64
|
+
addedAt: spec.addedAt ?? new Date().toISOString(),
|
|
65
|
+
};
|
|
66
|
+
updateMeta((meta) => ({ ...meta, hosts: { ...(meta.hosts ?? {}), [spec.name]: entry } }));
|
|
67
|
+
return toHost(spec.name, entry, true);
|
|
68
|
+
}
|
|
69
|
+
async remove(name) {
|
|
70
|
+
updateMeta((meta) => {
|
|
71
|
+
const hosts = { ...(meta.hosts ?? {}) };
|
|
72
|
+
delete hosts[name];
|
|
73
|
+
// Drop the key entirely when empty so we don't leave `hosts: {}` behind.
|
|
74
|
+
if (Object.keys(hosts).length === 0) {
|
|
75
|
+
const { hosts: _omit, ...rest } = meta;
|
|
76
|
+
return rest;
|
|
77
|
+
}
|
|
78
|
+
return { ...meta, hosts };
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host readiness + bootstrap.
|
|
3
|
+
*
|
|
4
|
+
* Before a dispatch we ensure the box is reachable and has agents-cli. Enrollment
|
|
5
|
+
* can additionally install/upgrade agents-cli to match the local version (version
|
|
6
|
+
* parity) using the same shape as scripts/sandbox.sh. We never copy `.history`.
|
|
7
|
+
*/
|
|
8
|
+
import type { Host } from './types.js';
|
|
9
|
+
/** Resolve this CLI's own version by walking up to the nearest package.json. */
|
|
10
|
+
export declare function localCliVersion(): string | null;
|
|
11
|
+
/** uname over ssh → reachable + OS string. */
|
|
12
|
+
export declare function probeHost(target: string): {
|
|
13
|
+
reachable: boolean;
|
|
14
|
+
os?: string;
|
|
15
|
+
};
|
|
16
|
+
/** Remote agents-cli version (login shell for PATH), or null if not installed. */
|
|
17
|
+
export declare function remoteAgentsVersion(target: string): string | null;
|
|
18
|
+
/** True if the named agent CLI is installed on the remote (best-effort). */
|
|
19
|
+
export declare function remoteAgentInstalled(target: string, agent: string): boolean;
|
|
20
|
+
/** Install (or upgrade to) a specific agents-cli version on the remote, then `agents setup`. */
|
|
21
|
+
export declare function bootstrapAgentsCli(target: string, version: string | null): {
|
|
22
|
+
ok: boolean;
|
|
23
|
+
output: string;
|
|
24
|
+
};
|
|
25
|
+
export interface EnsureReadyOptions {
|
|
26
|
+
agent: string;
|
|
27
|
+
/** Throw instead of warn when the agent isn't installed remotely. */
|
|
28
|
+
requireAgent?: boolean;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Verify a host can run the agent: reachable + agents-cli present. Throws with an
|
|
32
|
+
* actionable message otherwise. Agent-not-installed is a warning by default (the
|
|
33
|
+
* remote `agents run` will surface it); pass requireAgent to make it fatal.
|
|
34
|
+
*/
|
|
35
|
+
export declare function ensureHostReady(host: Host, opts: EnsureReadyOptions): {
|
|
36
|
+
warnings: string[];
|
|
37
|
+
};
|