@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.
- package/CHANGELOG.md +16 -0
- package/README.md +3 -8
- package/dist/commands/accounts.js +23 -7
- package/dist/commands/exec.js +2 -2
- package/dist/commands/import.js +2 -2
- package/dist/commands/models.js +2 -2
- package/dist/commands/permissions.js +2 -2
- package/dist/commands/rules.js +1 -1
- package/dist/commands/send.js +9 -3
- package/dist/commands/traces.js +1 -1
- package/dist/lib/account-capabilities.js +0 -2
- package/dist/lib/account-registry.d.ts +3 -3
- package/dist/lib/account-registry.js +25 -7
- package/dist/lib/acp/client.d.ts +1 -1
- package/dist/lib/acp/client.js +12 -1
- package/dist/lib/acp/harnesses.js +1 -1
- package/dist/lib/add-dir.js +0 -2
- package/dist/lib/agent-cli-commands.js +0 -2
- package/dist/lib/agent-spec/agents.d.ts +1 -1
- package/dist/lib/agent-spec/agents.js +2 -83
- package/dist/lib/browser/ipc.js +9 -1
- package/dist/lib/browser/remote-control.d.ts +6 -3
- package/dist/lib/browser/remote-control.js +6 -3
- package/dist/lib/browser/service.d.ts +4 -1
- package/dist/lib/browser/service.js +7 -1
- package/dist/lib/channels/registry.d.ts +6 -0
- package/dist/lib/channels/send.d.ts +11 -2
- package/dist/lib/channels/send.js +11 -2
- package/dist/lib/cloud/rush.d.ts +10 -2
- package/dist/lib/cloud/rush.js +15 -9
- package/dist/lib/daemon/daemon.js +28 -8
- package/dist/lib/daemon/runner.js +6 -2
- package/dist/lib/exec.d.ts +2 -2
- package/dist/lib/exec.js +3 -34
- package/dist/lib/feed-broadcast.d.ts +12 -29
- package/dist/lib/feed-broadcast.js +28 -27
- package/dist/lib/hooks/install.js +0 -87
- package/dist/lib/installations/strategies.js +1 -1
- package/dist/lib/mcp-registry.js +0 -13
- package/dist/lib/mcp.js +2 -2
- package/dist/lib/model-tiers.js +1 -1
- package/dist/lib/models.js +0 -63
- package/dist/lib/notify.d.ts +11 -0
- package/dist/lib/notify.js +17 -4
- package/dist/lib/owner-message.d.ts +46 -3
- package/dist/lib/owner-message.js +26 -6
- package/dist/lib/permissions-registry.d.ts +0 -2
- package/dist/lib/permissions-registry.js +3 -50
- package/dist/lib/permissions.d.ts +3 -17
- package/dist/lib/permissions.js +4 -73
- package/dist/lib/rush-session.d.ts +19 -0
- package/dist/lib/rush-session.js +24 -0
- package/dist/lib/secrets/drivers/rush.js +2 -1
- package/dist/lib/session/cloud.js +2 -1
- package/dist/lib/sink-format.d.ts +34 -0
- package/dist/lib/sink-format.js +17 -0
- package/dist/lib/staleness/detectors/permissions.js +0 -20
- package/dist/lib/staleness/writers/commands.js +1 -1
- package/dist/lib/staleness/writers/hooks.js +2 -2
- package/dist/lib/subagents-registry.js +2 -12
- package/dist/lib/subagents.d.ts +0 -10
- package/dist/lib/subagents.js +0 -37
- package/dist/lib/types.d.ts +1 -1
- 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
|
|
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)
|
|
24
|
-
*
|
|
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
|
|
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)
|
|
24
|
-
*
|
|
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
|
|
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
|
-
/**
|
|
82
|
-
|
|
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
|
-
/**
|
|
112
|
-
|
|
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 };
|
package/dist/lib/cloud/rush.d.ts
CHANGED
|
@@ -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
|
|
12
|
-
*
|
|
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;
|
package/dist/lib/cloud/rush.js
CHANGED
|
@@ -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
|
|
23
|
-
*
|
|
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
|
-
|
|
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
|
-
/**
|
|
43
|
-
|
|
44
|
-
|
|
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(
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
2398
|
-
//
|
|
2399
|
-
// invocation does not own the current binding and must leave it
|
|
2400
|
-
surviving.push(
|
|
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
|
-
|
|
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
|
}
|
package/dist/lib/exec.d.ts
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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 (
|
|
485
|
-
//
|
|
486
|
-
//
|
|
487
|
-
//
|
|
488
|
-
//
|
|
489
|
-
//
|
|
490
|
-
|
|
491
|
-
|
|
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:
|
|
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
|
-
|
|
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.
|