@phnx-labs/agents-cli 1.22.22 → 1.22.24

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 (138) hide show
  1. package/CHANGELOG.md +402 -0
  2. package/README.md +13 -6
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/attach.d.ts +2 -0
  5. package/dist/commands/attach.js +32 -7
  6. package/dist/commands/defaults.js +2 -0
  7. package/dist/commands/doctor.js +20 -7
  8. package/dist/commands/exec.js +100 -34
  9. package/dist/commands/feed.d.ts +18 -0
  10. package/dist/commands/feed.js +44 -1
  11. package/dist/commands/focus.d.ts +108 -5
  12. package/dist/commands/focus.js +455 -32
  13. package/dist/commands/fork.d.ts +20 -2
  14. package/dist/commands/fork.js +91 -64
  15. package/dist/commands/go.d.ts +39 -1
  16. package/dist/commands/go.js +112 -7
  17. package/dist/commands/harness-wizard.d.ts +206 -0
  18. package/dist/commands/harness-wizard.js +403 -0
  19. package/dist/commands/harness.d.ts +12 -0
  20. package/dist/commands/harness.js +97 -101
  21. package/dist/commands/insights.js +6 -2
  22. package/dist/commands/inspect.js +39 -5
  23. package/dist/commands/menubar.js +6 -1
  24. package/dist/commands/models.js +1 -0
  25. package/dist/commands/modes.d.ts +12 -0
  26. package/dist/commands/modes.js +147 -0
  27. package/dist/commands/resume.js +10 -3
  28. package/dist/commands/secrets-sync.js +11 -13
  29. package/dist/commands/secrets.d.ts +2 -0
  30. package/dist/commands/secrets.js +79 -47
  31. package/dist/commands/sessions-browser.d.ts +35 -0
  32. package/dist/commands/sessions-browser.js +140 -14
  33. package/dist/commands/sessions-resume.d.ts +23 -3
  34. package/dist/commands/sessions-resume.js +73 -19
  35. package/dist/commands/sessions.d.ts +91 -5
  36. package/dist/commands/sessions.js +359 -125
  37. package/dist/commands/setup-secrets.js +1 -1
  38. package/dist/commands/sync.js +246 -42
  39. package/dist/commands/view.js +2 -0
  40. package/dist/commands/watchdog.js +13 -2
  41. package/dist/index.js +2 -1
  42. package/dist/lib/agent-modes.d.ts +49 -0
  43. package/dist/lib/agent-modes.js +70 -0
  44. package/dist/lib/agents.d.ts +1 -1
  45. package/dist/lib/agents.js +132 -0
  46. package/dist/lib/codex-policy.d.ts +17 -0
  47. package/dist/lib/codex-policy.js +48 -0
  48. package/dist/lib/crabbox/lease.d.ts +25 -0
  49. package/dist/lib/crabbox/lease.js +62 -0
  50. package/dist/lib/daemon.js +70 -0
  51. package/dist/lib/doctor-diff.d.ts +3 -0
  52. package/dist/lib/doctor-diff.js +15 -13
  53. package/dist/lib/event-stream.d.ts +3 -1
  54. package/dist/lib/event-stream.js +14 -1
  55. package/dist/lib/exec.d.ts +16 -0
  56. package/dist/lib/exec.js +175 -62
  57. package/dist/lib/feed-broadcast.d.ts +1 -20
  58. package/dist/lib/feed-broadcast.js +31 -1
  59. package/dist/lib/hooks/cache.js +36 -3
  60. package/dist/lib/hooks.d.ts +19 -7
  61. package/dist/lib/hooks.js +100 -40
  62. package/dist/lib/hosts/session-index.d.ts +4 -0
  63. package/dist/lib/hosts/session-index.js +7 -0
  64. package/dist/lib/manifest.d.ts +12 -2
  65. package/dist/lib/manifest.js +60 -5
  66. package/dist/lib/mcp.js +44 -0
  67. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  68. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  69. package/dist/lib/menubar/install-menubar.d.ts +52 -2
  70. package/dist/lib/menubar/install-menubar.js +128 -6
  71. package/dist/lib/models.d.ts +0 -5
  72. package/dist/lib/models.js +48 -0
  73. package/dist/lib/plugin-marketplace.js +9 -0
  74. package/dist/lib/pricing/prices.json +119 -92
  75. package/dist/lib/pricing/table.js +13 -0
  76. package/dist/lib/refresh.d.ts +5 -0
  77. package/dist/lib/refresh.js +37 -33
  78. package/dist/lib/remote-agents-json.d.ts +29 -1
  79. package/dist/lib/remote-agents-json.js +47 -10
  80. package/dist/lib/resource-inventory.d.ts +79 -0
  81. package/dist/lib/resource-inventory.js +122 -0
  82. package/dist/lib/resources/mcp.js +2 -0
  83. package/dist/lib/resources/permissions.js +3 -0
  84. package/dist/lib/resources/types.d.ts +2 -1
  85. package/dist/lib/resources.js +8 -5
  86. package/dist/lib/run-defaults.d.ts +2 -0
  87. package/dist/lib/run-defaults.js +23 -2
  88. package/dist/lib/runner.js +50 -36
  89. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  90. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  91. package/dist/lib/secrets/bundles.js +157 -65
  92. package/dist/lib/secrets/filestore.d.ts +5 -3
  93. package/dist/lib/secrets/filestore.js +12 -8
  94. package/dist/lib/secrets/index.d.ts +15 -1
  95. package/dist/lib/secrets/index.js +148 -53
  96. package/dist/lib/secrets/reaper.d.ts +97 -0
  97. package/dist/lib/secrets/reaper.js +219 -0
  98. package/dist/lib/secrets/remote.d.ts +29 -0
  99. package/dist/lib/secrets/remote.js +37 -1
  100. package/dist/lib/secrets/sync-passphrase.d.ts +27 -0
  101. package/dist/lib/secrets/sync-passphrase.js +78 -0
  102. package/dist/lib/session/active.d.ts +36 -1
  103. package/dist/lib/session/active.js +60 -19
  104. package/dist/lib/session/actor-sidecar.d.ts +14 -0
  105. package/dist/lib/session/actor-sidecar.js +67 -4
  106. package/dist/lib/session/db.d.ts +1 -1
  107. package/dist/lib/session/db.js +32 -1
  108. package/dist/lib/session/discover.js +168 -0
  109. package/dist/lib/session/parse.d.ts +10 -0
  110. package/dist/lib/session/parse.js +98 -0
  111. package/dist/lib/session/recovery.d.ts +37 -0
  112. package/dist/lib/session/recovery.js +95 -0
  113. package/dist/lib/session/remote-list.d.ts +10 -1
  114. package/dist/lib/session/remote-list.js +2 -8
  115. package/dist/lib/session/remote.d.ts +57 -3
  116. package/dist/lib/session/remote.js +90 -26
  117. package/dist/lib/session/resume-command.d.ts +6 -0
  118. package/dist/lib/session/resume-command.js +8 -0
  119. package/dist/lib/session/session-cache.d.ts +173 -0
  120. package/dist/lib/session/session-cache.js +399 -0
  121. package/dist/lib/session/types.d.ts +1 -1
  122. package/dist/lib/session/types.js +1 -1
  123. package/dist/lib/session/width.d.ts +1 -1
  124. package/dist/lib/session/width.js +12 -2
  125. package/dist/lib/shims.d.ts +2 -2
  126. package/dist/lib/shims.js +62 -8
  127. package/dist/lib/staleness/writers/hooks.js +1 -1
  128. package/dist/lib/startup/command-registry.d.ts +1 -0
  129. package/dist/lib/startup/command-registry.js +2 -0
  130. package/dist/lib/sync-umbrella.d.ts +5 -0
  131. package/dist/lib/sync-umbrella.js +5 -4
  132. package/dist/lib/tmux/session.d.ts +4 -2
  133. package/dist/lib/tmux/session.js +5 -5
  134. package/dist/lib/types.d.ts +3 -1
  135. package/dist/lib/usage.d.ts +13 -0
  136. package/dist/lib/usage.js +215 -0
  137. package/dist/lib/versions.js +13 -2
  138. package/package.json +1 -1
@@ -33,7 +33,8 @@ import { shouldInstallCommandAsSkill, commandSkillMatches, commandSkillName } fr
33
33
  import { gooseCommandMatches, gooseCommandsDir } from './goose-commands.js';
34
34
  import { supports } from './capabilities.js';
35
35
  import { listSkillsInVersionHome, getVersionSkillsDir } from './skills.js';
36
- import { listHooksInVersionHome, listHookEntriesFromDir, checkVersionHookWiring } from './hooks.js';
36
+ import { listHookEntriesFromDir } from './hooks.js';
37
+ import { getResourceInventory } from './resource-inventory.js';
37
38
  const RULES_DOC_FILENAME = 'README.md';
38
39
  const ALL_KINDS = [
39
40
  'commands',
@@ -300,11 +301,10 @@ function diffSkills(agent, version, cwd, excludeProject = false) {
300
301
  return rows.sort((a, b) => a.name.localeCompare(b.name));
301
302
  }
302
303
  // ─── hooks ────────────────────────────────────────────────────────────────────
303
- function diffHooks(agent, version, cwd) {
304
+ function diffHooks(agent, version, cwd, inventory) {
304
305
  if (!AGENTS[agent].supportsHooks)
305
306
  return [];
306
- const installedEntries = listHooksInVersionHome(agent, version);
307
- const installedByName = new Map(installedEntries.map((e) => [e.name, e]));
307
+ const installedByName = new Map(inventory.onDisk.map((e) => [e.name, e]));
308
308
  // Sync intentionally excludes project/.agents/hooks/ — mirror that.
309
309
  const layerBases = buildLayerBases(cwd, 'hooks', { excludeProject: true });
310
310
  // Group source files the same way the hook installer does (basename across
@@ -329,14 +329,14 @@ function diffHooks(agent, version, cwd) {
329
329
  continue;
330
330
  }
331
331
  const a = readSafe(src.entry.scriptPath);
332
- const b = readSafe(installed.scriptPath);
332
+ const b = readSafe(installed.path);
333
333
  let matches = a != null && b != null && normalize(a) === normalize(b);
334
- if (matches && src.entry.dataFile && installed.dataFile) {
334
+ if (matches && src.entry.dataFile && installed.detail) {
335
335
  const ad = readSafe(src.entry.dataFile);
336
- const bd = readSafe(installed.dataFile);
336
+ const bd = readSafe(installed.detail);
337
337
  matches = ad != null && bd != null && normalize(ad) === normalize(bd);
338
338
  }
339
- else if (matches && (!!src.entry.dataFile !== !!installed.dataFile)) {
339
+ else if (matches && (!!src.entry.dataFile !== !!installed.detail)) {
340
340
  matches = false;
341
341
  }
342
342
  rows.push({
@@ -345,13 +345,13 @@ function diffHooks(agent, version, cwd) {
345
345
  status: matches ? 'ok' : 'diff',
346
346
  source: src.layer,
347
347
  sourcePath: src.entry.scriptPath,
348
- homePath: installed.scriptPath,
348
+ homePath: installed.path,
349
349
  });
350
350
  }
351
351
  for (const [name, installed] of installedByName) {
352
352
  if (seen.has(name))
353
353
  continue;
354
- rows.push({ kind: 'hooks', name, status: 'extra', homePath: installed.scriptPath });
354
+ rows.push({ kind: 'hooks', name, status: 'extra', homePath: installed.path });
355
355
  }
356
356
  return rows.sort((a, b) => a.name.localeCompare(b.name));
357
357
  }
@@ -609,12 +609,13 @@ export function diffVersionResources(agent, version, options = {}) {
609
609
  empty.commands = diffCommands(agent, version, cwd, excludeProject);
610
610
  if (requested.has('skills'))
611
611
  empty.skills = diffSkills(agent, version, cwd, excludeProject);
612
- if (requested.has('hooks'))
613
- empty.hooks = diffHooks(agent, version, cwd);
612
+ const hookInventory = requested.has('hooks') ? getResourceInventory(agent, version, 'hooks', { cwd }) : undefined;
613
+ if (hookInventory)
614
+ empty.hooks = diffHooks(agent, version, cwd, hookInventory);
614
615
  // Wiring check: a hook FILE can be present and byte-identical to source (ok
615
616
  // above) yet never referenced in settings.json, so it never fires. Only
616
617
  // meaningful when hooks are in scope.
617
- const hookWiring = requested.has('hooks') ? checkVersionHookWiring(agent, version) : undefined;
618
+ const hookWiring = hookInventory?.wiring;
618
619
  if (requested.has('rules'))
619
620
  empty.rules = diffRules(agent, version, cwd, excludeProject);
620
621
  if (requested.has('mcp'))
@@ -654,6 +655,7 @@ export function diffVersionResources(agent, version, options = {}) {
654
655
  kinds: empty,
655
656
  summary: { ok, diff, missing, extra },
656
657
  ...(hookWiring ? { hookWiring } : {}),
658
+ ...(hookInventory ? { hookInventory } : {}),
657
659
  };
658
660
  }
659
661
  export const DOCTOR_ALL_KINDS = ALL_KINDS;
@@ -36,6 +36,8 @@ export interface UnifiedQuery {
36
36
  * Read a unified, newest-first event stream. Operational events come from
37
37
  * events.ts `query()`; agent-semantic events from the activity logs, normalized
38
38
  * to the same record shape and filtered identically. `limit` caps the merged
39
- * result (each source is fetched up to `limit`, so the top-N is exact).
39
+ * result (each source is fetched up to `limit` *after* its primary filters
40
+ * eventTypes for activity, eventTypes/module/bundle for ops — so the top-N is
41
+ * exact for those filters).
40
42
  */
41
43
  export declare function readUnifiedEvents(q?: UnifiedQuery): EventRecord[];
@@ -46,7 +46,9 @@ function matches(r, q) {
46
46
  * Read a unified, newest-first event stream. Operational events come from
47
47
  * events.ts `query()`; agent-semantic events from the activity logs, normalized
48
48
  * to the same record shape and filtered identically. `limit` caps the merged
49
- * result (each source is fetched up to `limit`, so the top-N is exact).
49
+ * result (each source is fetched up to `limit` *after* its primary filters
50
+ * eventTypes for activity, eventTypes/module/bundle for ops — so the top-N is
51
+ * exact for those filters).
50
52
  */
51
53
  export function readUnifiedEvents(q = {}) {
52
54
  // `bundle` is filtered inside query()'s scan (before its limit cutoff) so a
@@ -66,10 +68,21 @@ export function readUnifiedEvents(q = {}) {
66
68
  });
67
69
  if (q.includeActivity === false)
68
70
  return ops;
71
+ // Activity events always stamp module: 'activity'. A non-activity module
72
+ // filter can never match them — skip the activity scan entirely.
73
+ if (q.module != null && q.module !== 'activity')
74
+ return ops;
75
+ // Push eventTypes into the activity reader so `limit` is applied AFTER the
76
+ // event-type filter (readRecentActivity already does this for `events`).
77
+ // Without this, a rare match older than the newest-`limit` window of routine
78
+ // churn is silently dropped — the same class of bug as the ops-side bundle
79
+ // pre-filter above (RUSH-2093). Remaining filters (agent, sessionId, …) still
80
+ // run via matches() for fields activity.ts does not pre-filter.
69
81
  const acts = readActivityAsEventRecords({
70
82
  sinceMs: q.startDate?.getTime(),
71
83
  limit: q.limit,
72
84
  root: q.activityRoot,
85
+ events: q.eventTypes,
73
86
  }).filter((r) => matches(r, q));
74
87
  const merged = [...ops, ...acts].sort((a, b) => Date.parse(b.ts) - Date.parse(a.ts));
75
88
  return typeof q.limit === 'number' ? merged.slice(0, q.limit) : merged;
@@ -86,6 +86,8 @@ export interface ModeWarningState {
86
86
  * also maps an unsupported `'plan'` request onto this same value.
87
87
  */
88
88
  export declare function defaultModeFor(agent: AgentId): Mode;
89
+ /** Safe mode used when the user did not provide --mode or a configured default. */
90
+ export declare function implicitModeFor(agent: AgentId): ExecMode;
89
91
  /** Reasoning effort levels passed to agents that support them. 'auto' defers to the agent's default. */
90
92
  export type ExecEffort = 'low' | 'medium' | 'high' | 'xhigh' | 'max' | 'auto';
91
93
  /** Options for spawning an agent process. Omitting `prompt` launches the CLI interactively. */
@@ -97,6 +99,8 @@ export interface ExecOptions {
97
99
  /** Force interactive mode even when a prompt is provided. Wins over `headless`. */
98
100
  interactive?: boolean;
99
101
  mode: ExecMode;
102
+ /** True when the caller omitted --mode; fallback agents resolve their own safe default. */
103
+ modeWasImplicit?: boolean;
100
104
  effort: ExecEffort;
101
105
  cwd?: string;
102
106
  /** Force headless mode even when no prompt is provided (e.g. piping via stdin). */
@@ -385,7 +389,19 @@ export declare function shouldWrapInTmux(ctx: TmuxWrapContext): boolean;
385
389
  */
386
390
  export declare function buildTmuxAgentCommand(executable: string, args: string[], env: NodeJS.ProcessEnv, opts?: {
387
391
  redactEnvValues?: boolean;
392
+ envFile?: string;
388
393
  }): string;
394
+ /**
395
+ * Serialize the pane env to a shell-sourceable file, created 0600 and exclusively
396
+ * (`wx`) so it can never adopt a pre-existing file's mode or content.
397
+ *
398
+ * EVERY key goes in the file, not a curated "secret-bearing" subset: a denylist
399
+ * has to be updated for each new credential and is wrong the moment someone
400
+ * forgets, whereas routing all of it through the file makes the guarantee hold by
401
+ * construction. Keys are filtered to valid shell identifiers for the same reason
402
+ * `env` needed it — an exported function (`BASH_FUNC_x%%`) is not assignable.
403
+ */
404
+ export declare function writeTmuxEnvFile(env: NodeJS.ProcessEnv, filePath: string): void;
389
405
  /**
390
406
  * Trim a raw `tmux capture-pane` dump to its last `maxLines` non-empty lines
391
407
  * (right-stripping each). Used by runInTmux to recap a fast-failed agent's
package/dist/lib/exec.js CHANGED
@@ -17,11 +17,11 @@ import { isTierToken, resolveTier } from './model-tiers.js';
17
17
  import { createTimer, redactPrompt, redactArgs } from './events.js';
18
18
  import { sanitizeProcessEnv } from './secrets/bundles.js';
19
19
  import { resolveActor, actorEnv } from './actor.js';
20
- import { getShimsDir, getHistoryDir, getUserAgentsDir } from './state.js';
20
+ import { getShimsDir, getHistoryDir, getRuntimeStateDir } from './state.js';
21
21
  import { resolveCodexHome } from './codex-home.js';
22
22
  import { readCodexConfiguredModel } from './shims.js';
23
23
  import { writePidSessionEntry, extractSessionIdArg } from './session/pid-registry.js';
24
- import { writeSessionActorRecord } from './session/actor-sidecar.js';
24
+ import { writeSessionActorRecord, writeSessionAliasRecord } from './session/actor-sidecar.js';
25
25
  import { loadHookSessionIndex, resolveHookSessionId } from './session/hook-sessions.js';
26
26
  import { sessionIdMarkerLine } from './hosts/session-marker.js';
27
27
  import { recordRunName } from './session/run-names.js';
@@ -30,6 +30,7 @@ import { composeWin32CommandLine } from './platform/index.js';
30
30
  import { isTmuxInstalled } from './tmux/binary.js';
31
31
  import { shellQuote } from './ssh-exec.js';
32
32
  import { resolveClaudeSetupToken } from './claude-account-token.js';
33
+ import { codexEditWritableRoots, codexPolicyArgs } from './codex-policy.js';
33
34
  /**
34
35
  * Map a raw mode string (CLI flag, YAML field, env var) to the canonical Mode.
35
36
  *
@@ -172,6 +173,10 @@ export function resolveHeadlessMode(agent, requested, interactive, warningContex
172
173
  export function defaultModeFor(agent) {
173
174
  return AGENTS[agent].capabilities.modes[0];
174
175
  }
176
+ /** Safe mode used when the user did not provide --mode or a configured default. */
177
+ export function implicitModeFor(agent) {
178
+ return agent === 'codex' ? 'edit' : 'plan';
179
+ }
175
180
  /**
176
181
  * Resolve interactive vs headless. Explicit flags are definitive and win over
177
182
  * inference: `--interactive` forces interactive, `--headless` forces headless.
@@ -357,6 +362,28 @@ export function buildExecEnv(options) {
357
362
  delete result.CODEX_HOME;
358
363
  delete result.COPILOT_HOME;
359
364
  }
365
+ else if (options.agent === 'muse') {
366
+ // Muse has no MUSE_CONFIG_DIR. Config is XDG-based:
367
+ // $XDG_CONFIG_HOME/muse (settings, skills, hooks, auth)
368
+ // $XDG_DATA_HOME/muse (sessions, plugins)
369
+ // Pin both into the version home so multi-version isolation matches
370
+ // Claude's CLAUDE_CONFIG_DIR / Codex's CODEX_HOME, and so Muse never
371
+ // resolves through the adopt-time ~/.config/muse symlink (SymlinkOrReparse).
372
+ const cwd = options.cwd || process.cwd();
373
+ const resolvedVersion = options.version ?? resolveVersion('muse', cwd);
374
+ const version = options.version
375
+ ? resolvedVersion
376
+ : (resolvedVersion && isVersionInstalled('muse', resolvedVersion) ? resolvedVersion : null);
377
+ if (version) {
378
+ const versionHome = getVersionHomePath('muse', version);
379
+ result.XDG_CONFIG_HOME = path.join(versionHome, '.config');
380
+ result.XDG_DATA_HOME = path.join(versionHome, '.local', 'share');
381
+ }
382
+ delete result.CLAUDE_CONFIG_DIR;
383
+ delete result.CODEX_HOME;
384
+ delete result.COPILOT_HOME;
385
+ delete result.KIMI_CODE_HOME;
386
+ }
360
387
  else {
361
388
  delete result.CLAUDE_CONFIG_DIR;
362
389
  delete result.CODEX_HOME;
@@ -441,11 +468,11 @@ export const AGENT_COMMANDS = {
441
468
  promptFlag: 'positional',
442
469
  resume: { subcommand: 'resume' },
443
470
  modeFlags: {
444
- plan: ['--sandbox', 'read-only'],
445
- // Sandboxed writes inside the workspace. Network stays on (workspace-write
446
- // disables it by default) so edit-mode runs can still use git/gh/package
447
- // installs. No approval bypass here — only `skip` drops the guardrails.
448
- edit: ['--sandbox', 'workspace-write', '-c', 'sandbox_workspace_write.network_access=true'],
471
+ // Native Codex modes are assembled by codexPolicyArgs below. Named
472
+ // permission profiles keep filesystem access and network access
473
+ // independent; legacy --sandbox flags cannot express that combination.
474
+ plan: [],
475
+ edit: [],
449
476
  // skip = codex --yolo: drops the sandbox entirely and approves anything.
450
477
  skip: ['--dangerously-bypass-approvals-and-sandbox'],
451
478
  },
@@ -637,6 +664,29 @@ export const AGENT_COMMANDS = {
637
664
  },
638
665
  modelFlag: '--model',
639
666
  },
667
+ // Meta Muse Code (`muse exec` headless, `muse` TUI). Flags from
668
+ // `muse --help` / `muse exec --help` (v0.1.0): prompt is positional;
669
+ // --model selects muse-spark-*; --reasoning-effort is the effort dial;
670
+ // --disable-write approximates plan (no non-shell writes); --disable-approval
671
+ // keeps the sandbox (auto); --yolo drops approval+sandbox and trusts the
672
+ // workspace (skip). --json emits JSONL on stdout. Interactive resume is
673
+ // `muse resume <id>`; headless resume is `muse exec --session-id <id>` —
674
+ // see the muse resume branch in buildExecCommand.
675
+ muse: {
676
+ base: ['muse', 'exec'],
677
+ promptFlag: 'positional',
678
+ modeFlags: {
679
+ plan: ['--disable-write'],
680
+ edit: [],
681
+ auto: ['--disable-approval'],
682
+ skip: ['--yolo'],
683
+ },
684
+ jsonFlags: ['--json'],
685
+ modelFlag: '--model',
686
+ // Flag form covers headless (`--session-id`). Interactive uses the
687
+ // `resume` subcommand — special-cased in buildExecCommand.
688
+ resume: { flag: '--session-id' },
689
+ },
640
690
  };
641
691
  /**
642
692
  * Whether `agent` has a native resume form (Tier 1). Derived solely from the
@@ -665,12 +715,13 @@ export function buildExecCommand(options) {
665
715
  const template = AGENT_COMMANDS[options.agent];
666
716
  const cmd = [...template.base];
667
717
  const interactive = resolveInteractive(options);
668
- // For Codex and Droid, 'exec' is the headless subcommand; for OpenCode, 'run'
669
- // is. Drop it for interactive mode so we launch the TUI (`codex` / `droid` /
670
- // `opencode`, each agent's default command) instead of the one-shot headless
671
- // subcommand ('codex exec' / 'droid exec' / 'opencode run').
718
+ // For Codex, Droid, and Muse, 'exec' is the headless subcommand; for OpenCode,
719
+ // 'run' is. Drop it for interactive mode so we launch the TUI (`codex` /
720
+ // `droid` / `muse` / `opencode`) instead of the one-shot headless subcommand
721
+ // ('codex exec' / 'droid exec' / 'muse exec' / 'opencode run').
672
722
  if (interactive) {
673
- if ((options.agent === 'codex' || options.agent === 'droid') && cmd[1] === 'exec') {
723
+ if ((options.agent === 'codex' || options.agent === 'droid' || options.agent === 'muse') &&
724
+ cmd[1] === 'exec') {
674
725
  cmd.splice(1, 1);
675
726
  }
676
727
  else if (options.agent === 'opencode' && cmd[1] === 'run') {
@@ -682,8 +733,18 @@ export function buildExecCommand(options) {
682
733
  // interactive drop above, `codex` -> `codex resume` (TUI). The session id is
683
734
  // pushed later as the first positional (before any prompt). `{ flag }` agents
684
735
  // (claude) need no base change — the flag is appended with the id below.
736
+ //
737
+ // Muse interactive resume is also a subcommand (`muse resume <id>`), but the
738
+ // session id MUST sit immediately after the verb — mode/model flags come
739
+ // after. So for interactive muse we push both `resume` and the id here, and
740
+ // skip the later generic resume flag path.
685
741
  const resumeSpec = options.resume ? template.resume : undefined;
686
- if (resumeSpec && 'subcommand' in resumeSpec) {
742
+ let museInteractiveResumeDone = false;
743
+ if (options.agent === 'muse' && options.resume && interactive && options.sessionId) {
744
+ cmd.push('resume', options.sessionId);
745
+ museInteractiveResumeDone = true;
746
+ }
747
+ else if (resumeSpec && 'subcommand' in resumeSpec) {
687
748
  cmd.push(resumeSpec.subcommand);
688
749
  }
689
750
  // Use versioned alias if a specific version was requested (e.g., claude@2.1.98).
@@ -764,40 +825,26 @@ export function buildExecCommand(options) {
764
825
  // narrower than --yolo/-f, which also bypasses permission checks.
765
826
  cmd.push('--trust');
766
827
  }
767
- // Codex's workspace-write sandbox blocks $HOME (verified against the live CLI
768
- // and OpenAI's sandbox docs: writable roots extend scope "without removing the
769
- // sandbox entirely"). But the model routinely shells out to `agents ...`, whose
770
- // runtime state lives under ~/.agents — the SSH askpass shim
771
- // (~/.agents/.cache/devices/askpass.sh), the device/stats cache, secrets,
772
- // session writes, config tunings. Without ~/.agents as a writable root those
773
- // inner writes fail with EROFS (e.g. `agents ssh` dies before connecting),
774
- // which is why a remote `agents run codex` couldn't reach the fleet. Grant it
775
- // implicitly whenever codex runs workspace-write — far narrower than --mode skip
776
- // (danger-full-access). Fresh runs take --add-dir (below); resume forms reject
777
- // --add-dir, so they take the same root via -c writable_roots here.
778
- const codexWorkspaceWrite = options.agent === 'codex' && resolvedMode === 'edit';
779
- if (resumeSpec && 'subcommand' in resumeSpec) {
828
+ if (options.agent === 'codex') {
829
+ const policyMode = resolvedMode === 'plan' || resolvedMode === 'skip' ? resolvedMode : 'edit';
830
+ const writableRoots = [...codexEditWritableRoots(), ...(options.addDirs ?? [])];
831
+ cmd.push(...codexPolicyArgs(policyMode, writableRoots));
832
+ }
833
+ else if (resumeSpec && 'subcommand' in resumeSpec) {
780
834
  if (resolvedMode === 'skip') {
781
835
  // skip = yolo on resume too; both `codex resume` (TUI) and
782
836
  // `codex exec resume` accept the bypass flag.
783
837
  cmd.push('--dangerously-bypass-approvals-and-sandbox');
784
838
  }
785
839
  else if (interactive) {
786
- // `codex resume` (TUI) accepts the same -s/--sandbox flags as a fresh run.
787
840
  cmd.push(...modeFlags);
788
- if (codexWorkspaceWrite)
789
- cmd.push('-c', codexWritableRootsConfig(getUserAgentsDir()));
790
841
  }
791
842
  else {
792
843
  // `codex exec resume` rejects `--sandbox <mode>` (verified against
793
844
  // `codex exec resume --help` on 0.142.5), but takes -c config overrides —
794
845
  // map the mode through sandbox_mode so a non-skip resume never gets the
795
846
  // approval/sandbox bypass.
796
- cmd.push('-c', `sandbox_mode=${resolvedMode === 'plan' ? 'read-only' : 'workspace-write'}`);
797
- if (resolvedMode !== 'plan') {
798
- cmd.push('-c', 'sandbox_workspace_write.network_access=true');
799
- cmd.push('-c', codexWritableRootsConfig(getUserAgentsDir()));
800
- }
847
+ cmd.push(...modeFlags);
801
848
  }
802
849
  }
803
850
  else if (options.agent === 'kimi' && !interactive) {
@@ -835,7 +882,7 @@ export function buildExecCommand(options) {
835
882
  // agents (codex) already pushed the verb above, so the id is the first
836
883
  // positional here — placed before the prompt positional appended later. Without
837
884
  // `resume`, the legacy claude-only `--session-id` CREATES a session with that id.
838
- if (options.resume && options.sessionId && resumeSpec) {
885
+ if (options.resume && options.sessionId && resumeSpec && !museInteractiveResumeDone) {
839
886
  if ('flag' in resumeSpec) {
840
887
  const flag = interactive
841
888
  ? (resumeSpec.interactiveFlag ?? resumeSpec.flag)
@@ -913,21 +960,10 @@ export function buildExecCommand(options) {
913
960
  cmd.push(template.promptFlag, options.prompt);
914
961
  }
915
962
  }
916
- // Extra writable dirs. Claude and Codex both take `--add-dir`; for Codex it
917
- // widens the workspace-write sandbox (teams relies on this to let teammates
918
- // write ~/.agents), so it must actually be forwarded — it used to be
919
- // claude-only, silently dropped for codex and masked by edit mode carrying
920
- // the approval/sandbox bypass. Codex's resume forms reject --add-dir, so
921
- // skip it there (claude's flag-based resume accepts it).
922
- //
923
- // On top of any user-supplied dirs, a fresh codex workspace-write run
924
- // implicitly gets ~/.agents (deduped) so the CLI's own tooling — askpass,
925
- // secrets, sessions, tunings — can write from inside the sandbox. Resume forms
926
- // get the same root via -c writable_roots above (see codexWorkspaceWrite).
927
- const codexImplicitDirs = codexWorkspaceWrite && !options.resume ? [getUserAgentsDir()] : [];
928
- const addDirs = [...new Set([...(options.addDirs ?? []), ...codexImplicitDirs])];
929
- if (addDirs.length > 0 &&
930
- (options.agent === 'claude' || (options.agent === 'codex' && !options.resume))) {
963
+ // Codex add-dirs are folded into its named edit profile above, including on
964
+ // resume where the native CLI rejects --add-dir. Claude keeps its native flag.
965
+ const addDirs = [...new Set(options.addDirs ?? [])];
966
+ if (addDirs.length > 0 && options.agent === 'claude') {
931
967
  for (const dir of addDirs) {
932
968
  cmd.push('--add-dir', dir);
933
969
  }
@@ -1024,8 +1060,11 @@ export async function execShimPassthrough(agent, rawArgs, cwd, pinnedVersion) {
1024
1060
  if (fs.existsSync(cmdPath))
1025
1061
  binary = cmdPath;
1026
1062
  }
1027
- // The only flag the bash shim injects (codex); everything else is transparent.
1028
- const launchArgs = agent === 'codex' ? ['-c', 'check_for_update_on_startup=false'] : [];
1063
+ // Match the POSIX shim: direct Codex launches default to the safe writable
1064
+ // profile, while later user arguments can still override native settings.
1065
+ const launchArgs = agent === 'codex'
1066
+ ? ['-c', 'check_for_update_on_startup=false', ...codexPolicyArgs('edit')]
1067
+ : [];
1029
1068
  // Mint a launch id and export it as AGENT_LAUNCH_ID so the agent's SessionStart
1030
1069
  // hook records the same id — the join key that maps this launch to its exact
1031
1070
  // session even though the recorded pid here is the cmd.exe wrapper (Windows) or
@@ -1150,13 +1189,53 @@ export function shouldWrapInTmux(ctx) {
1150
1189
  * real values; the stored/informational copy uses the redacted form (RUSH-1758).
1151
1190
  */
1152
1191
  export function buildTmuxAgentCommand(executable, args, env, opts = {}) {
1192
+ const agentCmd = [executable, ...args].map(shellQuote).join(' ');
1193
+ // envFile: source the values instead of inlining them, so no VALUE ever lands
1194
+ // in the pane's argv. `exec env K=V …` put every resolved secret into the
1195
+ // process table, readable by any process of this user — on one fleet box six
1196
+ // live processes carried the secrets-store master passphrase, which decrypts
1197
+ // every file-backed bundle including the Claude OAuth tokens (RUSH-2100).
1198
+ // `set -a` exports what the file assigns; the file is unlinked before `exec`,
1199
+ // so it exists only for the sourcing itself. A missing file aborts the pane
1200
+ // rather than silently launching with a half-built env.
1201
+ if (opts.envFile) {
1202
+ const f = shellQuote(opts.envFile);
1203
+ // Remove the file whether or not sourcing succeeds — a bare `. f || exit 1`
1204
+ // strands the plaintext env (incl. the secrets-store master passphrase) on
1205
+ // disk on any source failure, worse than the argv leak this replaces
1206
+ // (RUSH-2100). Capture the source rc, unlink, then honor it.
1207
+ return `set -a; . ${f}; __agents_rc=$?; set +a; rm -f ${f}; [ "$__agents_rc" -eq 0 ] || exit 1; exec ${agentCmd}`;
1208
+ }
1153
1209
  const envPrefix = Object.entries(env)
1154
1210
  .filter(([k, v]) => v !== undefined && EXEC_ENV_KEY_PATTERN.test(k))
1155
1211
  .map(([k, v]) => `${k}=${opts.redactEnvValues ? '<redacted>' : shellQuote(String(v))}`)
1156
1212
  .join(' ');
1157
- const agentCmd = [executable, ...args].map(shellQuote).join(' ');
1158
1213
  return `exec env ${envPrefix} ${agentCmd}`;
1159
1214
  }
1215
+ /**
1216
+ * Serialize the pane env to a shell-sourceable file, created 0600 and exclusively
1217
+ * (`wx`) so it can never adopt a pre-existing file's mode or content.
1218
+ *
1219
+ * EVERY key goes in the file, not a curated "secret-bearing" subset: a denylist
1220
+ * has to be updated for each new credential and is wrong the moment someone
1221
+ * forgets, whereas routing all of it through the file makes the guarantee hold by
1222
+ * construction. Keys are filtered to valid shell identifiers for the same reason
1223
+ * `env` needed it — an exported function (`BASH_FUNC_x%%`) is not assignable.
1224
+ */
1225
+ export function writeTmuxEnvFile(env, filePath) {
1226
+ const body = Object.entries(env)
1227
+ .filter(([k, v]) => v !== undefined && EXEC_ENV_KEY_PATTERN.test(k))
1228
+ .map(([k, v]) => `${k}=${shellQuote(String(v))}`)
1229
+ .join('\n');
1230
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
1231
+ const fd = fs.openSync(filePath, 'wx', 0o600);
1232
+ try {
1233
+ fs.writeSync(fd, `${body}\n`);
1234
+ }
1235
+ finally {
1236
+ fs.closeSync(fd);
1237
+ }
1238
+ }
1160
1239
  /**
1161
1240
  * Trim a raw `tmux capture-pane` dump to its last `maxLines` non-empty lines
1162
1241
  * (right-stripping each). Used by runInTmux to recap a fast-failed agent's
@@ -1192,24 +1271,57 @@ export function formatPaneTail(raw, maxLines = 30) {
1192
1271
  * (Ctrl-b d) — return 0 and LEAVE the session for `agents focus` to re-attach.
1193
1272
  */
1194
1273
  async function runInTmux(options, executable, args) {
1195
- const { createSession, killSession, paneExitStatus, setSessionHook, slugifyName, agentPaneDiedHook, markSessionHookSchema } = await import('./tmux/session.js');
1274
+ const { createSession, hasSession, killSession, paneExitStatus, setSessionHook, slugifyName, agentPaneDiedHook, markSessionHookSchema } = await import('./tmux/session.js');
1196
1275
  const { getDefaultSocketPath } = await import('./tmux/paths.js');
1197
1276
  const { attachTmux, runTmux } = await import('./tmux/binary.js');
1198
1277
  const socket = getDefaultSocketPath();
1199
1278
  const cwd = options.cwd || process.cwd();
1200
1279
  const idSeed = (options.sessionId ?? randomUUID()).slice(0, 8);
1201
1280
  const name = slugifyName(`ag-${options.agent}-${idSeed}`);
1202
- const execEnv = buildExecEnv(options);
1203
- // Launch with the real env (secret VALUES materialized into the pane); persist
1204
- // only a value-redacted copy in SessionMeta.cmd so resolved secrets never hit
1205
- // disk via the informational cmd field (RUSH-1758).
1206
- const cmd = buildTmuxAgentCommand(executable, args, execEnv);
1281
+ // A native resume must not create a competing wrapper for a live session.
1282
+ // A retained dead pane is reaped before the harness resumes normally.
1283
+ if (options.resume && await hasSession(name, socket)) {
1284
+ const existing = await createSession({ name, cwd, socket, source: 'cli', attachExisting: true });
1285
+ if (options.sessionId)
1286
+ writeSessionAliasRecord(options.sessionId, name);
1287
+ if (!existing.pane || !(await paneExitStatus(existing.pane, socket)).dead) {
1288
+ await attachTmux({ socket, args: ['attach-session', '-t', name] });
1289
+ return { exitCode: 0, stderr: '', stdout: '' };
1290
+ }
1291
+ await killSession(name, socket);
1292
+ }
1293
+ // SessionStart learns some harness IDs only after launch. Carry the wrapper
1294
+ // name into that hook so it can bind both identities durably.
1295
+ const execEnv = { ...buildExecEnv(options), AGENT_TMUX_SESSION_NAME: name };
1296
+ // The pane sources its env from a 0600 file it unlinks before exec, so no
1297
+ // resolved secret VALUE reaches the process table (RUSH-2100). SessionMeta.cmd
1298
+ // keeps the value-redacted inline form — the human-readable record of what ran,
1299
+ // which never carried real values anyway (RUSH-1758).
1300
+ const envFile = path.join(getRuntimeStateDir(), 'tmux-env', `${name}-${randomUUID().slice(0, 8)}.env`);
1301
+ writeTmuxEnvFile(execEnv, envFile);
1302
+ const cmd = buildTmuxAgentCommand(executable, args, execEnv, { envFile });
1207
1303
  const metaCmd = buildTmuxAgentCommand(executable, args, execEnv, { redactEnvValues: true });
1208
1304
  const labels = { agent: options.agent };
1209
1305
  if (options.sessionId)
1210
1306
  labels.sessionId = options.sessionId;
1211
- const meta = await createSession({ name, cmd, metaCmd, cwd, socket, source: 'cli', labels });
1307
+ // Only a launched pane sources-and-unlinks the env file. If createSession
1308
+ // throws, the pane never runs, so the resolved secrets (incl. the master
1309
+ // passphrase) would linger on disk — remove it on that failure path
1310
+ // (RUSH-2100). On success the detached pane owns the unlink.
1311
+ let meta;
1312
+ try {
1313
+ meta = await createSession({ name, cmd, metaCmd, cwd, socket, source: 'cli', labels });
1314
+ }
1315
+ catch (err) {
1316
+ try {
1317
+ fs.rmSync(envFile, { force: true });
1318
+ }
1319
+ catch { /* best-effort */ }
1320
+ throw err;
1321
+ }
1212
1322
  const pane = meta.pane;
1323
+ if (options.sessionId)
1324
+ writeSessionAliasRecord(options.sessionId, name);
1213
1325
  if (pane) {
1214
1326
  // When the AGENT pane dies, detach the client (don't kill) so the session
1215
1327
  // survives just long enough to read the dead pane's exit status below. The
@@ -1284,7 +1396,7 @@ async function runInTmux(options, executable, args) {
1284
1396
  };
1285
1397
  // The agent could exit before we attach (fast failure). Don't attach to an
1286
1398
  // already-dead pane — surface its output + status directly and tear down.
1287
- const before = pane ? await paneExitStatus(pane, socket) : { dead: false };
1399
+ const before = pane ? await paneExitStatus(pane, socket) : { found: false, dead: false, status: undefined };
1288
1400
  if (before.dead) {
1289
1401
  // F2 (RUSH-2185 / EXEC-23a): for interactive runs, ALWAYS recap — a clean
1290
1402
  // exit-0 before attach means the harness has no interactive REPL and the
@@ -1310,7 +1422,7 @@ async function runInTmux(options, executable, args) {
1310
1422
  return false;
1311
1423
  }
1312
1424
  };
1313
- const after = pane ? await paneExitStatus(pane, socket) : { dead: false };
1425
+ const after = pane ? await paneExitStatus(pane, socket) : { found: false, dead: false, status: undefined };
1314
1426
  if (after.dead) {
1315
1427
  // Nonzero exit after attach → the agent crashed rather than the user
1316
1428
  // detaching cleanly (a clean detach leaves the pane ALIVE, handled below).
@@ -1873,6 +1985,7 @@ export async function runWithFallback(options) {
1873
1985
  ...options,
1874
1986
  agent,
1875
1987
  version,
1988
+ mode: options.modeWasImplicit ? implicitModeFor(agent) : options.mode,
1876
1989
  prompt,
1877
1990
  env: envOverride ? { ...(options.env ?? {}), ...envOverride } : options.env,
1878
1991
  sessionId: pinnedSessionId ?? (i === 0 ? options.sessionId : undefined),
@@ -141,26 +141,7 @@ export declare function scrubOutboundDashes(text: string): string;
141
141
  * host last. Skip the uninformative default label `agent`.
142
142
  */
143
143
  export declare function composeBroadcastFooter(ctx: FeedBroadcastContext): string | undefined;
144
- /**
145
- * Human-facing body for a messaging sink (`{message}`).
146
- *
147
- * ```
148
- * Title in a few words
149
- *
150
- * Body of what happened or the ask.
151
- * Options: publish / wait (blocks with choices)
152
- * Default in 15 min: wait (blocks with a safe default)
153
- *
154
- * Sent from grok/a02da0e2 on mac-mini
155
- * https://… (optional attach URL)
156
- * ```
157
- *
158
- * Title first (scannable subject). Blank line. Body. Then the phone-actionable
159
- * choices + default (a block that has stopped for the human), then footer
160
- * provenance. No `agents focus <id>` line: a CLI command is unusable from a phone,
161
- * so the safe default is the fallback and the message carries it. Prefer `{message}`
162
- * over bare `{text}` in messaging sinks.
163
- */
144
+ export declare function truncateBroadcastBody(body: string): string;
164
145
  export declare function composeBroadcastMessage(ctx: FeedBroadcastContext): string;
165
146
  /**
166
147
  * Substitute `{placeholder}` tokens in an argv template. Returns undefined when
@@ -202,9 +202,39 @@ export function composeBroadcastFooter(ctx) {
202
202
  * so the safe default is the fallback and the message carries it. Prefer `{message}`
203
203
  * over bare `{text}` in messaging sinks.
204
204
  */
205
+ /**
206
+ * The phone copy is a text, not a report. A long body reaches the owner's phone
207
+ * as an unreadable wall, and the `feed post` forwarding path is where that has to
208
+ * be shaped: every sink (owner alias, in-process `channel:`, spawned `command:`)
209
+ * funnels through {@link composeBroadcastMessage}, which is the only seam a shell
210
+ * hook cannot reach. Keep the title (the scannable headline) and cap the BODY to a
211
+ * short excerpt, marking the cut with a plain "(full in feed)" — NOT a CLI command
212
+ * (unusable from a phone, see the note on composeBroadcastMessage). Nothing is
213
+ * lost: this shapes only the outbound sink text; the full post stays in the feed.
214
+ */
215
+ const PHONE_BODY_MAX_CHARS = 500;
216
+ const PHONE_BODY_MAX_LINES = 8;
217
+ export function truncateBroadcastBody(body) {
218
+ if (!body)
219
+ return body;
220
+ let out = body;
221
+ let cut = false;
222
+ const lines = out.split('\n');
223
+ if (lines.length > PHONE_BODY_MAX_LINES) {
224
+ out = lines.slice(0, PHONE_BODY_MAX_LINES).join('\n');
225
+ cut = true;
226
+ }
227
+ if (out.length > PHONE_BODY_MAX_CHARS) {
228
+ out = out.slice(0, PHONE_BODY_MAX_CHARS);
229
+ cut = true;
230
+ }
231
+ if (!cut)
232
+ return body;
233
+ return `${out.trimEnd()}\n… (full in feed)`;
234
+ }
205
235
  export function composeBroadcastMessage(ctx) {
206
236
  const title = scrubOutboundDashes(ctx.title ?? '');
207
- const body = scrubOutboundDashes(ctx.text ?? '');
237
+ const body = truncateBroadcastBody(scrubOutboundDashes(ctx.text ?? ''));
208
238
  // Title preferred; if an older post has no title, body alone still sends.
209
239
  const head = title || body;
210
240
  const mid = title && body && title !== body ? body : undefined;