@phnx-labs/agents-cli 1.22.69 → 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 (64) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +3 -8
  3. package/dist/commands/accounts.js +23 -7
  4. package/dist/commands/exec.js +2 -2
  5. package/dist/commands/import.js +2 -2
  6. package/dist/commands/models.js +2 -2
  7. package/dist/commands/permissions.js +2 -2
  8. package/dist/commands/rules.js +1 -1
  9. package/dist/commands/send.js +9 -3
  10. package/dist/commands/traces.js +1 -1
  11. package/dist/lib/account-capabilities.js +0 -2
  12. package/dist/lib/account-registry.d.ts +3 -3
  13. package/dist/lib/account-registry.js +25 -7
  14. package/dist/lib/acp/client.d.ts +1 -1
  15. package/dist/lib/acp/client.js +12 -1
  16. package/dist/lib/acp/harnesses.js +1 -1
  17. package/dist/lib/add-dir.js +0 -2
  18. package/dist/lib/agent-cli-commands.js +0 -2
  19. package/dist/lib/agent-spec/agents.d.ts +1 -1
  20. package/dist/lib/agent-spec/agents.js +2 -83
  21. package/dist/lib/browser/ipc.js +9 -1
  22. package/dist/lib/browser/remote-control.d.ts +6 -3
  23. package/dist/lib/browser/remote-control.js +6 -3
  24. package/dist/lib/browser/service.d.ts +4 -1
  25. package/dist/lib/browser/service.js +7 -1
  26. package/dist/lib/channels/registry.d.ts +6 -0
  27. package/dist/lib/channels/send.d.ts +11 -2
  28. package/dist/lib/channels/send.js +11 -2
  29. package/dist/lib/cloud/rush.d.ts +10 -2
  30. package/dist/lib/cloud/rush.js +15 -9
  31. package/dist/lib/daemon/daemon.js +28 -8
  32. package/dist/lib/daemon/runner.js +6 -2
  33. package/dist/lib/exec.d.ts +2 -2
  34. package/dist/lib/exec.js +3 -34
  35. package/dist/lib/feed-broadcast.d.ts +12 -29
  36. package/dist/lib/feed-broadcast.js +28 -27
  37. package/dist/lib/hooks/install.js +0 -87
  38. package/dist/lib/installations/strategies.js +1 -1
  39. package/dist/lib/mcp-registry.js +0 -13
  40. package/dist/lib/mcp.js +2 -2
  41. package/dist/lib/model-tiers.js +1 -1
  42. package/dist/lib/models.js +0 -63
  43. package/dist/lib/notify.d.ts +11 -0
  44. package/dist/lib/notify.js +17 -4
  45. package/dist/lib/owner-message.d.ts +46 -3
  46. package/dist/lib/owner-message.js +26 -6
  47. package/dist/lib/permissions-registry.d.ts +0 -2
  48. package/dist/lib/permissions-registry.js +3 -50
  49. package/dist/lib/permissions.d.ts +3 -17
  50. package/dist/lib/permissions.js +4 -73
  51. package/dist/lib/rush-session.d.ts +19 -0
  52. package/dist/lib/rush-session.js +24 -0
  53. package/dist/lib/secrets/drivers/rush.js +2 -1
  54. package/dist/lib/session/cloud.js +2 -1
  55. package/dist/lib/sink-format.d.ts +34 -0
  56. package/dist/lib/sink-format.js +17 -0
  57. package/dist/lib/staleness/detectors/permissions.js +0 -20
  58. package/dist/lib/staleness/writers/commands.js +1 -1
  59. package/dist/lib/staleness/writers/hooks.js +2 -2
  60. package/dist/lib/subagents-registry.js +2 -12
  61. package/dist/lib/subagents.d.ts +0 -10
  62. package/dist/lib/subagents.js +0 -37
  63. package/dist/lib/types.d.ts +1 -1
  64. package/package.json +1 -1
@@ -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
@@ -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;
@@ -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
  }
@@ -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
  }
@@ -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
@@ -96,7 +96,7 @@ export function headlessPlanStallCommand(args) {
96
96
  * (every agent supports edit-like behavior as its default).
97
97
  * - `plan` on an agent without a read-only mode degrades to the agent's
98
98
  * safest native mode (`capabilities.modes[0]`, typically `edit`). Agents
99
- * like antigravity/kiro have no plan flag; hard-failing made
99
+ * like antigravity have no plan flag; hard-failing made
100
100
  * multi-agent scripts (`--mode plan` for everyone) unusable and diverged
101
101
  * from `agents teams add`, which already defaults to `edit`. Callers that
102
102
  * care (the `agents run` CLI) must surface a warning when requested ≠
@@ -116,7 +116,7 @@ export function resolveMode(agent, requested) {
116
116
  }
117
117
  if (requested === 'plan') {
118
118
  // No read-only mode on this agent. modes[0] is the declared safest mode
119
- // (edit for antigravity/kiro/…). Prefer that over hard-fail so
119
+ // (edit for antigravity/…). Prefer that over hard-fail so
120
120
  // uniform multi-agent `--mode plan` dispatches still run.
121
121
  return supported[0];
122
122
  }
@@ -132,7 +132,7 @@ export function resolveMode(agent, requested) {
132
132
  * its ExitPlanMode gate. For those agents, a headless plan request degrades to
133
133
  * `auto` (kimi -p auto-runs; grok maps auto→edit via resolveMode) with a visible
134
134
  * one-line stderr warning, mirroring the graceful plan→edit degrade antigravity
135
- * 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
136
136
  * downgraded. This is the single source of truth shared by buildExecCommand
137
137
  * (agents run / teams) and the routine runner.
138
138
  */
@@ -522,25 +522,6 @@ export const AGENT_COMMANDS = {
522
522
  jsonFlags: ['--format', 'json'],
523
523
  modelFlag: '--model',
524
524
  },
525
- // Oh My Pi (`omp`). Headless is the positional MESSAGES arg + `-p/--print`.
526
- // Approval modes map to omp's `--approval-mode`: always-ask (read-only tools
527
- // auto-approved, writes gated -> our `plan`), write (read + workspace writes
528
- // auto-approved -> `edit`), yolo (all tiers auto-approved -> `skip`). JSON is
529
- // omp's `--mode json` event stream. `--model` fuzzy-matches a provider/model
530
- // selector. Native resume is `-r/--resume <id-prefix>`.
531
- pi: {
532
- base: ['omp'],
533
- promptFlag: 'positional',
534
- modeFlags: {
535
- plan: ['--approval-mode', 'always-ask'],
536
- edit: ['--approval-mode', 'write'],
537
- skip: ['--approval-mode', 'yolo'],
538
- },
539
- jsonFlags: ['--mode', 'json'],
540
- modelFlag: '--model',
541
- printFlags: ['-p'],
542
- resume: { flag: '--resume' },
543
- },
544
525
  openclaw: {
545
526
  base: ['openclaw'],
546
527
  promptFlag: 'positional',
@@ -584,18 +565,6 @@ export const AGENT_COMMANDS = {
584
565
  },
585
566
  modelFlag: '--model',
586
567
  },
587
- kiro: {
588
- // Standalone hooks live under ~/.kiro/hooks/*.json and only fire on the
589
- // v3 engine (opt-in via --v3; see https://kiro.dev/docs/cli/v3/hooks/).
590
- // Without this flag agents-cli would write v3 hook files that never run.
591
- base: ['kiro-cli', '--v3'],
592
- promptFlag: 'positional',
593
- modeFlags: {
594
- // kiro-cli has no permission flags — edit is the default behavior.
595
- edit: [],
596
- },
597
- modelFlag: '--model',
598
- },
599
568
  goose: {
600
569
  base: ['goose', 'run'],
601
570
  promptFlag: 'positional',
@@ -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;
@@ -157,35 +169,6 @@ export declare function sessionConsoleUrl(session: string | undefined): string |
157
169
  * Collapses whitespace; does not invent meaning.
158
170
  */
159
171
  export declare function scrubOutboundDashes(text: string): string;
160
- /**
161
- * The rendering vocabulary a sink can display, which decides how the shared
162
- * `{message}` surfaces its links (PHNX-3698):
163
- *
164
- * - `mrkdwn` — Slack, which renders `<url|label>` as blue tappable text. The
165
- * session crumb and every ticket key the prose NAMES become inline labeled
166
- * links, so nothing rides a trailing naked-URL line.
167
- * - `plain` — iMessage, the owner-scoped rush message, a spawned `command:`
168
- * sink, desktop banners: none can render a labeled link and a dumped naked
169
- * URL reads as noise, so the message stays the human sentence with no URLs.
170
- *
171
- * The default is `plain`; only a Slack `channel:` sink opts into `mrkdwn`.
172
- */
173
- export type SinkMessageFormat = 'plain' | 'mrkdwn';
174
- /**
175
- * Only Slack renders `<url|label>`, so it is the one format that gets labeled
176
- * links. iMessage / owner-scoped rush / command / desktop sinks stay `plain`
177
- * (they can't turn `claude/6fc1db18` blue, and dumping the raw URL is worse than
178
- * leaving the crumb unlinked — PHNX-3698).
179
- *
180
- * The argument is the **resolved provider name**, not the sink's declared
181
- * channel: an operator can point an arbitrary channel name at the Slack provider
182
- * through `notify.transports` (e.g. `eng-alerts -> slack`), and delivery keys off
183
- * that resolved provider (`lookupTransport`), so the format decision must too —
184
- * otherwise an aliased Slack sink would compose plain while delivering to Slack,
185
- * or a name remapped AWAY from Slack would emit `<url|label>` markup a non-Slack
186
- * transport shows literally. {@link resolveSinkProvider} does the mapping.
187
- */
188
- export declare function sinkMessageFormat(provider: string | undefined): SinkMessageFormat;
189
172
  /**
190
173
  * Footer like "Sent from my iPhone" — who posted, a session crumb, which box.
191
174
  *
@@ -41,6 +41,7 @@ import { sendToOwner } from './notify.js';
41
41
  import { linearIssueUrl, linearIssueKeys } from './session/linear.js';
42
42
  import { isValidMailboxId } from './mailbox.js';
43
43
  import { forwardOwnerNotifyToPeer } from './channels/owner-forward.js';
44
+ import { sinkMessageFormat } from './sink-format.js';
44
45
  const LEVEL_RANK = { milestone: 0, important: 1 };
45
46
  /** Parse a `--level` value; anything unrecognized is a usage error, not a default. */
46
47
  export function parseFeedPostLevel(raw) {
@@ -184,23 +185,6 @@ export function scrubOutboundDashes(text) {
184
185
  function slackLink(url, label) {
185
186
  return `<${url}|${label}>`;
186
187
  }
187
- /**
188
- * Only Slack renders `<url|label>`, so it is the one format that gets labeled
189
- * links. iMessage / owner-scoped rush / command / desktop sinks stay `plain`
190
- * (they can't turn `claude/6fc1db18` blue, and dumping the raw URL is worse than
191
- * leaving the crumb unlinked — PHNX-3698).
192
- *
193
- * The argument is the **resolved provider name**, not the sink's declared
194
- * channel: an operator can point an arbitrary channel name at the Slack provider
195
- * through `notify.transports` (e.g. `eng-alerts -> slack`), and delivery keys off
196
- * that resolved provider (`lookupTransport`), so the format decision must too —
197
- * otherwise an aliased Slack sink would compose plain while delivering to Slack,
198
- * or a name remapped AWAY from Slack would emit `<url|label>` markup a non-Slack
199
- * transport shows literally. {@link resolveSinkProvider} does the mapping.
200
- */
201
- export function sinkMessageFormat(provider) {
202
- return provider?.trim().toLowerCase() === 'slack' ? 'mrkdwn' : 'plain';
203
- }
204
188
  /**
205
189
  * The provider a channel name actually delivers through — the same
206
190
  * `notify.transports` remap `lookupTransport` applies at delivery — so the format
@@ -481,21 +465,28 @@ export function planFeedBroadcast(config, ctx, meta) {
481
465
  // placeholder below).
482
466
  if (!isOwnerAlias(channel) && !sink.to?.trim())
483
467
  continue;
484
- // Slack renders labeled links; every other channel (owner alias, iMessage,
485
- // telegram, discord, mailbox, desktop) stays plain (PHNX-3698). The owner
486
- // alias is deliberately plain even when it fans out to a Slack destination:
487
- // it delivers ONE shared string to every channel in owner.policy.normal, so
488
- // mrkdwn markup would corrupt a sibling iMessage copy. Keying on the
489
- // resolved provider (not the raw name) matches what delivery does.
490
- const provider = isOwnerAlias(channel) ? channel : resolveSinkProvider(channel, meta);
491
- const text = renderSinkMessage(sink.message ?? '{message}', ctx, sinkMessageFormat(provider));
468
+ // Slack renders labeled links; every other channel (iMessage, telegram,
469
+ // discord, mailbox, desktop) stays plain (PHNX-3698). A DIRECT channel sink
470
+ // has one known provider, so its format is resolved here. The OWNER ALIAS
471
+ // fans out to every channel in owner.policy.normal — each with its OWN
472
+ // provider — so it can't pick one format now: it carries the ctx + template
473
+ // and re-renders per destination inside the owner fan-out (runChannelSink →
474
+ // sendToOwner), so the Slack destination turns blue while a sibling iMessage
475
+ // copy stays plain. The plain body computed here is the dry-run/fallback
476
+ // default. Keying on the resolved provider (not the raw name) matches what
477
+ // delivery does.
478
+ const owner = isOwnerAlias(channel);
479
+ const template = sink.message ?? '{message}';
480
+ const provider = owner ? channel : resolveSinkProvider(channel, meta);
481
+ const text = renderSinkMessage(template, ctx, sinkMessageFormat(provider));
492
482
  if (!text)
493
483
  continue;
494
484
  planned.push({
495
485
  name,
496
486
  channel,
497
- to: isOwnerAlias(channel) ? undefined : sink.to.trim(),
487
+ to: owner ? undefined : sink.to.trim(),
498
488
  text,
489
+ ...(owner ? { ctx, messageTemplate: template } : {}),
499
490
  });
500
491
  continue;
501
492
  }
@@ -604,7 +595,17 @@ async function runChannelSink(sink, meta) {
604
595
  registerBuiltinProviders();
605
596
  const owner = isOwnerAlias(sink.channel);
606
597
  if (owner) {
607
- const result = await sendToOwner(sink.text ?? '', { meta });
598
+ // Re-render the body PER owner destination so a Slack channel in the policy
599
+ // gets mrkdwn labeled links while iMessage stays plain (PHNX-3698). The
600
+ // fan-out (sendToOwner) resolves each destination's provider and asks this
601
+ // composer for the matching format. renderSinkMessage is fail-closed on a
602
+ // missing placeholder — the plan already dropped the sink if the template
603
+ // couldn't fill, so here it always resolves; `?? sink.text` is a belt-and-
604
+ // braces guard, never the normal path.
605
+ const composeForFormat = sink.ctx
606
+ ? (format) => renderSinkMessage(sink.messageTemplate ?? '{message}', sink.ctx, format) ?? sink.text ?? ''
607
+ : undefined;
608
+ const result = await sendToOwner(sink.text ?? '', { meta, composeForFormat });
608
609
  return { name, ok: result.ok, ...(result.error ? { error: result.error } : {}) };
609
610
  }
610
611
  const resolved = resolveSendEnvelope({
@@ -1845,9 +1845,6 @@ export function registerHooksToSettings(agentId, versionHome, hookManifest, agen
1845
1845
  if (agentId === 'copilot') {
1846
1846
  return registerHooksForCopilot(versionHome, manifest, resolveScript, managedPrefixes);
1847
1847
  }
1848
- if (agentId === 'kiro') {
1849
- return registerHooksForKiro(versionHome, manifest, resolveScript, managedPrefixes);
1850
- }
1851
1848
  if (agentId === 'goose') {
1852
1849
  return registerHooksForGoose(versionHome, manifest, resolveScript, managedPrefixes);
1853
1850
  }
@@ -2846,90 +2843,6 @@ function registerHooksForCopilot(versionHome, manifest, resolveScript, _managedP
2846
2843
  }
2847
2844
  return { registered, errors };
2848
2845
  }
2849
- /**
2850
- * Canonical hooks.yaml event names → Kiro v3 trigger names.
2851
- * Kiro v3 uses PascalCase triggers under `.kiro/hooks/*.json`
2852
- * (https://kiro.dev/docs/cli/v3/hooks/). Unmapped events are skipped.
2853
- */
2854
- const KIRO_EVENT_MAP = {
2855
- SessionStart: 'SessionStart',
2856
- Stop: 'Stop',
2857
- PreToolUse: 'PreToolUse',
2858
- PostToolUse: 'PostToolUse',
2859
- UserPromptSubmit: 'UserPromptSubmit',
2860
- };
2861
- /**
2862
- * Kiro triggers that evaluate the `matcher` regex (tool name, file path, or
2863
- * prompt text depending on the trigger). Lifecycle SessionStart/Stop always fire.
2864
- */
2865
- const KIRO_MATCHER_EVENTS = new Set([
2866
- 'PreToolUse',
2867
- 'PostToolUse',
2868
- 'UserPromptSubmit',
2869
- ]);
2870
- /** Managed filename under ~/.kiro/hooks/ — we own this file entirely. */
2871
- const KIRO_MANAGED_HOOKS_FILE = 'agents-cli-hooks.json';
2872
- /**
2873
- * Register hooks for Kiro CLI (v3 standalone hooks format).
2874
- *
2875
- * Each file under `~/.kiro/hooks/*.json` is:
2876
- * { "version": "v1", "hooks": [ { name, trigger, matcher?, action, timeout?, enabled? } ] }
2877
- *
2878
- * We rewrite a single managed file so GC is a rewrite and user-authored sibling
2879
- * JSON files are never touched. Embedded agent-config hooks (2.x) still work
2880
- * in Kiro but we only write the v3 path.
2881
- */
2882
- function registerHooksForKiro(versionHome, manifest, resolveScript, _managedPrefixes) {
2883
- const registered = [];
2884
- const errors = [];
2885
- const kiroHooksDir = path.join(versionHome, '.kiro', 'hooks');
2886
- fs.mkdirSync(kiroHooksDir, { recursive: true });
2887
- const hooks = [];
2888
- for (const [name, hookDef] of Object.entries(manifest)) {
2889
- if (!hookDef.events || hookDef.events.length === 0)
2890
- continue;
2891
- const commandPath = resolveHookCommand(name, hookDef, resolveScript);
2892
- if (!commandPath) {
2893
- errors.push(`${name}: script not found in user or system hooks dir`);
2894
- continue;
2895
- }
2896
- const timeout = hookDef.timeout ?? 60;
2897
- for (const event of hookDef.events) {
2898
- const trigger = KIRO_EVENT_MAP[event];
2899
- if (!trigger)
2900
- continue;
2901
- const entry = {
2902
- name,
2903
- trigger,
2904
- action: { type: 'command', command: commandPath },
2905
- timeout,
2906
- enabled: true,
2907
- };
2908
- if (KIRO_MATCHER_EVENTS.has(trigger) && hookDef.matcher) {
2909
- entry.matcher = hookDef.matcher;
2910
- }
2911
- // De-dupe on (name, trigger, matcher)
2912
- const existingIdx = hooks.findIndex((h) => h.name === entry.name &&
2913
- h.trigger === entry.trigger &&
2914
- (h.matcher ?? '') === (entry.matcher ?? ''));
2915
- if (existingIdx >= 0) {
2916
- hooks[existingIdx] = entry;
2917
- }
2918
- else {
2919
- hooks.push(entry);
2920
- }
2921
- registered.push(`${name} -> ${trigger}`);
2922
- }
2923
- }
2924
- const outPath = path.join(kiroHooksDir, KIRO_MANAGED_HOOKS_FILE);
2925
- try {
2926
- fs.writeFileSync(outPath, JSON.stringify({ version: 'v1', hooks }, null, 2) + '\n', 'utf-8');
2927
- }
2928
- catch (err) {
2929
- errors.push(`Failed to write ${KIRO_MANAGED_HOOKS_FILE}: ${err.message}`);
2930
- }
2931
- return { registered, errors };
2932
- }
2933
2846
  /**
2934
2847
  * Canonical hooks.yaml event names that goose supports (Open Plugins PascalCase).
2935
2848
  * Unmapped events are skipped. Goose ≥ 1.34.0.