@phnx-labs/agents-cli 1.22.69 → 1.22.71

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 (130) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +34 -9
  3. package/dist/bootstrap.js +4 -4
  4. package/dist/commands/accounts.js +23 -7
  5. package/dist/commands/exec.js +2 -2
  6. package/dist/commands/import.js +2 -2
  7. package/dist/commands/models.js +2 -2
  8. package/dist/commands/permissions.js +2 -2
  9. package/dist/commands/repo.js +2 -2
  10. package/dist/commands/rules.js +1 -1
  11. package/dist/commands/send.js +9 -3
  12. package/dist/commands/sessions-export.d.ts +5 -1
  13. package/dist/commands/sessions-export.js +100 -24
  14. package/dist/commands/sessions-import.d.ts +2 -1
  15. package/dist/commands/sessions-import.js +85 -21
  16. package/dist/commands/traces.js +1 -1
  17. package/dist/lib/account-capabilities.js +0 -2
  18. package/dist/lib/account-registry.d.ts +3 -3
  19. package/dist/lib/account-registry.js +25 -7
  20. package/dist/lib/accounting/usage-sync.d.ts +1 -1
  21. package/dist/lib/accounting/usage-sync.js +3 -3
  22. package/dist/lib/acp/client.d.ts +1 -1
  23. package/dist/lib/acp/client.js +12 -1
  24. package/dist/lib/acp/harnesses.js +1 -1
  25. package/dist/lib/add-dir.js +0 -2
  26. package/dist/lib/agent-cli-commands.js +0 -2
  27. package/dist/lib/agent-spec/agents.d.ts +1 -1
  28. package/dist/lib/agent-spec/agents.js +2 -83
  29. package/dist/lib/browser/ipc.d.ts +34 -0
  30. package/dist/lib/browser/ipc.js +149 -20
  31. package/dist/lib/browser/remote-control.d.ts +6 -3
  32. package/dist/lib/browser/remote-control.js +6 -3
  33. package/dist/lib/browser/service.d.ts +4 -1
  34. package/dist/lib/browser/service.js +7 -1
  35. package/dist/lib/browser/types.d.ts +3 -1
  36. package/dist/lib/channels/registry.d.ts +6 -0
  37. package/dist/lib/channels/send.d.ts +11 -2
  38. package/dist/lib/channels/send.js +11 -2
  39. package/dist/lib/cloud/rush.d.ts +10 -2
  40. package/dist/lib/cloud/rush.js +15 -9
  41. package/dist/lib/daemon/auth-sync-service.js +1 -1
  42. package/dist/lib/daemon/browser-task-reap-service.js +1 -1
  43. package/dist/lib/daemon/daemon.js +41 -11
  44. package/dist/lib/daemon/heartbeat-service.js +3 -3
  45. package/dist/lib/daemon/keychain-reap-service.js +1 -1
  46. package/dist/lib/daemon/runner.d.ts +18 -1
  47. package/dist/lib/daemon/runner.js +237 -80
  48. package/dist/lib/daemon/self-heal-service.js +13 -3
  49. package/dist/lib/daemon/self-update-service.d.ts +174 -0
  50. package/dist/lib/daemon/self-update-service.js +353 -0
  51. package/dist/lib/daemon/state-dir-check-service.js +3 -3
  52. package/dist/lib/daemon/usage-sync-service.js +1 -1
  53. package/dist/lib/daemon/watchdog-service.js +4 -4
  54. package/dist/lib/daemon-services.d.ts +1 -1
  55. package/dist/lib/daemon-services.js +5 -0
  56. package/dist/lib/device-config.d.ts +12 -1
  57. package/dist/lib/device-config.js +63 -13
  58. package/dist/lib/exec-bounded.d.ts +52 -0
  59. package/dist/lib/exec-bounded.js +113 -0
  60. package/dist/lib/exec.d.ts +2 -2
  61. package/dist/lib/exec.js +3 -34
  62. package/dist/lib/feed/events.d.ts +22 -14
  63. package/dist/lib/feed/events.js +84 -44
  64. package/dist/lib/feed-broadcast.d.ts +12 -29
  65. package/dist/lib/feed-broadcast.js +28 -27
  66. package/dist/lib/fleet-shared-state.d.ts +12 -5
  67. package/dist/lib/fleet-shared-state.js +50 -20
  68. package/dist/lib/fs-atomic.d.ts +11 -0
  69. package/dist/lib/fs-atomic.js +60 -0
  70. package/dist/lib/hooks/install.js +0 -87
  71. package/dist/lib/hosts/reconcile.d.ts +11 -4
  72. package/dist/lib/hosts/reconcile.js +31 -5
  73. package/dist/lib/installations/strategies.js +1 -1
  74. package/dist/lib/mcp-registry.js +0 -13
  75. package/dist/lib/mcp.js +2 -2
  76. package/dist/lib/model-tiers.js +1 -1
  77. package/dist/lib/models.js +0 -63
  78. package/dist/lib/notify.d.ts +11 -0
  79. package/dist/lib/notify.js +17 -4
  80. package/dist/lib/owner-message.d.ts +46 -3
  81. package/dist/lib/owner-message.js +26 -6
  82. package/dist/lib/permissions-registry.d.ts +0 -2
  83. package/dist/lib/permissions-registry.js +3 -50
  84. package/dist/lib/permissions.d.ts +3 -17
  85. package/dist/lib/permissions.js +4 -73
  86. package/dist/lib/project-resources.d.ts +12 -0
  87. package/dist/lib/project-resources.js +129 -0
  88. package/dist/lib/routine-process-cleanup.d.ts +2 -2
  89. package/dist/lib/routine-process-cleanup.js +45 -34
  90. package/dist/lib/rush-session.d.ts +19 -0
  91. package/dist/lib/rush-session.js +24 -0
  92. package/dist/lib/secrets/drivers/rush.js +2 -1
  93. package/dist/lib/secrets/reaper.d.ts +2 -2
  94. package/dist/lib/secrets/reaper.js +13 -10
  95. package/dist/lib/secrets/reserved-sync.d.ts +1 -1
  96. package/dist/lib/secrets/reserved-sync.js +4 -4
  97. package/dist/lib/self-update.d.ts +21 -8
  98. package/dist/lib/self-update.js +54 -31
  99. package/dist/lib/session/cloud.js +2 -1
  100. package/dist/lib/session/sync/backend.d.ts +61 -0
  101. package/dist/lib/session/sync/backend.js +89 -0
  102. package/dist/lib/session/sync/managed-config.d.ts +29 -0
  103. package/dist/lib/session/sync/managed-config.js +23 -0
  104. package/dist/lib/session/sync/managed-key.d.ts +45 -0
  105. package/dist/lib/session/sync/managed-key.js +128 -0
  106. package/dist/lib/session/sync/net-client.d.ts +65 -0
  107. package/dist/lib/session/sync/net-client.js +117 -0
  108. package/dist/lib/session/sync/provision.d.ts +19 -0
  109. package/dist/lib/session/sync/provision.js +38 -0
  110. package/dist/lib/session/sync/r2.d.ts +5 -2
  111. package/dist/lib/session/sync/r2.js +5 -2
  112. package/dist/lib/session/sync/worker-template.d.ts +6 -0
  113. package/dist/lib/session/sync/worker-template.js +847 -0
  114. package/dist/lib/sink-format.d.ts +34 -0
  115. package/dist/lib/sink-format.js +17 -0
  116. package/dist/lib/staleness/detectors/permissions.js +0 -20
  117. package/dist/lib/staleness/writers/commands.js +1 -1
  118. package/dist/lib/staleness/writers/hooks.js +2 -2
  119. package/dist/lib/subagents-registry.js +2 -12
  120. package/dist/lib/subagents.d.ts +0 -10
  121. package/dist/lib/subagents.js +0 -37
  122. package/dist/lib/tmux/orphan-reap.js +6 -4
  123. package/dist/lib/tmux/session.js +4 -1
  124. package/dist/lib/traces/classify.d.ts +8 -1
  125. package/dist/lib/traces/insights.d.ts +13 -1
  126. package/dist/lib/traces/insights.js +78 -3
  127. package/dist/lib/traces/sync.js +8 -3
  128. package/dist/lib/traces/worker-template.js +9 -5
  129. package/dist/lib/types.d.ts +1 -1
  130. package/package.json +1 -1
@@ -2,7 +2,7 @@
2
2
  * Core agent configuration and detection module.
3
3
  *
4
4
  * Defines the canonical registry of current and legacy AI coding agents (Claude,
5
- * Codex, Gemini, Cursor, OpenCode, OpenClaw, Copilot, Amp, Kiro, Goose, Grok)
5
+ * Codex, Gemini, Cursor, OpenCode, OpenClaw, Copilot, Amp, Goose, Grok)
6
6
  * with their CLI commands, config paths, capability flags, and MCP integration
7
7
  * points.
8
8
  *
@@ -413,52 +413,6 @@ export const AGENTS = {
413
413
  supportsHooks: true,
414
414
  capabilities: { hooks: { since: '0.3.130' }, mcp: true, mcpHttp: false, mcpHeaders: false, allowlist: { since: '1.1.1' }, skills: true, commands: true, plugins: true, subagents: true, rules: { file: 'AGENTS.md' }, workflows: false, memory: false, modes: ['plan', 'edit'], interactiveRepl: true },
415
415
  },
416
- // Oh My Pi (`omp`, omp.sh) — a Bun-based, terminal-first coding agent that runs
417
- // against many model providers (OpenRouter, OpenAI, Anthropic, xAI, DeepSeek,
418
- // Ollama, LM Studio, …). It is Claude-compatible: it natively discovers
419
- // `.claude/commands`, `.mcp.json`, and Claude-shaped subagents, and keeps its
420
- // own native resources under `~/.omp/agent/` (config dir reported by
421
- // `omp config path`). configDir points AT the agent dir (not `~/.omp`) so the
422
- // rules file lands at `~/.omp/agent/AGENTS.md`, the user context file omp reads.
423
- pi: {
424
- id: 'pi',
425
- name: 'Pi',
426
- sessionDir: null,
427
- sessionFileExt: null,
428
- versionStdoutMatch: 'semver',
429
- unmanagedBinary: 'path',
430
- mcpRegister: 'cli',
431
- mcpAddHttp: 'transport',
432
- mcpAddStdio: 'simple',
433
- mcpConfigWrite: 'json-mcpServers',
434
- color: 'magenta',
435
- cliCommand: 'omp',
436
- npmPackage: '@oh-my-pi/pi-coding-agent',
437
- configDir: path.join(HOME, '.omp', 'agent'),
438
- commandsDir: path.join(HOME, '.omp', 'agent', 'commands'),
439
- commandsSubdir: 'commands',
440
- skillsDir: path.join(HOME, '.omp', 'agent', 'skills'),
441
- hooksDir: 'hooks',
442
- instructionsFile: 'AGENTS.md',
443
- format: 'markdown',
444
- variableSyntax: '$ARGUMENTS',
445
- // omp hooks are per-tool JS/TS extension modules discovered from
446
- // `~/.omp/agent/hooks/{pre,post}/<tool>.<ext>` (loaded as HookFactory code),
447
- // NOT the event->shell-command registrations agents-cli's hook sync writes.
448
- // The two models don't map, so hooks stay off (capabilities.hooks:false).
449
- supportsHooks: false,
450
- // MCP: omp reads `.mcp.json` with the Claude `{ "mcpServers": {...} }` schema
451
- // at user scope (`~/.omp/agent/.mcp.json`) and project scope (`<root>/.mcp.json`),
452
- // stdio + http + sse with headers. skills (`~/.omp/agent/skills/<name>/SKILL.md`),
453
- // commands (`~/.omp/agent/commands/*.md`), and subagents (`~/.omp/agent/agents/*.md`,
454
- // Claude-shaped) are all native. allowlist is OFF: omp gates approval per-TOOL
455
- // only (`tools.approval` record: allow|prompt|deny) with no command/path/domain
456
- // patterns, so agents-cli's granular permission format has nothing to map to.
457
- // plugins are npm packages / TS modules, not the Claude marketplace manifest.
458
- // interactiveRepl: true — bare `omp` runs the TUI; `omp -p` is the one-shot
459
- // form that answers a prompt and exits (upstream README, "Four entry points").
460
- capabilities: { hooks: false, mcp: true, mcpHttp: true, mcpHeaders: true, allowlist: false, skills: true, commands: true, plugins: false, subagents: true, rules: { file: 'AGENTS.md' }, workflows: false, memory: false, modes: ['plan', 'edit', 'skip'], interactiveRepl: true },
461
- },
462
416
  openclaw: {
463
417
  id: 'openclaw',
464
418
  name: 'OpenClaw',
@@ -560,39 +514,6 @@ export const AGENTS = {
560
514
  // interactiveRepl: false — amp requires a prompt; bare invocation exits immediately.
561
515
  capabilities: { hooks: false, mcp: true, mcpHttp: false, mcpHeaders: false, allowlist: false, skills: true, commands: true, plugins: false, subagents: false, rules: { file: 'AGENTS.md' }, workflows: false, memory: false, modes: ['plan', 'edit'], interactiveRepl: false },
562
516
  },
563
- kiro: {
564
- id: 'kiro',
565
- name: 'Kiro',
566
- sessionDir: null,
567
- sessionFileExt: null,
568
- versionStdoutMatch: 'semver',
569
- unmanagedBinary: 'path',
570
- mcpRegister: 'cli',
571
- mcpAddHttp: 'transport',
572
- mcpAddStdio: 'simple',
573
- mcpConfigWrite: 'json-mcpServers',
574
- color: 'greenBright',
575
- cliCommand: 'kiro-cli',
576
- npmPackage: '',
577
- installScript: 'brew install --cask kiro-cli',
578
- configDir: path.join(HOME, '.kiro'),
579
- commandsDir: path.join(HOME, '.kiro', 'commands'),
580
- commandsSubdir: 'commands',
581
- skillsDir: path.join(HOME, '.kiro', 'skills'),
582
- // Hooks: v3 standalone files under ~/.kiro/hooks/*.json
583
- // (`{ "version": "v1", "hooks": [...] }`). Fixed PreToolUse/PostToolUse
584
- // firing in kiro-cli 0.10; fully stable by 2.6.1. Launch always passes
585
- // --v3 (see AGENT_COMMANDS.kiro) so the standalone files actually load.
586
- // See registerHooksForKiro.
587
- hooksDir: 'hooks',
588
- instructionsFile: 'AGENTS.md',
589
- format: 'markdown',
590
- variableSyntax: '$ARGUMENTS',
591
- supportsHooks: true,
592
- // interactiveRepl: false — kiro-cli requires a prompt for coding sessions; bare
593
- // invocation does not open a persistent REPL.
594
- capabilities: { hooks: { since: '0.10.0' }, mcp: true, mcpHttp: false, mcpHeaders: false, allowlist: { since: '2.8.0' }, skills: true, commands: true, plugins: false, subagents: { since: '1.23.0' }, rules: { file: 'AGENTS.md' }, workflows: false, memory: false, modes: ['edit'], interactiveRepl: false },
595
- },
596
517
  goose: {
597
518
  id: 'goose',
598
519
  name: 'Goose',
@@ -1117,7 +1038,7 @@ export const MANAGED_AGENT_IDS = ALL_AGENT_IDS.filter((id) => !AGENTS[id].deprec
1117
1038
  * `curl … | sh` / `brew install` script that carries NO version token — the
1118
1039
  * installer can only ever fetch the *current* release, and the binary then keeps
1119
1040
  * itself up to date in place (droid, grok, antigravity, cursor, hermes,
1120
- * kiro, goose). There is no semver to pin, so agents-cli must not model these as
1041
+ * goose). There is no semver to pin, so agents-cli must not model these as
1121
1042
  * having multiple installable version-homes the way it does for npm-packaged
1122
1043
  * agents (claude, codex, kimi, …).
1123
1044
  *
@@ -3029,8 +2950,6 @@ export const AGENT_NAME_ALIASES = {
3029
2950
  gh: 'copilot',
3030
2951
  amp: 'amp',
3031
2952
  sourcegraph: 'amp',
3032
- kiro: 'kiro',
3033
- 'kiro-cli': 'kiro',
3034
2953
  goose: 'goose',
3035
2954
  'block-goose': 'goose',
3036
2955
  antigravity: 'antigravity',
@@ -3,6 +3,16 @@ import { BrowserService } from './service.js';
3
3
  import type { IPCRequest, IPCResponse } from './types.js';
4
4
  export interface IPCRequestOptions {
5
5
  autoStartDaemon?: boolean;
6
+ /**
7
+ * Opt-in client-side deadline in ms — most IPC actions (browser automation,
8
+ * long-running recordings) have no fixed budget and must NOT get one by
9
+ * default. Set explicitly for a request that genuinely has one, e.g.
10
+ * `request-self-update`, whose handler answers immediately (it kicks the
11
+ * install off in the background — see `triggerSelfUpdateInBackground`), so its
12
+ * client deadline (`DAEMON_SELF_UPDATE_TRIGGER_TIMEOUT_MS`) is a short
13
+ * "did the daemon accept the trigger?" bound, not a wait on the install.
14
+ */
15
+ timeoutMs?: number;
6
16
  }
7
17
  export declare class BrowserServiceNotRunningError extends Error {
8
18
  constructor();
@@ -148,6 +158,23 @@ export declare class BrowserIPCServer {
148
158
  * owns the shared process.
149
159
  */
150
160
  export declare function shouldRecommendDaemonRefresh(daemonVersion: string | undefined, clientVersion: string): boolean;
161
+ /**
162
+ * Reconcile the running daemon's version with ours without evicting it. If the
163
+ * daemon is serving stale code, ask the daemon to run its OWN self-update path
164
+ * (`request-self-update`, same fail-closed check/install/verify/exit
165
+ * SelfUpdateService's periodic tick runs, self-update-service.ts) rather than
166
+ * telling a human to run `agents daemon restart` by hand. A browser client
167
+ * still owns neither the shared supervisor nor its sibling services, so
168
+ * version skew can never be permission for THIS client to stop or restart
169
+ * that process directly (PHNX-3605 holds) — it only asks the daemon to run a
170
+ * path the daemon already runs on its own schedule, which only exits once the
171
+ * daemon itself has verified the new version is good. If self-update declines
172
+ * (dev build, shadowed install, already current, or a real failure) this
173
+ * degrades to the same "nothing changed" outcome PHNX-3605 shipped.
174
+ */
175
+ export declare function reconcileDaemonVersion(): Promise<void>;
176
+ /** Test-only: `reconcileDaemonVersion` runs at most once per real process — this clears that latch so a test file can drive more than one branch. */
177
+ export declare function resetVersionReconciliationForTest(): void;
151
178
  export declare function sendIPCRequest(request: IPCRequest, opts?: IPCRequestOptions): Promise<IPCResponse>;
152
179
  /**
153
180
  * Fill actor / launchId / sessionId from the calling process when the request
@@ -160,3 +187,10 @@ export declare function stampCallerIdentity(request: IPCRequest, env?: NodeJS.Pr
160
187
  * only the client process and IPC socket lifetime differ.
161
188
  */
162
189
  export declare function connectBrowserIPC(opts?: IPCRequestOptions): Promise<BrowserIPCConnection>;
190
+ /**
191
+ * Exported (rather than kept module-private) so `ipc.test.ts` can drive the
192
+ * `opts.timeoutMs` client-side deadline directly against a raw `net.Server`
193
+ * test double that never responds — the hermetic way to prove the timeout
194
+ * actually fires without needing a slow real IPC action to provoke it.
195
+ */
196
+ export declare function sendRawIPCRequest(request: IPCRequest, opts?: IPCRequestOptions): Promise<IPCResponse>;
@@ -7,6 +7,8 @@ import { resolveBrowserTaskIdleMs } from '../device-config.js';
7
7
  import { getDaemonLogPath, isDaemonRunning, signalDaemonReload, startDaemon, } from '../daemon/daemon.js';
8
8
  import { getCliVersion } from '../version.js';
9
9
  import { compareVersions } from '../agent-spec/primitives.js';
10
+ import { scheduleSelfUpdateExit, selfUpdateSyncDeclineReason, triggerSelfUpdateInBackground } from '../daemon/self-update-service.js';
11
+ import { log as daemonLog } from '../daemon/daemon.js';
10
12
  import { isDaemonServiceEnabled, queueDaemonServiceRestart, setDaemonServiceEnabled, } from '../daemon-services.js';
11
13
  import { isFleetRemoteInvocation } from './remote-control.js';
12
14
  import { resolveCallerIdentity } from './caller-identity.js';
@@ -82,6 +84,17 @@ function isSameSocket(socketPath, expected) {
82
84
  * coming near the grace window.
83
85
  */
84
86
  const IPC_CLOSE_TIMEOUT_MS = 1_500;
87
+ /**
88
+ * Client-side ceiling on the on-demand `request-self-update` call inside
89
+ * {@link reconcileDaemonVersion}. The daemon handler is DECOUPLED from the
90
+ * install — it kicks the self-update off in the background and answers
91
+ * immediately — so this only needs to be long enough to confirm the daemon
92
+ * ACCEPTED the trigger, never long enough to wait out a check/install/verify.
93
+ * It must stay short because a version-skewed browser verb routes through this
94
+ * reconcile on its way to the real request; a wedged or gone daemon degrades to
95
+ * the advisory PHNX-3605 shipped rather than stalling the verb.
96
+ */
97
+ const DAEMON_SELF_UPDATE_TRIGGER_TIMEOUT_MS = 5_000;
85
98
  /**
86
99
  * How long {@link waitForBrowserService} waits for the browser service to come up before
87
100
  * failing loud (PHNX-3289).
@@ -655,6 +668,44 @@ export class BrowserIPCServer {
655
668
  case 'version': {
656
669
  return { ok: true, version: getCliVersion() };
657
670
  }
671
+ // On-demand self-update, triggered by a client that noticed version
672
+ // skew (reconcileDaemonVersion below). DECOUPLED from the install
673
+ // (PHNX-3605): this handler MUST NOT await the check→download→install→
674
+ // verify, because it sits on the path of EVERY version-skewed
675
+ // `agents browser <verb>` (reconcileDaemonVersion → prepareIPC → here), so
676
+ // awaiting the whole thing would synchronously stall an ordinary browser
677
+ // verb for tens of seconds — worst case ~15 min — behind daemon
678
+ // housekeeping, exactly the client-stall PHNX-3605 exists to prevent.
679
+ // Instead: kick the self-update off in the BACKGROUND (bounded, and
680
+ // sharing the module-level in-flight guard so two triggers can't race a
681
+ // second install) and respond IMMEDIATELY that it was accepted. The daemon
682
+ // does install→verify→exit(0) on its own; the OS supervisor relaunches it
683
+ // and the browser reconnects. scheduleSelfUpdateExit (fired from the
684
+ // background continuation on a verified update) still delays the exit so
685
+ // this response has flushed to the socket first, and serializes against
686
+ // the periodic tick's own exit.
687
+ case 'request-self-update': {
688
+ // Instant, network-free decline gate FIRST (dev build / shadowed
689
+ // install), so a version-skewed browser client gets the PHNX-3605
690
+ // "nothing changed, not evicting" advisory immediately rather than a
691
+ // "triggered" it would never act on — reconcileDaemonVersion keys the
692
+ // advisory on !selfUpdateTriggered. Only a genuinely-eligible install is
693
+ // backgrounded below; the slow registry/download/install/verify never
694
+ // runs on this response path.
695
+ const declineReason = selfUpdateSyncDeclineReason();
696
+ if (declineReason) {
697
+ return { ok: true, selfUpdateTriggered: false, reason: declineReason };
698
+ }
699
+ void triggerSelfUpdateInBackground({ log: daemonLog })
700
+ .then((outcome) => {
701
+ if (outcome.updated)
702
+ scheduleSelfUpdateExit();
703
+ })
704
+ .catch((err) => {
705
+ daemonLog('ERROR', `self-update: background trigger failed: ${err instanceof Error ? err.message : String(err)}`);
706
+ });
707
+ return { ok: true, selfUpdateTriggered: true };
708
+ }
658
709
  case 'show': {
659
710
  // Task-less by design — see BrowserService.showUrl. Absent from both
660
711
  // PAGE_CREATE_VERBS and PAGE_RESOLVE_VERBS, so bindTask never ran above
@@ -707,7 +758,7 @@ export class BrowserIPCServer {
707
758
  // this box's own configured default (or 30 minutes when unset).
708
759
  const idleMs = request.idleMinutes !== undefined
709
760
  ? (request.idleMinutes === 0 ? null : request.idleMinutes * 60_000)
710
- : resolveBrowserTaskIdleMs();
761
+ : await resolveBrowserTaskIdleMs();
711
762
  const reaped = await this.service.reapAbandoned({ idleMs, dryRun: request.dryRun });
712
763
  return { ok: true, reaped };
713
764
  }
@@ -736,7 +787,15 @@ export class BrowserIPCServer {
736
787
  };
737
788
  }
738
789
  if (request.profile) {
739
- await this.service.stopProfile(request.profile);
790
+ try {
791
+ await this.service.stopProfile(request.profile, {
792
+ fleetRemote: request.fleetRemote,
793
+ actor: request.actor,
794
+ });
795
+ }
796
+ catch (err) {
797
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
798
+ }
740
799
  return { ok: true };
741
800
  }
742
801
  return {
@@ -1069,12 +1128,19 @@ export function shouldRecommendDaemonRefresh(daemonVersion, clientVersion) {
1069
1128
  }
1070
1129
  /**
1071
1130
  * Reconcile the running daemon's version with ours without evicting it. If the
1072
- * daemon is serving stale code, surface the deliberate operator command once
1073
- * and continue on the already-running service. A browser client owns neither
1074
- * the shared supervisor nor its sibling services, so version skew can never be
1075
- * permission to stop or restart that process (PHNX-3605).
1131
+ * daemon is serving stale code, ask the daemon to run its OWN self-update path
1132
+ * (`request-self-update`, same fail-closed check/install/verify/exit
1133
+ * SelfUpdateService's periodic tick runs, self-update-service.ts) rather than
1134
+ * telling a human to run `agents daemon restart` by hand. A browser client
1135
+ * still owns neither the shared supervisor nor its sibling services, so
1136
+ * version skew can never be permission for THIS client to stop or restart
1137
+ * that process directly (PHNX-3605 holds) — it only asks the daemon to run a
1138
+ * path the daemon already runs on its own schedule, which only exits once the
1139
+ * daemon itself has verified the new version is good. If self-update declines
1140
+ * (dev build, shadowed install, already current, or a real failure) this
1141
+ * degrades to the same "nothing changed" outcome PHNX-3605 shipped.
1076
1142
  */
1077
- async function reconcileDaemonVersion() {
1143
+ export async function reconcileDaemonVersion() {
1078
1144
  if (versionReconciledThisProcess)
1079
1145
  return;
1080
1146
  versionReconciledThisProcess = true;
@@ -1091,8 +1157,41 @@ async function reconcileDaemonVersion() {
1091
1157
  if (!shouldRecommendDaemonRefresh(daemon, client))
1092
1158
  return;
1093
1159
  process.stderr.write(`\nBrowser service is running on shared daemon ${daemon}, while this CLI is ${client}. `
1094
- + 'Continuing without evicting the daemon or its other services.\n'
1095
- + 'To load current daemon code when it is safe to interrupt every hosted service: agents daemon restart\n\n');
1160
+ + 'Asking the daemon to self-update in the background (verify-then-exit; the OS supervisor relaunches it onto the new code)...\n');
1161
+ try {
1162
+ // SHORT timeout — the handler is decoupled from the install now (it kicks
1163
+ // the self-update off in the background and answers at once), so this only
1164
+ // waits for the daemon to CONFIRM it accepted the trigger, not for the
1165
+ // install to finish. It must stay short so the real browser verb that
1166
+ // triggered this reconcile proceeds promptly; a wedged/gone daemon degrades
1167
+ // to the same advisory PHNX-3605 shipped.
1168
+ const resp = await sendRawIPCRequest({ action: 'request-self-update' }, { autoStartDaemon: false, timeoutMs: DAEMON_SELF_UPDATE_TRIGGER_TIMEOUT_MS });
1169
+ if (resp.selfUpdateTriggered) {
1170
+ process.stderr.write('Daemon self-update requested — it verifies a newer version in the background and restarts if one is available. '
1171
+ + 'Continuing without waiting.\n\n');
1172
+ }
1173
+ else {
1174
+ // Two shapes reach here: a CURRENT daemon that ran the instant decline
1175
+ // gate (dev build / shadowed install) and answered {selfUpdateTriggered:
1176
+ // false, reason}, and an OLDER daemon that predates the field entirely and
1177
+ // answered without it (no reason). Both degrade to the same non-evicting
1178
+ // advisory PHNX-3605 shipped; only the lead sentence differs.
1179
+ const declineDetail = resp.reason
1180
+ ? `Daemon self-update declined (${resp.reason}). `
1181
+ : 'Daemon did not accept the self-update request. ';
1182
+ process.stderr.write(declineDetail
1183
+ + 'Continuing without evicting the daemon or its other services. '
1184
+ + 'To load current daemon code when it is safe to interrupt every hosted service: agents daemon restart\n\n');
1185
+ }
1186
+ }
1187
+ catch (err) {
1188
+ process.stderr.write(`Could not reach the daemon to request a self-update (${err instanceof Error ? err.message : String(err)}). `
1189
+ + 'To load current daemon code when it is safe to interrupt every hosted service: agents daemon restart\n\n');
1190
+ }
1191
+ }
1192
+ /** Test-only: `reconcileDaemonVersion` runs at most once per real process — this clears that latch so a test file can drive more than one branch. */
1193
+ export function resetVersionReconciliationForTest() {
1194
+ versionReconciledThisProcess = false;
1096
1195
  }
1097
1196
  export async function sendIPCRequest(request, opts = {}) {
1098
1197
  // Stamp caller identity ONCE here so the 28+ call sites don't each have to.
@@ -1147,13 +1246,37 @@ export async function connectBrowserIPC(opts = {}) {
1147
1246
  });
1148
1247
  });
1149
1248
  }
1150
- async function sendRawIPCRequest(request, opts = {}) {
1249
+ /**
1250
+ * Exported (rather than kept module-private) so `ipc.test.ts` can drive the
1251
+ * `opts.timeoutMs` client-side deadline directly against a raw `net.Server`
1252
+ * test double that never responds — the hermetic way to prove the timeout
1253
+ * actually fires without needing a slow real IPC action to provoke it.
1254
+ */
1255
+ export async function sendRawIPCRequest(request, opts = {}) {
1151
1256
  const endpoint = getIpcEndpoint();
1152
1257
  const autoStartDaemon = opts.autoStartDaemon ?? true;
1153
1258
  await prepareIPC(request.action, opts);
1154
1259
  return new Promise((resolve, reject) => {
1155
1260
  const socket = net.createConnection(endpoint);
1156
1261
  let buffer = '';
1262
+ let settled = false;
1263
+ let deadline;
1264
+ const settle = (fn) => {
1265
+ if (settled)
1266
+ return;
1267
+ settled = true;
1268
+ if (deadline)
1269
+ clearTimeout(deadline);
1270
+ fn();
1271
+ };
1272
+ if (opts.timeoutMs) {
1273
+ deadline = setTimeout(() => {
1274
+ settle(() => {
1275
+ socket.destroy();
1276
+ reject(new Error(`IPC request '${request.action}' timed out after ${opts.timeoutMs}ms`));
1277
+ });
1278
+ }, opts.timeoutMs);
1279
+ }
1157
1280
  socket.on('connect', () => {
1158
1281
  socket.write(JSON.stringify(request) + '\n');
1159
1282
  });
@@ -1162,21 +1285,27 @@ async function sendRawIPCRequest(request, opts = {}) {
1162
1285
  const idx = buffer.indexOf('\n');
1163
1286
  if (idx !== -1) {
1164
1287
  const response = JSON.parse(buffer.slice(0, idx));
1165
- socket.end();
1166
- resolve(response);
1288
+ settle(() => {
1289
+ socket.end();
1290
+ resolve(response);
1291
+ });
1167
1292
  }
1168
1293
  });
1169
1294
  socket.on('error', (err) => {
1170
- if (!autoStartDaemon && (err.code === 'ENOENT' || err.code === 'ECONNREFUSED')) {
1171
- reject(new BrowserServiceNotRunningError());
1172
- return;
1173
- }
1174
- reject(new Error(`IPC error: ${err.message}`));
1295
+ settle(() => {
1296
+ if (!autoStartDaemon && (err.code === 'ENOENT' || err.code === 'ECONNREFUSED')) {
1297
+ reject(new BrowserServiceNotRunningError());
1298
+ return;
1299
+ }
1300
+ reject(new Error(`IPC error: ${err.message}`));
1301
+ });
1175
1302
  });
1176
1303
  socket.on('close', () => {
1177
- if (!buffer.includes('\n')) {
1178
- reject(new Error('Connection closed before response'));
1179
- }
1304
+ settle(() => {
1305
+ if (!buffer.includes('\n')) {
1306
+ reject(new Error('Connection closed before response'));
1307
+ }
1308
+ });
1180
1309
  });
1181
1310
  });
1182
1311
  }
@@ -16,12 +16,15 @@
16
16
  * The authoritative gate is {@link assertRemoteControlAllowedForRequest}, called
17
17
  * inside the browser daemon at the top of `resolveOrCreateTask` — the one
18
18
  * chokepoint every task-scoped verb resolves through — plus `BrowserService.start`
19
- * for the task-less `browser start` command. It has to live there because ~18
19
+ * for the task-less `browser start` command and `BrowserService.stopProfile` for
20
+ * the task-less `browser stop --profile` command. It has to live there because ~18
20
21
  * page verbs (`navigate`, `click`, `screenshot`, `tab-add`, …) launch OR attach
21
22
  * to a browser implicitly, and gating only the `browser start` command left every
22
23
  * one of them ungated; gating only the create branch of `resolveOrCreateTask` left
23
- * the attach paths ungated (RUSH-3064). {@link assertRemoteControlAllowed} remains
24
- * as a fast-fail CLI-side check so a refused `start` never auto-creates a profile.
24
+ * the attach paths ungated (RUSH-3064); `stop --profile` is handled by `bindTask`'s
25
+ * early return before `resolveOrCreateTask` ever runs, so it needed its own gate
26
+ * call too (PHNX-3317). {@link assertRemoteControlAllowed} remains as a fast-fail
27
+ * CLI-side check so a refused `start` never auto-creates a profile.
25
28
  */
26
29
  /** Env marker set on every remote `agents` invocation by `buildRemoteAgentsInvocation` and `markFleetRemote`. */
27
30
  export declare const FLEET_REMOTE_ENV = "AGENTS_FLEET_REMOTE";
@@ -16,12 +16,15 @@
16
16
  * The authoritative gate is {@link assertRemoteControlAllowedForRequest}, called
17
17
  * inside the browser daemon at the top of `resolveOrCreateTask` — the one
18
18
  * chokepoint every task-scoped verb resolves through — plus `BrowserService.start`
19
- * for the task-less `browser start` command. It has to live there because ~18
19
+ * for the task-less `browser start` command and `BrowserService.stopProfile` for
20
+ * the task-less `browser stop --profile` command. It has to live there because ~18
20
21
  * page verbs (`navigate`, `click`, `screenshot`, `tab-add`, …) launch OR attach
21
22
  * to a browser implicitly, and gating only the `browser start` command left every
22
23
  * one of them ungated; gating only the create branch of `resolveOrCreateTask` left
23
- * the attach paths ungated (RUSH-3064). {@link assertRemoteControlAllowed} remains
24
- * as a fast-fail CLI-side check so a refused `start` never auto-creates a profile.
24
+ * the attach paths ungated (RUSH-3064); `stop --profile` is handled by `bindTask`'s
25
+ * early return before `resolveOrCreateTask` ever runs, so it needed its own gate
26
+ * call too (PHNX-3317). {@link assertRemoteControlAllowed} remains as a fast-fail
27
+ * CLI-side check so a refused `start` never auto-creates a profile.
25
28
  */
26
29
  import { getConfigValue } from '../device-config.js';
27
30
  /** Env marker set on every remote `agents` invocation by `buildRemoteAgentsInvocation` and `markFleetRemote`. */
@@ -263,7 +263,10 @@ export declare class BrowserService {
263
263
  ok: boolean;
264
264
  profile?: string;
265
265
  }>;
266
- stopProfile(profileRef: ProfileName | ConnectionKey): Promise<void>;
266
+ stopProfile(profileRef: ProfileName | ConnectionKey, opts?: {
267
+ fleetRemote?: boolean;
268
+ actor?: string;
269
+ }): Promise<void>;
267
270
  navigate(taskId: string, url: string, profileRef?: ProfileName | ConnectionKey): Promise<{
268
271
  tabId: string;
269
272
  url: string;
@@ -773,7 +773,13 @@ export class BrowserService {
773
773
  async done(taskName) {
774
774
  return this.stop(taskName);
775
775
  }
776
- async stopProfile(profileRef) {
776
+ async stopProfile(profileRef, opts = {}) {
777
+ // Consent gate — this is a fleet-remote destructive path (kills the
778
+ // profile's browser process and clears its runtime dir) that reached the
779
+ // daemon without ever hitting resolveOrCreateTask's gate, since it is a
780
+ // task-less stop. Same per-request marker rule as every other gated verb —
781
+ // see remote-control.ts.
782
+ assertRemoteControlAllowedForRequest(opts.fleetRemote, { actor: opts.actor });
777
783
  // Connections are keyed by the runtime key `<profile>@<device>` (see
778
784
  // start()) while callers pass the bare profile name (or, occasionally, an
779
785
  // exact key). A plain `connections.get(profileRef)` therefore missed every
@@ -287,7 +287,7 @@ export interface ReapResult {
287
287
  /** Tasks left alone this pass: still live, still inside the idle window, or recording. */
288
288
  skipped: number;
289
289
  }
290
- export type IPCAction = 'start' | 'show' | 'gc' | 'record-start' | 'record-stop' | 'done' | 'stop' | 'status' | 'history' | 'navigate' | 'tab-add' | 'tab-focus' | 'tab-close' | 'tab-list' | 'evaluate' | 'screenshot' | 'pdf' | 'refs' | 'click' | 'type' | 'press' | 'hover' | 'scroll' | 'set-viewport' | 'set-device' | 'console' | 'errors' | 'requests' | 'response-body' | 'wait' | 'set-download-path' | 'wait-download' | 'upload' | 'getAppLogs' | 'version';
290
+ export type IPCAction = 'start' | 'show' | 'gc' | 'record-start' | 'record-stop' | 'done' | 'stop' | 'status' | 'history' | 'navigate' | 'tab-add' | 'tab-focus' | 'tab-close' | 'tab-list' | 'evaluate' | 'screenshot' | 'pdf' | 'refs' | 'click' | 'type' | 'press' | 'hover' | 'scroll' | 'set-viewport' | 'set-device' | 'console' | 'errors' | 'requests' | 'response-body' | 'wait' | 'set-download-path' | 'wait-download' | 'upload' | 'getAppLogs' | 'version' | 'request-self-update';
291
291
  export interface IPCRequest {
292
292
  action: IPCAction;
293
293
  task?: string;
@@ -425,6 +425,8 @@ export interface IPCResponse {
425
425
  uploadMode?: 'input' | 'drop' | 'chooser';
426
426
  appLogs?: any[];
427
427
  version?: string;
428
+ selfUpdateTriggered?: boolean;
429
+ reason?: string;
428
430
  skill?: {
429
431
  name: string;
430
432
  path: string;
@@ -33,6 +33,12 @@ export interface SendResult {
33
33
  attachments?: string[];
34
34
  /** Mailbox provider returns the enqueued message id. */
35
35
  msgId?: string;
36
+ /**
37
+ * The exact body handed to the provider for THIS destination. Set by the owner
38
+ * fan-out (`sendToOwner`) so a per-destination compose is observable — Slack
39
+ * carries the `mrkdwn` labeled-link variant, iMessage the plain one (PHNX-3698).
40
+ */
41
+ body?: string;
36
42
  /** Per-destination results when the owner policy selects multiple channels. */
37
43
  deliveries?: SendResult[];
38
44
  }
@@ -10,6 +10,7 @@
10
10
  */
11
11
  import type { Meta } from '../types.js';
12
12
  import type { SendResult } from './registry.js';
13
+ import type { SinkMessageFormat } from '../sink-format.js';
13
14
  /** Normalized delivery request after CLI/config resolution. */
14
15
  export interface SendEnvelope {
15
16
  text: string;
@@ -78,8 +79,16 @@ export declare function resolveSendEnvelope(input: ResolveSendInput, meta: Meta)
78
79
  * internal caller that already has a resolved envelope.
79
80
  */
80
81
  export declare function deliverEnvelope(envelope: SendEnvelope, meta: Meta): Promise<SendResult>;
81
- /** Resolve + deliver in one step (CLI happy path). */
82
- export declare function sendMessage(input: ResolveSendInput, meta: Meta): Promise<{
82
+ /**
83
+ * Resolve + deliver in one step (CLI happy path).
84
+ *
85
+ * `ownerCompose`, when given, shapes the body PER owner destination on the
86
+ * policy fan-out — Slack gets `mrkdwn` labeled links, iMessage stays plain
87
+ * (PHNX-3698). Only the owner-policy path uses it; a direct `--channel`/`--to`
88
+ * send is delivered verbatim. The envelope's own `text` remains the plain
89
+ * default (validation, `--json`, dry-run display).
90
+ */
91
+ export declare function sendMessage(input: ResolveSendInput, meta: Meta, ownerCompose?: (format: SinkMessageFormat) => string): Promise<{
83
92
  result: SendResult;
84
93
  envelope: SendEnvelope;
85
94
  } | {
@@ -108,8 +108,16 @@ export async function deliverEnvelope(envelope, meta) {
108
108
  dryRun: envelope.dryRun,
109
109
  });
110
110
  }
111
- /** Resolve + deliver in one step (CLI happy path). */
112
- export async function sendMessage(input, meta) {
111
+ /**
112
+ * Resolve + deliver in one step (CLI happy path).
113
+ *
114
+ * `ownerCompose`, when given, shapes the body PER owner destination on the
115
+ * policy fan-out — Slack gets `mrkdwn` labeled links, iMessage stays plain
116
+ * (PHNX-3698). Only the owner-policy path uses it; a direct `--channel`/`--to`
117
+ * send is delivered verbatim. The envelope's own `text` remains the plain
118
+ * default (validation, `--json`, dry-run display).
119
+ */
120
+ export async function sendMessage(input, meta, ownerCompose) {
113
121
  const resolved = resolveSendEnvelope(input, meta);
114
122
  if (!resolved.ok)
115
123
  return { error: resolved.error };
@@ -123,6 +131,7 @@ export async function sendMessage(input, meta) {
123
131
  thread: resolved.envelope.thread,
124
132
  attachments: resolved.envelope.attachments,
125
133
  from: resolved.envelope.from,
134
+ ...(ownerCompose ? { composeForFormat: ownerCompose } : {}),
126
135
  })
127
136
  : await deliverEnvelope(resolved.envelope, meta);
128
137
  return { result, envelope: resolved.envelope };
@@ -8,10 +8,18 @@ import type { CloudProvider, CloudTask, CloudTaskStatus, CloudEvent, DispatchOpt
8
8
  /**
9
9
  * Returns true when ~/.rush/user.yaml exists, carries an access_token, and
10
10
  * the token has not passed its expires_at timestamp (Unix seconds). A missing
11
- * expires_at is treated as non-expired so tokens written without an expiry
12
- * still work. Pass yamlPath to override the default path in tests.
11
+ * expires_at, or `expires_at: 0` (a non-expiring Phoenix `pid_` bearer), is
12
+ * treated as non-expired (see isRushSessionExpired, PHNX-3645). Pass yamlPath
13
+ * to override the default path in tests.
13
14
  */
14
15
  export declare function isRushSessionValid(yamlPath?: string): boolean;
16
+ /**
17
+ * Read the Rush session access token from ~/.rush/user.yaml. Exported (with an
18
+ * overridable yamlPath, like isRushSessionValid) so the freshness behavior —
19
+ * including the `expires_at: 0` non-expiring case (PHNX-3645) — is directly
20
+ * testable; the class methods call it with the default path.
21
+ */
22
+ export declare function readToken(yamlPath?: string): string;
15
23
  /** One version's entry in the account manifest sent on every dispatch. */
16
24
  export interface AccountManifestEntry {
17
25
  version: string;