@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
@@ -2249,7 +2249,15 @@ function stopDaemonLocked() {
2249
2249
  // any service-manager teardown or direct signal and repairs only under lock.
2250
2250
  const pid = resolveLiveDaemonPid(true);
2251
2251
  const browserSock = process.platform === 'win32' ? null : getBrowserIpcSocketPath();
2252
- const browserSockOwner = pid !== null && browserSock ? readPathIdentity(browserSock) : null;
2252
+ // Capture the socket's inode INDEPENDENTLY of whether a live pid resolved
2253
+ // (PHNX-3618). A daemon that died between the CLI liveness precheck and this
2254
+ // locked read leaves resolveLiveDaemonPid() returning null while its ungraceful
2255
+ // exit left the binding on disk — capturing only when `pid !== null` meant that
2256
+ // genuinely stale socket could never be reclaimed and was reported "ownership
2257
+ // could not be verified". The inode is the successor guard: a fresh daemon that
2258
+ // rebinds during the stop gets a new inode, so a later identity match still
2259
+ // proves this exact binding is the one we captured, never a successor's.
2260
+ const browserSockOwner = browserSock ? readPathIdentity(browserSock) : null;
2253
2261
  if (platform === 'darwin') {
2254
2262
  const plistPath = getLaunchdPlistPath();
2255
2263
  if (fs.existsSync(plistPath)) {
@@ -2380,8 +2388,19 @@ function stopDaemonLocked() {
2380
2388
  // ungracefully (killTree) and left a stale binding — the owner is provably
2381
2389
  // dead, so reclaim it and report.
2382
2390
  if (process.platform !== 'win32') {
2391
+ // A binding is provably stale — and reclaimable — when the inode on disk is
2392
+ // still the exact one captured under this lock AND no daemon (the signalled
2393
+ // target OR any successor for this state dir) survives to own it. Keying the
2394
+ // "a daemon still owns it" test on the whole survivor set rather than only the
2395
+ // captured target is what lets a daemon that died BEFORE this stop (pid ===
2396
+ // null, PHNX-3618) still have its stale socket reclaimed, while a live
2397
+ // successor — which either rebound the inode or shows up as a survivor — is
2398
+ // never touched (PHNX-3607's never-delete-a-successor invariant).
2399
+ const aDaemonSurvives = survivors.length > 0 || unverifiedSurvivors.length > 0;
2383
2400
  if (browserSock && fs.existsSync(browserSock)) {
2384
- if (browserSockOwner && pathIdentityMatches(browserSock, browserSockOwner) && !targetStillOwnsResources) {
2401
+ const ownsCapturedBinding = browserSockOwner !== null
2402
+ && pathIdentityMatches(browserSock, browserSockOwner);
2403
+ if (ownsCapturedBinding && !aDaemonSurvives) {
2385
2404
  try {
2386
2405
  fs.unlinkSync(browserSock);
2387
2406
  }
@@ -2393,13 +2412,14 @@ function stopDaemonLocked() {
2393
2412
  else
2394
2413
  released.push('browser IPC socket (reclaimed)');
2395
2414
  }
2415
+ else if (aDaemonSurvives) {
2416
+ surviving.push('browser IPC socket still owned by a surviving daemon');
2417
+ }
2396
2418
  else {
2397
- // The path was absent when the target was captured, there was no proven
2398
- // daemon target, or a successor replaced the inode. In every case this
2399
- // invocation does not own the current binding and must leave it alone.
2400
- surviving.push(targetStillOwnsResources
2401
- ? 'browser IPC socket still owned by a surviving daemon'
2402
- : 'browser IPC socket ownership could not be verified');
2419
+ // The path was absent when this transaction captured the target (a
2420
+ // successor bound it afterward) or a successor replaced the inode. Either
2421
+ // way this invocation does not own the current binding and must leave it.
2422
+ surviving.push('browser IPC socket ownership could not be verified');
2403
2423
  }
2404
2424
  }
2405
2425
  else {
@@ -922,8 +922,12 @@ export async function resolveRoutineLaunch(config, cwd = process.cwd(), deps = {
922
922
  if (config.account && !explicitCredential) {
923
923
  // A native routine account is named by its durable name; the version matcher
924
924
  // keys on the identity (email/accountKey), so translate before resolving,
925
- // and refuse a login that belongs to a different harness.
926
- const unified = findUnifiedAccount(config.account, meta);
925
+ // and refuse a login that belongs to a different harness. Scope the lookup to
926
+ // the routine's own harness: `identityLabel` defaults to the login's email, so
927
+ // `account: <email>` matches every harness that identity is signed into, and
928
+ // un-scoped this resolved whichever row the store ordered first — then rejected
929
+ // it on the very next line.
930
+ const unified = findUnifiedAccount(config.account, meta, undefined, agent);
927
931
  if (unified?.kind === 'native' && unified.agent !== agent) {
928
932
  throw new Error(`Routine '${config.name}' account '${config.account}' is a ${unified.agent} login and cannot authenticate ${agent}.`);
929
933
  }
@@ -12,9 +12,37 @@
12
12
  * separate copy on purpose: the CLI does not import across packages.
13
13
  */
14
14
  import type { DeviceProfile } from './registry.js';
15
- /** Default per-device probe budget. Short enough that the list never hangs on a
16
- * wedged box, long enough for a cold relayed SSH handshake. */
15
+ /** Default per-device probe budget, for a device reachable over a DIRECT
16
+ * Tailscale path. Short enough that the list never hangs on a wedged box. */
17
17
  export declare const PROBE_TIMEOUT_MS = 2500;
18
+ /**
19
+ * Probe budget for a device whose last handshake was DERP-relayed
20
+ * ({@link DeviceTailscale.direct} === false).
21
+ *
22
+ * This constant used to not exist: 2.5s was applied to every device and its
23
+ * docstring claimed to be "long enough for a cold relayed SSH handshake". That
24
+ * was false, and on a fleet with no direct paths it broke `--device auto`
25
+ * outright (PHNX-3682). Measured on a 9-box relayed fleet, probed in parallel
26
+ * with cold paths: 1686/1805/1914/2688/2706/2749/3082/5586/6588 ms — six of nine
27
+ * over budget, every one of them healthy (rc=0 within 10s). Warm, the same
28
+ * probes take 512-872ms, which is what made the failure intermittent.
29
+ *
30
+ * A relayed hop pays DERP path setup on top of the TCP+SSH handshake, so it
31
+ * gets the same budget the readiness probe already allows
32
+ * (`READY_PROBE_TIMEOUT_MS`) rather than the direct-path one.
33
+ */
34
+ export declare const RELAYED_PROBE_TIMEOUT_MS = 8000;
35
+ /**
36
+ * The probe budget for one device. A relayed peer gets
37
+ * {@link RELAYED_PROBE_TIMEOUT_MS}; a direct (or unknown-path) peer keeps the
38
+ * tight {@link PROBE_TIMEOUT_MS}. Windows keeps its own larger budget, which
39
+ * already exceeds both.
40
+ *
41
+ * `direct` is only meaningful when a tailscale snapshot exists — a
42
+ * `via:"manual"` device never gets a peer entry, so absence is "unknown path",
43
+ * not "relayed", and must not silently widen every manual device's budget.
44
+ */
45
+ export declare function probeBudgetMs(device: DeviceProfile): number;
18
46
  /** Windows probe budget. The first CIM query of a PowerShell session pays a
19
47
  * "Preparing modules for first use" cost on top of PowerShell startup, which
20
48
  * routinely blows the 2.5s POSIX budget on a relayed connection. */
@@ -35,6 +63,14 @@ export declare function localProbeInvocation(platform: NodeJS.Platform): {
35
63
  export interface DeviceStats {
36
64
  host: string;
37
65
  reachable: boolean;
66
+ /**
67
+ * The probe exceeded its budget rather than being refused or unresolvable.
68
+ * Only meaningful when `reachable` is false — it separates "this box did not
69
+ * answer in time" from "this box actively could not be reached", so callers
70
+ * report a slow link honestly instead of calling a healthy device offline
71
+ * (PHNX-3682).
72
+ */
73
+ timedOut?: boolean;
38
74
  loadAvg1?: number;
39
75
  ncpu?: number;
40
76
  /** Load normalized to core count (the "has room" number): loadAvg1 / ncpu *
@@ -13,9 +13,43 @@
13
13
  */
14
14
  import { execFile } from 'child_process';
15
15
  import { buildSshInvocation, writeAskpassShim } from './connect.js';
16
- /** Default per-device probe budget. Short enough that the list never hangs on a
17
- * wedged box, long enough for a cold relayed SSH handshake. */
16
+ /** Default per-device probe budget, for a device reachable over a DIRECT
17
+ * Tailscale path. Short enough that the list never hangs on a wedged box. */
18
18
  export const PROBE_TIMEOUT_MS = 2_500;
19
+ /**
20
+ * Probe budget for a device whose last handshake was DERP-relayed
21
+ * ({@link DeviceTailscale.direct} === false).
22
+ *
23
+ * This constant used to not exist: 2.5s was applied to every device and its
24
+ * docstring claimed to be "long enough for a cold relayed SSH handshake". That
25
+ * was false, and on a fleet with no direct paths it broke `--device auto`
26
+ * outright (PHNX-3682). Measured on a 9-box relayed fleet, probed in parallel
27
+ * with cold paths: 1686/1805/1914/2688/2706/2749/3082/5586/6588 ms — six of nine
28
+ * over budget, every one of them healthy (rc=0 within 10s). Warm, the same
29
+ * probes take 512-872ms, which is what made the failure intermittent.
30
+ *
31
+ * A relayed hop pays DERP path setup on top of the TCP+SSH handshake, so it
32
+ * gets the same budget the readiness probe already allows
33
+ * (`READY_PROBE_TIMEOUT_MS`) rather than the direct-path one.
34
+ */
35
+ export const RELAYED_PROBE_TIMEOUT_MS = 8_000;
36
+ /**
37
+ * The probe budget for one device. A relayed peer gets
38
+ * {@link RELAYED_PROBE_TIMEOUT_MS}; a direct (or unknown-path) peer keeps the
39
+ * tight {@link PROBE_TIMEOUT_MS}. Windows keeps its own larger budget, which
40
+ * already exceeds both.
41
+ *
42
+ * `direct` is only meaningful when a tailscale snapshot exists — a
43
+ * `via:"manual"` device never gets a peer entry, so absence is "unknown path",
44
+ * not "relayed", and must not silently widen every manual device's budget.
45
+ */
46
+ export function probeBudgetMs(device) {
47
+ if (device.shell === 'powershell')
48
+ return WIN_PROBE_TIMEOUT_MS;
49
+ return device.tailscale && device.tailscale.direct === false
50
+ ? RELAYED_PROBE_TIMEOUT_MS
51
+ : PROBE_TIMEOUT_MS;
52
+ }
19
53
  /** Windows probe budget. The first CIM query of a PowerShell session pays a
20
54
  * "Preparing modules for first use" cost on top of PowerShell startup, which
21
55
  * routinely blows the 2.5s POSIX budget on a relayed connection. */
@@ -227,10 +261,14 @@ export function probeDeviceStats(device, opts = {}) {
227
261
  execFile('ssh', args, {
228
262
  encoding: 'utf-8',
229
263
  env: { ...process.env, ...env },
230
- timeout: opts.timeoutMs ?? (isWin ? WIN_PROBE_TIMEOUT_MS : PROBE_TIMEOUT_MS),
264
+ timeout: opts.timeoutMs ?? probeBudgetMs(device),
231
265
  }, (err, stdout) => {
232
- if (err || !stdout)
233
- return resolve({ host, reachable: false, fetchedAt });
266
+ // execFile kills an over-budget child with a signal; that is a slow link,
267
+ // not an unreachable box, and the two must not read the same downstream.
268
+ if (err || !stdout) {
269
+ const timedOut = Boolean(err && err.killed);
270
+ return resolve(timedOut ? { host, reachable: false, timedOut, fetchedAt } : { host, reachable: false, fetchedAt });
271
+ }
234
272
  resolve(isWin ? parseWinProbeOutput(host, stdout, fetchedAt) : parseProbeOutput(host, stdout, fetchedAt));
235
273
  });
236
274
  });
@@ -2,7 +2,7 @@ import { type DevicePlacementSignal } from '../teams/scheduler.js';
2
2
  /** Why a candidate was dropped, for the fail-loud error message. */
3
3
  export interface WorkerExclusion {
4
4
  device: string;
5
- reason: 'unreachable' | 'overloaded' | 'wrong-platform' | 'interactive';
5
+ reason: 'unreachable' | 'probe timed out' | 'overloaded' | 'wrong-platform' | 'interactive';
6
6
  }
7
7
  export interface WorkerPickPlan {
8
8
  /** The chosen device name. Never the local box unless it is an auto-pool member. */
@@ -79,7 +79,10 @@ export async function resolveWorkerDevice(opts = {}) {
79
79
  const eligible = onPlatform.filter((device) => {
80
80
  const signal = signals.get(device);
81
81
  if (signal?.reachable !== true) {
82
- excluded.push({ device, reason: 'unreachable' });
82
+ // A probe killed for exceeding its budget says nothing about whether the
83
+ // box is up — on a relayed fleet it usually is. Report the two apart so
84
+ // the message points at the link, not at a fleet outage (PHNX-3682).
85
+ excluded.push({ device, reason: signal?.timedOut ? 'probe timed out' : 'unreachable' });
83
86
  return false;
84
87
  }
85
88
  if (signal.headroom === 'loaded') {
@@ -44,7 +44,7 @@ export declare function headlessPlanStallCommand(args: {
44
44
  * (every agent supports edit-like behavior as its default).
45
45
  * - `plan` on an agent without a read-only mode degrades to the agent's
46
46
  * safest native mode (`capabilities.modes[0]`, typically `edit`). Agents
47
- * like antigravity/kiro have no plan flag; hard-failing made
47
+ * like antigravity have no plan flag; hard-failing made
48
48
  * multi-agent scripts (`--mode plan` for everyone) unusable and diverged
49
49
  * from `agents teams add`, which already defaults to `edit`. Callers that
50
50
  * care (the `agents run` CLI) must surface a warning when requested ≠
@@ -64,7 +64,7 @@ export declare function resolveMode(agent: AgentId, requested: Mode): Mode;
64
64
  * its ExitPlanMode gate. For those agents, a headless plan request degrades to
65
65
  * `auto` (kimi -p auto-runs; grok maps auto→edit via resolveMode) with a visible
66
66
  * one-line stderr warning, mirroring the graceful plan→edit degrade antigravity
67
- * and kiro get for having no plan flag at all. Interactive runs are never
67
+ * get for having no plan flag at all. Interactive runs are never
68
68
  * downgraded. This is the single source of truth shared by buildExecCommand
69
69
  * (agents run / teams) and the routine runner.
70
70
  */
package/dist/lib/exec.js CHANGED
@@ -40,6 +40,7 @@ import { resolveHarnessAdapter, stripForeignConfigDir } from './harness/index.js
40
40
  import { resolveConfigVersion } from './harness/exec-config-version.js';
41
41
  import { getAccountInfo } from './agents.js';
42
42
  import { getUsageLookupKey, noteClaudeSessionLimit, noteClaudeOutOfCredits, clearClaudeAccountRefusal, parseClaudeSessionLimitReset } from './accounting/usage.js';
43
+ import { bootMark, flushBootProfile } from './boot-profile.js';
43
44
  /**
44
45
  * Map a raw mode string (CLI flag, YAML field, env var) to the canonical Mode.
45
46
  *
@@ -95,7 +96,7 @@ export function headlessPlanStallCommand(args) {
95
96
  * (every agent supports edit-like behavior as its default).
96
97
  * - `plan` on an agent without a read-only mode degrades to the agent's
97
98
  * safest native mode (`capabilities.modes[0]`, typically `edit`). Agents
98
- * like antigravity/kiro have no plan flag; hard-failing made
99
+ * like antigravity have no plan flag; hard-failing made
99
100
  * multi-agent scripts (`--mode plan` for everyone) unusable and diverged
100
101
  * from `agents teams add`, which already defaults to `edit`. Callers that
101
102
  * care (the `agents run` CLI) must surface a warning when requested ≠
@@ -115,7 +116,7 @@ export function resolveMode(agent, requested) {
115
116
  }
116
117
  if (requested === 'plan') {
117
118
  // No read-only mode on this agent. modes[0] is the declared safest mode
118
- // (edit for antigravity/kiro/…). Prefer that over hard-fail so
119
+ // (edit for antigravity/…). Prefer that over hard-fail so
119
120
  // uniform multi-agent `--mode plan` dispatches still run.
120
121
  return supported[0];
121
122
  }
@@ -131,7 +132,7 @@ export function resolveMode(agent, requested) {
131
132
  * its ExitPlanMode gate. For those agents, a headless plan request degrades to
132
133
  * `auto` (kimi -p auto-runs; grok maps auto→edit via resolveMode) with a visible
133
134
  * one-line stderr warning, mirroring the graceful plan→edit degrade antigravity
134
- * and kiro get for having no plan flag at all. Interactive runs are never
135
+ * get for having no plan flag at all. Interactive runs are never
135
136
  * downgraded. This is the single source of truth shared by buildExecCommand
136
137
  * (agents run / teams) and the routine runner.
137
138
  */
@@ -521,25 +522,6 @@ export const AGENT_COMMANDS = {
521
522
  jsonFlags: ['--format', 'json'],
522
523
  modelFlag: '--model',
523
524
  },
524
- // Oh My Pi (`omp`). Headless is the positional MESSAGES arg + `-p/--print`.
525
- // Approval modes map to omp's `--approval-mode`: always-ask (read-only tools
526
- // auto-approved, writes gated -> our `plan`), write (read + workspace writes
527
- // auto-approved -> `edit`), yolo (all tiers auto-approved -> `skip`). JSON is
528
- // omp's `--mode json` event stream. `--model` fuzzy-matches a provider/model
529
- // selector. Native resume is `-r/--resume <id-prefix>`.
530
- pi: {
531
- base: ['omp'],
532
- promptFlag: 'positional',
533
- modeFlags: {
534
- plan: ['--approval-mode', 'always-ask'],
535
- edit: ['--approval-mode', 'write'],
536
- skip: ['--approval-mode', 'yolo'],
537
- },
538
- jsonFlags: ['--mode', 'json'],
539
- modelFlag: '--model',
540
- printFlags: ['-p'],
541
- resume: { flag: '--resume' },
542
- },
543
525
  openclaw: {
544
526
  base: ['openclaw'],
545
527
  promptFlag: 'positional',
@@ -583,18 +565,6 @@ export const AGENT_COMMANDS = {
583
565
  },
584
566
  modelFlag: '--model',
585
567
  },
586
- kiro: {
587
- // Standalone hooks live under ~/.kiro/hooks/*.json and only fire on the
588
- // v3 engine (opt-in via --v3; see https://kiro.dev/docs/cli/v3/hooks/).
589
- // Without this flag agents-cli would write v3 hook files that never run.
590
- base: ['kiro-cli', '--v3'],
591
- promptFlag: 'positional',
592
- modeFlags: {
593
- // kiro-cli has no permission flags — edit is the default behavior.
594
- edit: [],
595
- },
596
- modelFlag: '--model',
597
- },
598
568
  goose: {
599
569
  base: ['goose', 'run'],
600
570
  promptFlag: 'positional',
@@ -1733,6 +1703,7 @@ async function emitRunLaunch(ctx) {
1733
1703
  }
1734
1704
  }
1735
1705
  async function spawnAgent(options) {
1706
+ bootMark('spawn-agent:enter');
1736
1707
  // Assign a known session id up front for agents that accept one, so the
1737
1708
  // launcher can record an EXACT pid -> session mapping (see pid-registry) —
1738
1709
  // otherwise the headless `ag sessions --active` path can only guess
@@ -1867,6 +1838,7 @@ async function spawnAgent(options) {
1867
1838
  });
1868
1839
  if (tmuxWrap.kind === 'wrap') {
1869
1840
  timer.mark('startup');
1841
+ flushBootProfile('spawn');
1870
1842
  try {
1871
1843
  const result = await runInTmux(options, executable, args);
1872
1844
  timer.end({ exitCode: result.exitCode, status: result.exitCode === 0 ? 'success' : 'failed' });
@@ -1898,6 +1870,7 @@ async function spawnAgent(options) {
1898
1870
  const useShell = process.platform === 'win32' && (!path.isAbsolute(executable) || executable.endsWith('.cmd'));
1899
1871
  const spawnCommand = useShell ? composeWin32CommandLine(executable, args) : executable;
1900
1872
  const spawnArgs = useShell ? [] : args;
1873
+ flushBootProfile('spawn');
1901
1874
  const child = spawn(spawnCommand, spawnArgs, {
1902
1875
  cwd: options.cwd || process.cwd(),
1903
1876
  stdio,
@@ -1,4 +1,5 @@
1
1
  import type { Meta } from './types.js';
2
+ import { type SinkMessageFormat } from './sink-format.js';
2
3
  /** How loudly a post asks to be heard. Ordered — `important` implies milestone. */
3
4
  export type FeedPostLevel = 'milestone' | 'important';
4
5
  /** Parse a `--level` value; anything unrecognized is a usage error, not a default. */
@@ -122,6 +123,17 @@ export interface PlannedSink {
122
123
  to?: string;
123
124
  /** Channel sink body — the composed `{message}` for this post. */
124
125
  text?: string;
126
+ /**
127
+ * Owner-alias sinks only: the post context + its `message:` template, carried
128
+ * so the owner fan-out can re-render the body PER DESTINATION — Slack in the
129
+ * policy gets `mrkdwn` labeled links while iMessage stays plain (PHNX-3698).
130
+ * `text` above is the plain default (dry-run display + the fallback when a
131
+ * destination has no resolvable provider); this drives the real send. A
132
+ * direct `channel:` sink resolves its one provider's format at plan time and
133
+ * needs neither field.
134
+ */
135
+ ctx?: FeedBroadcastContext;
136
+ messageTemplate?: string;
125
137
  }
126
138
  export interface SinkOutcome {
127
139
  name: string;
@@ -136,6 +148,22 @@ export interface SinkOutcome {
136
148
  export declare function shortHost(host: string | undefined): string | undefined;
137
149
  /** First 8 hex chars of a session id for the footer (readable, not a full uuid). */
138
150
  export declare function shortSessionChunk(session: string | undefined): string | undefined;
151
+ /**
152
+ * Tap-to-view link for the session behind a post: the addressable console page
153
+ * ({@link https://prix.dev/console/sessions/<id>}, prix/web). The footer already
154
+ * carries a short session crumb for disambiguation; this rides the link trail so
155
+ * the owner can open the full transcript straight from an iMessage broadcast
156
+ * instead of hunting for it in the console.
157
+ *
158
+ * Accepts any real, path-safe session id — a Claude/Codex UUID *and* a native
159
+ * `ses_…` id from OpenCode or another harness. The console shard uploader
160
+ * (`traces/sync.ts`) syncs sessions with no harness filter, so all of them are
161
+ * addressable; a UUID-only gate would silently drop the link for every non-Claude
162
+ * harness (the whole point of the link). Reject only an id that could not resolve:
163
+ * one with a path separator (URL-unsafe, via {@link isValidMailboxId}) or the bare
164
+ * 8-char footer crumb (a truncated id that would 404).
165
+ */
166
+ export declare function sessionConsoleUrl(session: string | undefined): string | undefined;
139
167
  /**
140
168
  * Scrub em/en dashes from outbound phone copy (house rule + iMessage readability).
141
169
  * Collapses whitespace; does not invent meaning.
@@ -148,10 +176,15 @@ export declare function scrubOutboundDashes(text: string): string;
148
176
  *
149
177
  * Agent name first; session chunk for disambiguation when many groks run;
150
178
  * host last. Skip the uninformative default label `agent`.
179
+ *
180
+ * In `mrkdwn` the crumb (`agent/short`) becomes a Slack labeled link to the
181
+ * session's console page, so the human sentence reads identically while the
182
+ * crumb turns blue and taps through (PHNX-3698). `plain` keeps the bare sentence
183
+ * — it can't render a labeled link and must not dump the URL.
151
184
  */
152
- export declare function composeBroadcastFooter(ctx: FeedBroadcastContext): string | undefined;
185
+ export declare function composeBroadcastFooter(ctx: FeedBroadcastContext, format?: SinkMessageFormat): string | undefined;
153
186
  export declare function truncateBroadcastBody(body: string): string;
154
- export declare function composeBroadcastMessage(ctx: FeedBroadcastContext): string;
187
+ export declare function composeBroadcastMessage(ctx: FeedBroadcastContext, format?: SinkMessageFormat): string;
155
188
  /**
156
189
  * Substitute `{placeholder}` tokens in an argv template. Returns undefined when
157
190
  * the template needs a value this post does not have — the sink is then skipped
@@ -159,8 +192,13 @@ export declare function composeBroadcastMessage(ctx: FeedBroadcastContext): stri
159
192
  * would otherwise comment on nothing.
160
193
  */
161
194
  export declare function renderSinkArgv(template: string[], ctx: FeedBroadcastContext): string[] | undefined;
162
- /** Render one channel-message template with the same fail-closed placeholder contract as argv. */
163
- export declare function renderSinkMessage(template: string, ctx: FeedBroadcastContext): string | undefined;
195
+ /**
196
+ * Render one channel-message template with the same fail-closed placeholder
197
+ * contract as argv. `format` (Slack `mrkdwn` vs `plain`) flows into the shared
198
+ * `{message}` var so a Slack sink gets labeled links and an iMessage/owner sink
199
+ * gets the plain sentence.
200
+ */
201
+ export declare function renderSinkMessage(template: string, ctx: FeedBroadcastContext, format?: SinkMessageFormat): string | undefined;
164
202
  /**
165
203
  * Which sinks this post reaches, in config order. Pure — the dry-run listing and
166
204
  * the real fan-out plan through here, so what `--dry-run` shows is what runs.
@@ -168,8 +206,12 @@ export declare function renderSinkMessage(template: string, ctx: FeedBroadcastCo
168
206
  * A `channel:` sink is gated by the same `minLevel` rule as a `command:` sink —
169
207
  * one level check for both shapes, so a dry-run plan is truthful regardless of
170
208
  * which shape an operator's sink uses.
209
+ *
210
+ * `meta` is used only to resolve a channel name to its real provider for the
211
+ * mrkdwn/plain format decision (`notify.transports`), the same map delivery uses;
212
+ * it is optional so a test can plan without a config snapshot (identity mapping).
171
213
  */
172
- export declare function planFeedBroadcast(config: FeedBroadcastConfig | undefined, ctx: FeedBroadcastContext): PlannedSink[];
214
+ export declare function planFeedBroadcast(config: FeedBroadcastConfig | undefined, ctx: FeedBroadcastContext, meta?: Meta): PlannedSink[];
173
215
  /**
174
216
  * The effective sink config for a post: the operator's `feed.broadcast`, or —
175
217
  * when that is unset or empty — an implicit fallback straight to