@phnx-labs/agents-cli 1.20.27 → 1.20.28

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.
Files changed (83) hide show
  1. package/CHANGELOG.md +3 -0
  2. package/dist/commands/doctor.js +57 -4
  3. package/dist/commands/exec.d.ts +1 -1
  4. package/dist/commands/exec.js +177 -6
  5. package/dist/commands/hosts.d.ts +11 -0
  6. package/dist/commands/hosts.js +229 -0
  7. package/dist/commands/repo.d.ts +29 -0
  8. package/dist/commands/repo.js +174 -38
  9. package/dist/commands/secrets.d.ts +2 -7
  10. package/dist/commands/secrets.js +15 -23
  11. package/dist/commands/sessions.d.ts +2 -0
  12. package/dist/commands/sessions.js +7 -24
  13. package/dist/commands/sync.d.ts +2 -0
  14. package/dist/commands/sync.js +22 -5
  15. package/dist/commands/view.js +27 -11
  16. package/dist/index.js +2 -1
  17. package/dist/lib/agents.d.ts +1 -0
  18. package/dist/lib/agents.js +44 -4
  19. package/dist/lib/browser/drivers/ssh.d.ts +47 -2
  20. package/dist/lib/browser/drivers/ssh.js +113 -24
  21. package/dist/lib/browser/profiles.js +28 -1
  22. package/dist/lib/browser/runtime-state.js +28 -8
  23. package/dist/lib/browser/types.d.ts +10 -1
  24. package/dist/lib/cli-resources.js +10 -1
  25. package/dist/lib/doctor-diff.d.ts +12 -0
  26. package/dist/lib/doctor-diff.js +89 -2
  27. package/dist/lib/exec.d.ts +27 -0
  28. package/dist/lib/exec.js +62 -19
  29. package/dist/lib/hooks.d.ts +17 -0
  30. package/dist/lib/hooks.js +127 -3
  31. package/dist/lib/hosts/dispatch.d.ts +26 -0
  32. package/dist/lib/hosts/dispatch.js +71 -0
  33. package/dist/lib/hosts/progress.d.ts +21 -0
  34. package/dist/lib/hosts/progress.js +49 -0
  35. package/dist/lib/hosts/providers/local.d.ts +17 -0
  36. package/dist/lib/hosts/providers/local.js +81 -0
  37. package/dist/lib/hosts/ready.d.ts +37 -0
  38. package/dist/lib/hosts/ready.js +88 -0
  39. package/dist/lib/hosts/registry.d.ts +22 -0
  40. package/dist/lib/hosts/registry.js +65 -0
  41. package/dist/lib/hosts/ssh-config.d.ts +37 -0
  42. package/dist/lib/hosts/ssh-config.js +157 -0
  43. package/dist/lib/hosts/tasks.d.ts +32 -0
  44. package/dist/lib/hosts/tasks.js +58 -0
  45. package/dist/lib/hosts/types.d.ts +51 -0
  46. package/dist/lib/hosts/types.js +21 -0
  47. package/dist/lib/loop.d.ts +9 -0
  48. package/dist/lib/loop.js +13 -1
  49. package/dist/lib/mcp.js +12 -3
  50. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  51. package/dist/lib/migrate.js +9 -5
  52. package/dist/lib/platform/exec.d.ts +10 -0
  53. package/dist/lib/platform/exec.js +17 -0
  54. package/dist/lib/platform/index.d.ts +1 -0
  55. package/dist/lib/platform/index.js +1 -0
  56. package/dist/lib/platform/links.d.ts +15 -0
  57. package/dist/lib/platform/links.js +42 -0
  58. package/dist/lib/platform/paths.d.ts +18 -0
  59. package/dist/lib/platform/paths.js +22 -0
  60. package/dist/lib/platform/posixpath.d.ts +28 -0
  61. package/dist/lib/platform/posixpath.js +153 -0
  62. package/dist/lib/plugins.d.ts +10 -0
  63. package/dist/lib/plugins.js +1 -1
  64. package/dist/lib/project-launch.js +6 -3
  65. package/dist/lib/sandbox.js +5 -2
  66. package/dist/lib/self-update.js +7 -2
  67. package/dist/lib/session/db.d.ts +23 -0
  68. package/dist/lib/session/db.js +76 -1
  69. package/dist/lib/session/discover.d.ts +26 -0
  70. package/dist/lib/session/discover.js +75 -4
  71. package/dist/lib/session/relative-time.d.ts +7 -0
  72. package/dist/lib/session/relative-time.js +28 -0
  73. package/dist/lib/session/remote.d.ts +31 -3
  74. package/dist/lib/session/remote.js +121 -14
  75. package/dist/lib/ssh-exec.d.ts +45 -0
  76. package/dist/lib/ssh-exec.js +61 -0
  77. package/dist/lib/startup/command-registry.d.ts +1 -0
  78. package/dist/lib/startup/command-registry.js +2 -0
  79. package/dist/lib/types.d.ts +21 -0
  80. package/dist/lib/versions.d.ts +6 -2
  81. package/dist/lib/versions.js +8 -4
  82. package/package.json +1 -1
  83. package/scripts/postinstall.js +62 -0
@@ -80,6 +80,14 @@ export interface ExecOptions {
80
80
  addDirs?: string[];
81
81
  timeout?: string;
82
82
  sessionId?: string;
83
+ /**
84
+ * Resume the conversation named by `sessionId` using the agent's NATIVE resume
85
+ * form (claude `--resume`, codex `resume`) instead of the default `--session-id`
86
+ * create. Only set for agents where `nativeResume` returns true; other agents
87
+ * resume via a `/continue <id>` first message (Tier 2), which needs no flag and
88
+ * leaves this unset.
89
+ */
90
+ resume?: boolean;
83
91
  verbose?: boolean;
84
92
  env?: Record<string, string>;
85
93
  /**
@@ -150,6 +158,19 @@ export interface AgentCommandTemplate {
150
158
  modelFlag?: string;
151
159
  printFlags?: string[];
152
160
  verboseFlag?: string;
161
+ /**
162
+ * How this agent natively resumes a prior conversation. Presence here is the
163
+ * single source of truth for `nativeResume(agent)` — agents without it fall
164
+ * back to the universal `/continue <id>` replay (Tier 2). Two shapes:
165
+ * { flag } — append `<flag> <id>` (e.g. claude `--resume <id>`)
166
+ * { subcommand } — replace the headless base subcommand with `<subcommand> <id>`
167
+ * (codex: `codex exec` -> `codex exec resume <id>`)
168
+ */
169
+ resume?: {
170
+ flag: string;
171
+ } | {
172
+ subcommand: string;
173
+ };
153
174
  }
154
175
  /**
155
176
  * CLI command templates for every supported agent.
@@ -158,6 +179,12 @@ export interface AgentCommandTemplate {
158
179
  * AGENTS[agent].capabilities.modes. A test in exec.test.ts asserts this.
159
180
  */
160
181
  export declare const AGENT_COMMANDS: Record<AgentId, AgentCommandTemplate>;
182
+ /**
183
+ * Whether `agent` has a native resume form (Tier 1). Derived solely from the
184
+ * command template's `resume` field — the single source of truth. Agents that
185
+ * return false resume via the universal Tier-2 `/continue` replay instead.
186
+ */
187
+ export declare function nativeResume(agent: AgentId): boolean;
161
188
  /** Assemble the full CLI argument array for an agent invocation. */
162
189
  export declare function buildExecCommand(options: ExecOptions): string[];
163
190
  /** Spawn an agent and return its exit code. Convenience wrapper over spawnAgent. */
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, shims are bash scripts and cannot be executed by spawn() directly.
462
- // buildExecEnv() already sets the isolation env vars (CLAUDE_CONFIG_DIR, CODEX_HOME,
463
- // etc.) that the bash shim would set, so we can skip the shim entirely and resolve
464
- // straight to the real binary via getBinaryPath.
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
- if (process.platform === 'win32') {
467
- const binaryPath = getBinaryPath(options.agent, options.version);
468
- const binaryPathCmd = binaryPath + '.cmd';
469
- if (fs.existsSync(binaryPathCmd)) {
470
- cmd[0] = binaryPathCmd;
471
- }
472
- else if (fs.existsSync(binaryPath)) {
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
- const versionedName = `${cmd[0]}@${options.version}`;
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
- cmd.push(...modeFlags);
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
- // Add session ID (Claude only)
516
- if (options.sessionId && options.agent === 'claude') {
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)
@@ -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] codex_hooks = true in config.toml
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
- tomlConfig.features.codex_hooks = true;
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
+ }