@phnx-labs/agents-cli 1.22.68 → 1.22.70

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 (132) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +17 -14
  3. package/dist/bootstrap.js +3 -0
  4. package/dist/commands/accounts.js +23 -7
  5. package/dist/commands/exec.js +33 -23
  6. package/dist/commands/feed.js +20 -7
  7. package/dist/commands/import.js +2 -2
  8. package/dist/commands/models.js +2 -2
  9. package/dist/commands/monitors.js +3 -0
  10. package/dist/commands/permissions.js +2 -2
  11. package/dist/commands/projects.d.ts +26 -6
  12. package/dist/commands/projects.js +55 -22
  13. package/dist/commands/rules.js +1 -1
  14. package/dist/commands/send.js +35 -2
  15. package/dist/commands/sessions-inject.d.ts +58 -0
  16. package/dist/commands/sessions-inject.js +143 -7
  17. package/dist/commands/sessions-picker.js +1 -0
  18. package/dist/commands/share.js +43 -14
  19. package/dist/commands/ssh.js +205 -2
  20. package/dist/commands/traces.js +1 -1
  21. package/dist/lib/account-capabilities.js +0 -2
  22. package/dist/lib/account-registry.d.ts +3 -3
  23. package/dist/lib/account-registry.js +25 -7
  24. package/dist/lib/accounting/usage.d.ts +7 -2
  25. package/dist/lib/accounting/usage.js +142 -10
  26. package/dist/lib/acp/client.d.ts +1 -1
  27. package/dist/lib/acp/client.js +12 -1
  28. package/dist/lib/acp/harnesses.js +1 -1
  29. package/dist/lib/add-dir.js +0 -2
  30. package/dist/lib/agent-cli-commands.js +0 -2
  31. package/dist/lib/agent-spec/agents.d.ts +1 -1
  32. package/dist/lib/agent-spec/agents.js +2 -83
  33. package/dist/lib/boot-profile.d.ts +14 -0
  34. package/dist/lib/boot-profile.js +66 -0
  35. package/dist/lib/browser/ipc.js +9 -1
  36. package/dist/lib/browser/remote-control.d.ts +6 -3
  37. package/dist/lib/browser/remote-control.js +6 -3
  38. package/dist/lib/browser/service.d.ts +4 -1
  39. package/dist/lib/browser/service.js +7 -1
  40. package/dist/lib/channels/providers/desktop.d.ts +5 -4
  41. package/dist/lib/channels/providers/desktop.js +5 -4
  42. package/dist/lib/channels/registry.d.ts +6 -0
  43. package/dist/lib/channels/send.d.ts +11 -2
  44. package/dist/lib/channels/send.js +11 -2
  45. package/dist/lib/claude-account-token.js +108 -4
  46. package/dist/lib/cloud/rush.d.ts +10 -2
  47. package/dist/lib/cloud/rush.js +15 -9
  48. package/dist/lib/daemon/daemon.js +28 -8
  49. package/dist/lib/daemon/runner.js +6 -2
  50. package/dist/lib/devices/health.d.ts +38 -2
  51. package/dist/lib/devices/health.js +43 -5
  52. package/dist/lib/devices/worker-pick.d.ts +1 -1
  53. package/dist/lib/devices/worker-pick.js +4 -1
  54. package/dist/lib/exec.d.ts +2 -2
  55. package/dist/lib/exec.js +7 -34
  56. package/dist/lib/feed-broadcast.d.ts +47 -5
  57. package/dist/lib/feed-broadcast.js +127 -24
  58. package/dist/lib/hooks/install.js +0 -87
  59. package/dist/lib/installations/strategies.js +1 -1
  60. package/dist/lib/mcp-registry.js +0 -13
  61. package/dist/lib/mcp.js +2 -2
  62. package/dist/lib/model-tiers.js +1 -1
  63. package/dist/lib/models.js +0 -63
  64. package/dist/lib/monitors/engine.js +18 -0
  65. package/dist/lib/monitors/sources/command.js +13 -3
  66. package/dist/lib/monitors/sources/failure.d.ts +32 -0
  67. package/dist/lib/monitors/sources/failure.js +52 -0
  68. package/dist/lib/monitors/sources/types.d.ts +9 -0
  69. package/dist/lib/notify.d.ts +11 -0
  70. package/dist/lib/notify.js +17 -4
  71. package/dist/lib/owner-message.d.ts +55 -0
  72. package/dist/lib/owner-message.js +64 -0
  73. package/dist/lib/permissions-registry.d.ts +0 -2
  74. package/dist/lib/permissions-registry.js +3 -50
  75. package/dist/lib/permissions.d.ts +3 -17
  76. package/dist/lib/permissions.js +4 -73
  77. package/dist/lib/run-trace-sync.d.ts +15 -0
  78. package/dist/lib/run-trace-sync.js +43 -21
  79. package/dist/lib/rush-session.d.ts +19 -0
  80. package/dist/lib/rush-session.js +24 -0
  81. package/dist/lib/secrets/drivers/rush.js +2 -1
  82. package/dist/lib/secrets/filestore.d.ts +4 -0
  83. package/dist/lib/secrets/filestore.js +164 -3
  84. package/dist/lib/session/active.d.ts +10 -0
  85. package/dist/lib/session/active.js +3 -0
  86. package/dist/lib/session/cloud.js +2 -1
  87. package/dist/lib/session/db.d.ts +12 -1
  88. package/dist/lib/session/db.js +20 -1
  89. package/dist/lib/session/discover.js +81 -1
  90. package/dist/lib/session/linear.d.ts +13 -0
  91. package/dist/lib/session/linear.js +44 -0
  92. package/dist/lib/session/live-metadata.js +1 -0
  93. package/dist/lib/session/parse.js +2 -3
  94. package/dist/lib/session/prompt.d.ts +7 -1
  95. package/dist/lib/session/prompt.js +12 -2
  96. package/dist/lib/session/recovery.d.ts +21 -12
  97. package/dist/lib/session/recovery.js +29 -11
  98. package/dist/lib/session/remote/watch.js +5 -2
  99. package/dist/lib/session/state.js +11 -13
  100. package/dist/lib/share/backend.d.ts +2 -2
  101. package/dist/lib/share/backend.js +20 -9
  102. package/dist/lib/share/delete.d.ts +5 -1
  103. package/dist/lib/share/delete.js +7 -2
  104. package/dist/lib/share/http-error.d.ts +52 -0
  105. package/dist/lib/share/http-error.js +65 -0
  106. package/dist/lib/share/publish.d.ts +13 -3
  107. package/dist/lib/share/publish.js +19 -15
  108. package/dist/lib/share/worker-template.js +5 -1
  109. package/dist/lib/sink-format.d.ts +34 -0
  110. package/dist/lib/sink-format.js +17 -0
  111. package/dist/lib/smart-launch.js +27 -4
  112. package/dist/lib/staleness/detectors/permissions.js +0 -20
  113. package/dist/lib/staleness/writers/commands.js +1 -1
  114. package/dist/lib/staleness/writers/hooks.js +2 -2
  115. package/dist/lib/storage/index.d.ts +14 -0
  116. package/dist/lib/storage/index.js +14 -0
  117. package/dist/lib/storage/selection.d.ts +48 -0
  118. package/dist/lib/storage/selection.js +39 -0
  119. package/dist/lib/storage/visibility.d.ts +82 -0
  120. package/dist/lib/storage/visibility.js +99 -0
  121. package/dist/lib/subagents-registry.js +2 -12
  122. package/dist/lib/subagents.d.ts +0 -10
  123. package/dist/lib/subagents.js +0 -37
  124. package/dist/lib/teams/agents.js +3 -1
  125. package/dist/lib/teams/placement-probe.js +1 -0
  126. package/dist/lib/teams/scheduler.d.ts +8 -1
  127. package/dist/lib/teams/scheduler.js +4 -1
  128. package/dist/lib/traces/backend.js +13 -2
  129. package/dist/lib/types.d.ts +1 -1
  130. package/dist/lib/worktree/held.d.ts +166 -0
  131. package/dist/lib/worktree/held.js +368 -0
  132. package/package.json +2 -2
@@ -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',
@@ -0,0 +1,14 @@
1
+ /** True when `AGENTS_PROFILE_BOOT` is set — callers can skip building label strings. */
2
+ export declare function bootProfileEnabled(): boolean;
3
+ /**
4
+ * Record a named stage boundary. No-op unless `AGENTS_PROFILE_BOOT` is set, so
5
+ * this is free to call unconditionally on the launch path.
6
+ */
7
+ export declare function bootMark(label: string): void;
8
+ /**
9
+ * Print the collected timeline to stderr, once. Called right before the harness
10
+ * child is spawned (the end of the pre-exec window) and again as an
11
+ * `process.on('exit')` backstop for launch paths that error out before spawn.
12
+ * `reason` labels the final boundary (e.g. `spawn`, `exit`).
13
+ */
14
+ export declare function flushBootProfile(reason: string): void;
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Boot-time profiler for the `agents run` pre-exec phase (PHNX-3585).
3
+ *
4
+ * The AGI EXT "New Claude" boot spends the whole `agents run` wrapper cost
5
+ * BEFORE the harness prints anything — version resolution, account rotation,
6
+ * config sync, login preflight. This module makes that window measurable
7
+ * without a debugger: gate it on `AGENTS_PROFILE_BOOT=1` and the run path
8
+ * stamps named marks, then flushes a per-stage timeline to stderr the instant
9
+ * before the child is spawned (the moment the wrapper's work ends).
10
+ *
11
+ * All marks are `performance.now()` values, which Node measures from
12
+ * `performance.timeOrigin` ≈ process start — so a mark's absolute value is
13
+ * "ms since the `agents` process began", and consecutive marks give the cost
14
+ * of each stage. When the env flag is off, every function here is a couple of
15
+ * cheap branches and pushes nothing, so it is safe to leave wired on the hot
16
+ * path (the committed `scripts/bench-boot.sh` benchmark drives it).
17
+ */
18
+ import { performance } from 'node:perf_hooks';
19
+ const ENABLED = process.env.AGENTS_PROFILE_BOOT === '1' || process.env.AGENTS_PROFILE_BOOT === 'true';
20
+ const marks = [];
21
+ let flushed = false;
22
+ /** True when `AGENTS_PROFILE_BOOT` is set — callers can skip building label strings. */
23
+ export function bootProfileEnabled() {
24
+ return ENABLED;
25
+ }
26
+ /**
27
+ * Record a named stage boundary. No-op unless `AGENTS_PROFILE_BOOT` is set, so
28
+ * this is free to call unconditionally on the launch path.
29
+ */
30
+ export function bootMark(label) {
31
+ if (!ENABLED)
32
+ return;
33
+ marks.push({ label, at: performance.now() });
34
+ }
35
+ /**
36
+ * Print the collected timeline to stderr, once. Called right before the harness
37
+ * child is spawned (the end of the pre-exec window) and again as an
38
+ * `process.on('exit')` backstop for launch paths that error out before spawn.
39
+ * `reason` labels the final boundary (e.g. `spawn`, `exit`).
40
+ */
41
+ export function flushBootProfile(reason) {
42
+ if (!ENABLED || flushed)
43
+ return;
44
+ flushed = true;
45
+ bootMark(reason);
46
+ if (marks.length === 0)
47
+ return;
48
+ const start = 0; // process start
49
+ const end = marks[marks.length - 1].at;
50
+ const width = Math.max(...marks.map((m) => m.label.length));
51
+ const lines = [];
52
+ lines.push(`[boot-profile] pre-exec timeline (total ${(end - start).toFixed(1)}ms since process start)`);
53
+ let prev = start;
54
+ for (const m of marks) {
55
+ const delta = m.at - prev;
56
+ prev = m.at;
57
+ lines.push(` ${m.label.padEnd(width)} +${delta.toFixed(1).padStart(7)}ms @${m.at.toFixed(1).padStart(8)}ms`);
58
+ }
59
+ process.stderr.write(lines.join('\n') + '\n');
60
+ }
61
+ // Backstop: a run that exits before reaching the spawn (a login dead-end, a
62
+ // missing install) still emits whatever stages it reached, so the profile is
63
+ // never silently empty.
64
+ if (ENABLED) {
65
+ process.on('exit', () => flushBootProfile('exit'));
66
+ }
@@ -736,7 +736,15 @@ export class BrowserIPCServer {
736
736
  };
737
737
  }
738
738
  if (request.profile) {
739
- await this.service.stopProfile(request.profile);
739
+ try {
740
+ await this.service.stopProfile(request.profile, {
741
+ fleetRemote: request.fleetRemote,
742
+ actor: request.actor,
743
+ });
744
+ }
745
+ catch (err) {
746
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
747
+ }
740
748
  return { ok: true };
741
749
  }
742
750
  return {
@@ -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
@@ -2,10 +2,11 @@ import type { ChannelProvider } from '../registry.js';
2
2
  /**
3
3
  * Split one message into the notification's title and body.
4
4
  *
5
- * A broadcast sink hands us `composeBroadcastMessage`'s shape — `<project> · <text>`
6
- * with any link on a second line — so honouring the newline puts the human
7
- * sentence in the title and the URL underneath, which is how the existing
8
- * daemon notifications already read.
5
+ * A broadcast sink hands us `composeBroadcastMessage`'s shape — a title line, a
6
+ * blank line, then the body (and a `Sent from …` footer) — so honouring the first
7
+ * newline puts the scannable head in the title and the rest in the body, which is
8
+ * how the existing daemon notifications already read. (Desktop is a plain sink, so
9
+ * the message carries no URLs to split off.)
9
10
  *
10
11
  * Notification banners show roughly two lines before an ellipsis, so a long
11
12
  * single-line message is split at the title boundary rather than truncated away:
@@ -28,10 +28,11 @@ const TITLE_MAX = 64;
28
28
  /**
29
29
  * Split one message into the notification's title and body.
30
30
  *
31
- * A broadcast sink hands us `composeBroadcastMessage`'s shape — `<project> · <text>`
32
- * with any link on a second line — so honouring the newline puts the human
33
- * sentence in the title and the URL underneath, which is how the existing
34
- * daemon notifications already read.
31
+ * A broadcast sink hands us `composeBroadcastMessage`'s shape — a title line, a
32
+ * blank line, then the body (and a `Sent from …` footer) — so honouring the first
33
+ * newline puts the scannable head in the title and the rest in the body, which is
34
+ * how the existing daemon notifications already read. (Desktop is a plain sink, so
35
+ * the message carries no URLs to split off.)
35
36
  *
36
37
  * Notification banners show roughly two lines before an ellipsis, so a long
37
38
  * single-line message is split at the title boundary rather than truncated away:
@@ -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 };
@@ -79,11 +79,100 @@ export function resolveClaudeSetupToken(home) {
79
79
  // Require a known account (email) up front: without it we cannot key a
80
80
  // per-account token, and we must NOT fall back to a bare shared key that
81
81
  // would misapply one account's setup-token to another.
82
- const email = readClaudeAccountEmail(home);
82
+ const email = readClaudeAccountEmail(home)
83
+ // Self-heal (PHNX-3660): a home provisioned before seed-on-attach carries an
84
+ // `.oauth_token` but no identity. Recover the email from the bundle and
85
+ // write it back, so the home converges instead of needing a re-attach.
86
+ // Explicit-home only: with no home the probe targets the operator's real
87
+ // ~/.claude.json, which a library read must never rewrite.
88
+ ?? (home ? discoverClaudeAccountEmailFromOauthToken(home) : null);
83
89
  if (!email)
84
90
  return null;
85
91
  return resolveClaudeSetupTokenForEmail(email, home ?? os.homedir());
86
92
  }
93
+ /** Negative/positive discovery cache, keyed by home + .oauth_token fingerprint (SHOULD-2). */
94
+ const discoveryCache = new Map();
95
+ /**
96
+ * Recover a home's account email from its `.claude/.oauth_token` by matching
97
+ * the token VALUE against the `auth` bundle (the slug encodes the email, and
98
+ * the re-encode check makes the decode lossless or fail). On a match the
99
+ * identity is written back via {@link seedClaudeWorkerHomeIdentity}, so this
100
+ * runs at most once per home. Returns null — and writes nothing — when the
101
+ * file is missing, malformed, or matches no bundle key: a token that no longer
102
+ * exists in the bundle must not resurrect an account mapping. A no-match
103
+ * result is cached against the token file's fingerprint so a rotated-out home
104
+ * does not re-decrypt the bundle on every probe.
105
+ */
106
+ function discoverClaudeAccountEmailFromOauthToken(home) {
107
+ try {
108
+ const tokenPath = path.join(home, '.claude', '.oauth_token');
109
+ const fingerprint = credentialFingerprint(tokenPath);
110
+ if (fingerprint === 'missing')
111
+ return null;
112
+ const cached = discoveryCache.get(home);
113
+ if (cached && cached.fingerprint === fingerprint)
114
+ return cached.email;
115
+ const email = discoverEmailUncached(home, tokenPath);
116
+ discoveryCache.set(home, { fingerprint, email });
117
+ return email;
118
+ }
119
+ catch (err) {
120
+ if (err instanceof ReservedBundleWrongBackendError)
121
+ throw err;
122
+ return null;
123
+ }
124
+ }
125
+ function discoverEmailUncached(home, tokenPath) {
126
+ let token;
127
+ try {
128
+ token = fs.readFileSync(tokenPath, 'utf-8').trim();
129
+ }
130
+ catch {
131
+ return null;
132
+ }
133
+ if (!isValidClaudeSetupToken(token))
134
+ return null;
135
+ if (!bundleExists(AUTH_BUNDLE))
136
+ return null;
137
+ if (bundleBackend(AUTH_BUNDLE) !== 'file') {
138
+ throw new ReservedBundleWrongBackendError(AUTH_BUNDLE, bundleBackend(AUTH_BUNDLE));
139
+ }
140
+ const { env } = readAndResolveBundleEnv(AUTH_BUNDLE, { caller: 'usage', agentOnly: true });
141
+ for (const [key, value] of Object.entries(env)) {
142
+ if (value.trim() !== token)
143
+ continue;
144
+ const email = emailFromTokenKey(key);
145
+ if (!email)
146
+ continue;
147
+ seedClaudeWorkerHomeIdentity(home, email);
148
+ return email;
149
+ }
150
+ return null;
151
+ }
152
+ /**
153
+ * Decode the email a `CLAUDE_CODE_OAUTH_TOKEN_<slug>` key encodes — or null
154
+ * when the decode is ambiguous. The mapping collapses every non-alphanumeric
155
+ * to `_`, so a `_` surviving in the decoded address cannot be told apart from
156
+ * a folded `.`/`+`/etc. (`FIRST_DOT_LAST_AT_GMAIL_DOT_COM` decodes to
157
+ * `first_dot_last@gmail.com`, which round-trips — wrongly — under the bare
158
+ * re-encode check; review BLOCKER 1). Only a fully unambiguous decode — local
159
+ * and domain pure `[a-z0-9]`, dots in the domain only — is trusted.
160
+ */
161
+ function emailFromTokenKey(key) {
162
+ const prefix = 'CLAUDE_CODE_OAUTH_TOKEN_';
163
+ if (!key.startsWith(prefix))
164
+ return null;
165
+ const slug = key.slice(prefix.length);
166
+ const [local, domain, ...rest] = slug.split('_AT_');
167
+ if (!local || !domain || rest.length > 0)
168
+ return null;
169
+ const email = `${local}@${domain.replace(/_DOT_/g, '.')}`.toLowerCase();
170
+ if (!/^[a-z0-9]+@[a-z0-9.]+$/.test(email))
171
+ return null;
172
+ if (claudeAccountTokenKey(email) !== key)
173
+ return null;
174
+ return email;
175
+ }
87
176
  /**
88
177
  * Resolve a long-lived setup-token for an EXPLICIT account email, independent of
89
178
  * any version home's `.claude.json`. This is what lets `agents accounts attach`
@@ -167,14 +256,29 @@ export function seedClaudeWorkerHomeIdentity(versionHome, email) {
167
256
  try {
168
257
  doc = JSON.parse(fs.readFileSync(p, 'utf-8'));
169
258
  }
170
- catch {
171
- // Missing or unreadable at this location — write a fresh minimal document.
259
+ catch (err) {
260
+ // Missing file → write a fresh minimal document. A file that EXISTS but
261
+ // does not parse is being concurrently rewritten by Claude Code itself —
262
+ // skip it rather than overwrite a live config with the minimal doc.
263
+ if (err.code !== 'ENOENT')
264
+ continue;
172
265
  }
173
266
  const existing = (doc.oauthAccount && typeof doc.oauthAccount === 'object'
174
267
  ? doc.oauthAccount
175
268
  : {});
176
269
  doc.oauthAccount = { ...existing, emailAddress: trimmed };
177
270
  fs.mkdirSync(path.dirname(p), { recursive: true });
178
- fs.writeFileSync(p, JSON.stringify(doc));
271
+ // Temp-write + rename: a reader mid-write never sees a truncated doc.
272
+ const tmp = `${p}.agents-${process.pid}.tmp`;
273
+ try {
274
+ fs.writeFileSync(tmp, JSON.stringify(doc));
275
+ fs.renameSync(tmp, p);
276
+ }
277
+ catch {
278
+ try {
279
+ fs.rmSync(tmp, { force: true });
280
+ }
281
+ catch { /* best effort */ }
282
+ }
179
283
  }
180
284
  }
@@ -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;
@@ -10,6 +10,7 @@ import * as os from 'os';
10
10
  import * as crypto from 'crypto';
11
11
  import * as yaml from 'yaml';
12
12
  import { resolveDispatchRepos, normalizeProviderStatus, MAX_IMAGES_PER_DISPATCH } from './types.js';
13
+ import { isRushSessionExpired } from '../rush-session.js';
13
14
  import { parseSSE } from './stream.js';
14
15
  import { listInstalledVersions, getVersionHomePath } from '../installations/versions.js';
15
16
  import { getAccountInfo } from '../agents.js';
@@ -19,8 +20,9 @@ const USER_YAML = path.join(os.homedir(), '.rush', 'user.yaml');
19
20
  /**
20
21
  * Returns true when ~/.rush/user.yaml exists, carries an access_token, and
21
22
  * the token has not passed its expires_at timestamp (Unix seconds). A missing
22
- * expires_at is treated as non-expired so tokens written without an expiry
23
- * still work. Pass yamlPath to override the default path in tests.
23
+ * expires_at, or `expires_at: 0` (a non-expiring Phoenix `pid_` bearer), is
24
+ * treated as non-expired (see isRushSessionExpired, PHNX-3645). Pass yamlPath
25
+ * to override the default path in tests.
24
26
  */
25
27
  export function isRushSessionValid(yamlPath = USER_YAML) {
26
28
  try {
@@ -30,8 +32,7 @@ export function isRushSessionValid(yamlPath = USER_YAML) {
30
32
  const data = yaml.parse(raw);
31
33
  if (!data?.session?.access_token)
32
34
  return false;
33
- const expiresAt = data.session.expires_at;
34
- if (typeof expiresAt === 'number' && expiresAt <= Date.now() / 1000)
35
+ if (isRushSessionExpired(data.session.expires_at))
35
36
  return false;
36
37
  return true;
37
38
  }
@@ -39,19 +40,24 @@ export function isRushSessionValid(yamlPath = USER_YAML) {
39
40
  return false;
40
41
  }
41
42
  }
42
- /** Read the Rush session access token from ~/.rush/user.yaml. */
43
- function readToken() {
44
- if (!fs.existsSync(USER_YAML)) {
43
+ /**
44
+ * Read the Rush session access token from ~/.rush/user.yaml. Exported (with an
45
+ * overridable yamlPath, like isRushSessionValid) so the freshness behavior —
46
+ * including the `expires_at: 0` non-expiring case (PHNX-3645) — is directly
47
+ * testable; the class methods call it with the default path.
48
+ */
49
+ export function readToken(yamlPath = USER_YAML) {
50
+ if (!fs.existsSync(yamlPath)) {
45
51
  throw new Error('Not logged in to Rush. Run `rush login` first.');
46
52
  }
47
- const raw = fs.readFileSync(USER_YAML, 'utf-8');
53
+ const raw = fs.readFileSync(yamlPath, 'utf-8');
48
54
  const data = yaml.parse(raw);
49
55
  const token = data?.session?.access_token;
50
56
  if (!token) {
51
57
  throw new Error('No session token in ~/.rush/user.yaml. Run `rush login` first.');
52
58
  }
53
59
  const expiresAt = data.session?.expires_at;
54
- if (typeof expiresAt === 'number' && expiresAt <= Date.now() / 1000) {
60
+ if (isRushSessionExpired(expiresAt)) {
55
61
  const expiredAt = new Date(expiresAt * 1000).toISOString();
56
62
  throw new Error(`Rush session expired at ${expiredAt}. Run \`rush login\` to refresh.`);
57
63
  }