@phnx-labs/agents-cli 1.22.69 → 1.22.71
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +34 -0
- package/README.md +34 -9
- package/dist/bootstrap.js +4 -4
- 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/repo.js +2 -2
- package/dist/commands/rules.js +1 -1
- package/dist/commands/send.js +9 -3
- package/dist/commands/sessions-export.d.ts +5 -1
- package/dist/commands/sessions-export.js +100 -24
- package/dist/commands/sessions-import.d.ts +2 -1
- package/dist/commands/sessions-import.js +85 -21
- 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/accounting/usage-sync.d.ts +1 -1
- package/dist/lib/accounting/usage-sync.js +3 -3
- 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.d.ts +34 -0
- package/dist/lib/browser/ipc.js +149 -20
- 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/browser/types.d.ts +3 -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/auth-sync-service.js +1 -1
- package/dist/lib/daemon/browser-task-reap-service.js +1 -1
- package/dist/lib/daemon/daemon.js +41 -11
- package/dist/lib/daemon/heartbeat-service.js +3 -3
- package/dist/lib/daemon/keychain-reap-service.js +1 -1
- package/dist/lib/daemon/runner.d.ts +18 -1
- package/dist/lib/daemon/runner.js +237 -80
- package/dist/lib/daemon/self-heal-service.js +13 -3
- package/dist/lib/daemon/self-update-service.d.ts +174 -0
- package/dist/lib/daemon/self-update-service.js +353 -0
- package/dist/lib/daemon/state-dir-check-service.js +3 -3
- package/dist/lib/daemon/usage-sync-service.js +1 -1
- package/dist/lib/daemon/watchdog-service.js +4 -4
- package/dist/lib/daemon-services.d.ts +1 -1
- package/dist/lib/daemon-services.js +5 -0
- package/dist/lib/device-config.d.ts +12 -1
- package/dist/lib/device-config.js +63 -13
- package/dist/lib/exec-bounded.d.ts +52 -0
- package/dist/lib/exec-bounded.js +113 -0
- package/dist/lib/exec.d.ts +2 -2
- package/dist/lib/exec.js +3 -34
- package/dist/lib/feed/events.d.ts +22 -14
- package/dist/lib/feed/events.js +84 -44
- package/dist/lib/feed-broadcast.d.ts +12 -29
- package/dist/lib/feed-broadcast.js +28 -27
- package/dist/lib/fleet-shared-state.d.ts +12 -5
- package/dist/lib/fleet-shared-state.js +50 -20
- package/dist/lib/fs-atomic.d.ts +11 -0
- package/dist/lib/fs-atomic.js +60 -0
- package/dist/lib/hooks/install.js +0 -87
- package/dist/lib/hosts/reconcile.d.ts +11 -4
- package/dist/lib/hosts/reconcile.js +31 -5
- 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/project-resources.d.ts +12 -0
- package/dist/lib/project-resources.js +129 -0
- package/dist/lib/routine-process-cleanup.d.ts +2 -2
- package/dist/lib/routine-process-cleanup.js +45 -34
- 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/secrets/reaper.d.ts +2 -2
- package/dist/lib/secrets/reaper.js +13 -10
- package/dist/lib/secrets/reserved-sync.d.ts +1 -1
- package/dist/lib/secrets/reserved-sync.js +4 -4
- package/dist/lib/self-update.d.ts +21 -8
- package/dist/lib/self-update.js +54 -31
- package/dist/lib/session/cloud.js +2 -1
- package/dist/lib/session/sync/backend.d.ts +61 -0
- package/dist/lib/session/sync/backend.js +89 -0
- package/dist/lib/session/sync/managed-config.d.ts +29 -0
- package/dist/lib/session/sync/managed-config.js +23 -0
- package/dist/lib/session/sync/managed-key.d.ts +45 -0
- package/dist/lib/session/sync/managed-key.js +128 -0
- package/dist/lib/session/sync/net-client.d.ts +65 -0
- package/dist/lib/session/sync/net-client.js +117 -0
- package/dist/lib/session/sync/provision.d.ts +19 -0
- package/dist/lib/session/sync/provision.js +38 -0
- package/dist/lib/session/sync/r2.d.ts +5 -2
- package/dist/lib/session/sync/r2.js +5 -2
- package/dist/lib/session/sync/worker-template.d.ts +6 -0
- package/dist/lib/session/sync/worker-template.js +847 -0
- 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/tmux/orphan-reap.js +6 -4
- package/dist/lib/tmux/session.js +4 -1
- package/dist/lib/traces/classify.d.ts +8 -1
- package/dist/lib/traces/insights.d.ts +13 -1
- package/dist/lib/traces/insights.js +78 -3
- package/dist/lib/traces/sync.js +8 -3
- package/dist/lib/traces/worker-template.js +9 -5
- package/dist/lib/types.d.ts +1 -1
- package/package.json +1 -1
|
@@ -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({
|
|
@@ -25,14 +25,21 @@ export interface FleetSharedStateReadResult {
|
|
|
25
25
|
}
|
|
26
26
|
/** Path owned by one device in the conflict-free tracked device-doc tree. */
|
|
27
27
|
export declare function fleetSharedStatePath(device: string, userAgentsDir?: string): string;
|
|
28
|
-
/**
|
|
29
|
-
* Merge one daemon-owned field into this device's shared file under a real
|
|
30
|
-
* inter-process lock. Stable serialization avoids dirtying the user repo when
|
|
31
|
-
* neither usage nor auth state changed.
|
|
32
|
-
*/
|
|
33
28
|
export declare function updateFleetSharedDeviceState(device: string, patch: FleetSharedStatePatch, userAgentsDir?: string): {
|
|
34
29
|
changed: boolean;
|
|
35
30
|
path: string;
|
|
36
31
|
};
|
|
32
|
+
/**
|
|
33
|
+
* Async, non-blocking twin of {@link updateFleetSharedDeviceState} for the
|
|
34
|
+
* daemon's usage-sync / auth-sync ticks (PHNX-3695). The sync version acquires
|
|
35
|
+
* the file lock with `sleepSync` (`Atomics.wait`), freezing the shared event
|
|
36
|
+
* loop for up to 30s under contention on EVERY tick; this uses
|
|
37
|
+
* `withFileLockAsync`. The under-lock read is async; the atomic write is a tiny
|
|
38
|
+
* bounded write held inside the lock.
|
|
39
|
+
*/
|
|
40
|
+
export declare function updateFleetSharedDeviceStateAsync(device: string, patch: FleetSharedStatePatch, userAgentsDir?: string): Promise<{
|
|
41
|
+
changed: boolean;
|
|
42
|
+
path: string;
|
|
43
|
+
}>;
|
|
37
44
|
/** Read every valid peer-owned state file; malformed peers fail separately. */
|
|
38
45
|
export declare function readFleetSharedDeviceStates(userAgentsDir?: string): FleetSharedStateReadResult;
|
|
@@ -8,9 +8,10 @@
|
|
|
8
8
|
* remains the one path that may carry secret material.
|
|
9
9
|
*/
|
|
10
10
|
import * as fs from 'node:fs';
|
|
11
|
+
import * as fsp from 'node:fs/promises';
|
|
11
12
|
import * as path from 'node:path';
|
|
12
13
|
import { assertValidDeviceName } from './devices/registry.js';
|
|
13
|
-
import { atomicWriteFileSync, ensureLockTarget, withFileLock } from './fs-atomic.js';
|
|
14
|
+
import { atomicWriteFileSync, ensureLockTarget, withFileLock, withFileLockAsync } from './fs-atomic.js';
|
|
14
15
|
import { getUserAgentsDir } from './state.js';
|
|
15
16
|
export const FLEET_SHARED_STATE_VERSION = 1;
|
|
16
17
|
export const FLEET_SHARED_STATE_FILE = 'daemon-state.json';
|
|
@@ -43,32 +44,61 @@ function parseFleetSharedDeviceState(raw, owner) {
|
|
|
43
44
|
* inter-process lock. Stable serialization avoids dirtying the user repo when
|
|
44
45
|
* neither usage nor auth state changed.
|
|
45
46
|
*/
|
|
47
|
+
/** Merge `patch` onto the current on-disk state; returns the serialized next state, or null when unchanged. Shared by the sync and async writers. */
|
|
48
|
+
function mergeFleetState(currentRaw, device, patch) {
|
|
49
|
+
let current = { version: FLEET_SHARED_STATE_VERSION, device };
|
|
50
|
+
const trimmed = currentRaw.trim();
|
|
51
|
+
if (trimmed) {
|
|
52
|
+
try {
|
|
53
|
+
current = parseFleetSharedDeviceState(trimmed, device);
|
|
54
|
+
}
|
|
55
|
+
catch { /* owning device repairs its own malformed file; peers are never repaired here */ }
|
|
56
|
+
}
|
|
57
|
+
const next = {
|
|
58
|
+
...current,
|
|
59
|
+
...(patch.usage !== undefined ? { usage: patch.usage } : {}),
|
|
60
|
+
...(patch.auth !== undefined ? { auth: patch.auth } : {}),
|
|
61
|
+
version: FLEET_SHARED_STATE_VERSION,
|
|
62
|
+
device,
|
|
63
|
+
};
|
|
64
|
+
const serialized = `${JSON.stringify(next, null, 2)}\n`;
|
|
65
|
+
return { serialized, changed: currentRaw !== serialized };
|
|
66
|
+
}
|
|
46
67
|
export function updateFleetSharedDeviceState(device, patch, userAgentsDir = getUserAgentsDir()) {
|
|
47
68
|
const file = fleetSharedStatePath(device, userAgentsDir);
|
|
48
69
|
ensureLockTarget(file, '');
|
|
49
70
|
return withFileLock(file, () => {
|
|
50
|
-
let
|
|
51
|
-
version: FLEET_SHARED_STATE_VERSION,
|
|
52
|
-
device,
|
|
53
|
-
};
|
|
71
|
+
let raw = '';
|
|
54
72
|
try {
|
|
55
|
-
|
|
56
|
-
if (raw)
|
|
57
|
-
current = parseFleetSharedDeviceState(raw, device);
|
|
73
|
+
raw = fs.readFileSync(file, 'utf-8');
|
|
58
74
|
}
|
|
59
|
-
catch {
|
|
60
|
-
|
|
61
|
-
|
|
75
|
+
catch { /* missing → treat as empty */ }
|
|
76
|
+
const { serialized, changed } = mergeFleetState(raw, device, patch);
|
|
77
|
+
if (!changed)
|
|
78
|
+
return { changed: false, path: file };
|
|
79
|
+
atomicWriteFileSync(file, serialized, 'utf-8');
|
|
80
|
+
return { changed: true, path: file };
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Async, non-blocking twin of {@link updateFleetSharedDeviceState} for the
|
|
85
|
+
* daemon's usage-sync / auth-sync ticks (PHNX-3695). The sync version acquires
|
|
86
|
+
* the file lock with `sleepSync` (`Atomics.wait`), freezing the shared event
|
|
87
|
+
* loop for up to 30s under contention on EVERY tick; this uses
|
|
88
|
+
* `withFileLockAsync`. The under-lock read is async; the atomic write is a tiny
|
|
89
|
+
* bounded write held inside the lock.
|
|
90
|
+
*/
|
|
91
|
+
export async function updateFleetSharedDeviceStateAsync(device, patch, userAgentsDir = getUserAgentsDir()) {
|
|
92
|
+
const file = fleetSharedStatePath(device, userAgentsDir);
|
|
93
|
+
ensureLockTarget(file, '');
|
|
94
|
+
return withFileLockAsync(file, async () => {
|
|
95
|
+
let raw = '';
|
|
96
|
+
try {
|
|
97
|
+
raw = await fsp.readFile(file, 'utf-8');
|
|
62
98
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
...(patch.auth !== undefined ? { auth: patch.auth } : {}),
|
|
67
|
-
version: FLEET_SHARED_STATE_VERSION,
|
|
68
|
-
device,
|
|
69
|
-
};
|
|
70
|
-
const serialized = `${JSON.stringify(next, null, 2)}\n`;
|
|
71
|
-
if (fs.readFileSync(file, 'utf-8') === serialized)
|
|
99
|
+
catch { /* missing → treat as empty */ }
|
|
100
|
+
const { serialized, changed } = mergeFleetState(raw, device, patch);
|
|
101
|
+
if (!changed)
|
|
72
102
|
return { changed: false, path: file };
|
|
73
103
|
atomicWriteFileSync(file, serialized, 'utf-8');
|
|
74
104
|
return { changed: true, path: file };
|
package/dist/lib/fs-atomic.d.ts
CHANGED
|
@@ -42,3 +42,14 @@ export interface FileLockOptions {
|
|
|
42
42
|
acquireTimeoutMs?: number;
|
|
43
43
|
}
|
|
44
44
|
export declare function withFileLock<T>(filePath: string, fn: (heartbeat: () => void) => T, opts?: FileLockOptions): T;
|
|
45
|
+
/**
|
|
46
|
+
* Async counterpart of {@link withFileLock} for callers on the daemon's shared
|
|
47
|
+
* event loop (PHNX-3695). The sync version retries acquisition with
|
|
48
|
+
* {@link sleepSync} (`Atomics.wait`), which HALTS the thread for up to
|
|
49
|
+
* {@link LOCK_ACQUIRE_TIMEOUT_MS} on contention — on a daemon tick that freezes
|
|
50
|
+
* every service and the browser IPC server. This variant awaits proper-lockfile's
|
|
51
|
+
* async `lock()` and yields with a real timer between retries, so the loop keeps
|
|
52
|
+
* turning while a peer holds the lock. Same stale-break, same acquire budget,
|
|
53
|
+
* same compromised-lock surfacing.
|
|
54
|
+
*/
|
|
55
|
+
export declare function withFileLockAsync<T>(filePath: string, fn: (heartbeat: () => void) => Promise<T> | T, opts?: FileLockOptions): Promise<T>;
|
package/dist/lib/fs-atomic.js
CHANGED
|
@@ -126,3 +126,63 @@ export function withFileLock(filePath, fn, opts = {}) {
|
|
|
126
126
|
catch { /* already gone */ }
|
|
127
127
|
}
|
|
128
128
|
}
|
|
129
|
+
/**
|
|
130
|
+
* Async counterpart of {@link withFileLock} for callers on the daemon's shared
|
|
131
|
+
* event loop (PHNX-3695). The sync version retries acquisition with
|
|
132
|
+
* {@link sleepSync} (`Atomics.wait`), which HALTS the thread for up to
|
|
133
|
+
* {@link LOCK_ACQUIRE_TIMEOUT_MS} on contention — on a daemon tick that freezes
|
|
134
|
+
* every service and the browser IPC server. This variant awaits proper-lockfile's
|
|
135
|
+
* async `lock()` and yields with a real timer between retries, so the loop keeps
|
|
136
|
+
* turning while a peer holds the lock. Same stale-break, same acquire budget,
|
|
137
|
+
* same compromised-lock surfacing.
|
|
138
|
+
*/
|
|
139
|
+
export async function withFileLockAsync(filePath, fn, opts = {}) {
|
|
140
|
+
let release = null;
|
|
141
|
+
let lastError;
|
|
142
|
+
let compromised = null;
|
|
143
|
+
const staleMs = opts.staleMs ?? LOCK_STALE_MS;
|
|
144
|
+
const acquireTimeoutMs = opts.acquireTimeoutMs ?? LOCK_ACQUIRE_TIMEOUT_MS;
|
|
145
|
+
const deadline = Date.now() + acquireTimeoutMs;
|
|
146
|
+
for (let attempt = 0;; attempt++) {
|
|
147
|
+
try {
|
|
148
|
+
release = await lockfile.lock(filePath, {
|
|
149
|
+
stale: staleMs,
|
|
150
|
+
onCompromised: (err) => { compromised = err; },
|
|
151
|
+
});
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
catch (err) {
|
|
155
|
+
lastError = err;
|
|
156
|
+
if (Date.now() >= deadline)
|
|
157
|
+
break;
|
|
158
|
+
const backoff = Math.min(LOCK_RETRY_MIN_MS * (attempt + 1), LOCK_RETRY_MAX_MS);
|
|
159
|
+
await new Promise((r) => setTimeout(r, Math.min(backoff, Math.max(0, deadline - Date.now()))));
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (!release) {
|
|
163
|
+
const message = lastError instanceof Error ? lastError.message : String(lastError);
|
|
164
|
+
throw new Error(`Could not acquire lock for ${filePath} after ${acquireTimeoutMs}ms: ${message}`);
|
|
165
|
+
}
|
|
166
|
+
const lockDir = `${filePath}.lock`;
|
|
167
|
+
const heartbeat = () => {
|
|
168
|
+
try {
|
|
169
|
+
const now = new Date();
|
|
170
|
+
fs.utimesSync(lockDir, now, now);
|
|
171
|
+
}
|
|
172
|
+
catch { /* best effort */ }
|
|
173
|
+
};
|
|
174
|
+
try {
|
|
175
|
+
const result = await fn(heartbeat);
|
|
176
|
+
if (compromised) {
|
|
177
|
+
throw new Error(`Lock for ${filePath} was broken by another process while held: ` +
|
|
178
|
+
`${compromised.message}`);
|
|
179
|
+
}
|
|
180
|
+
return result;
|
|
181
|
+
}
|
|
182
|
+
finally {
|
|
183
|
+
try {
|
|
184
|
+
await release();
|
|
185
|
+
}
|
|
186
|
+
catch { /* already gone */ }
|
|
187
|
+
}
|
|
188
|
+
}
|
|
@@ -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.
|
|
@@ -30,18 +30,25 @@ export type RemoteExitState = {
|
|
|
30
30
|
* empty stdout, and a truncate-then-write mid-race is a sub-ms empty window).
|
|
31
31
|
*/
|
|
32
32
|
export declare function classifyExit(res: Pick<SshExecResult, 'code' | 'stdout' | 'timedOut'>): RemoteExitState;
|
|
33
|
+
export declare function readRemoteExit(target: string, remoteExit: string, timeoutMs?: number, identityFile?: string, remoteShell?: 'posix' | 'powershell'): RemoteExitState;
|
|
33
34
|
/**
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
35
|
+
* Async twin of {@link readRemoteExit} for the daemon's heartbeat tick — the ssh
|
|
36
|
+
* read runs on the shared event loop, so it MUST NOT block it with a synchronous
|
|
37
|
+
* `spawnSync('ssh', …)` (PHNX-3695). `sshExecAsync` applies the same
|
|
38
|
+
* timeout/kill-grace bound.
|
|
37
39
|
*/
|
|
38
|
-
export declare function
|
|
40
|
+
export declare function readRemoteExitAsync(target: string, remoteExit: string, timeoutMs?: number, identityFile?: string, remoteShell?: 'posix' | 'powershell'): Promise<RemoteExitState>;
|
|
39
41
|
/**
|
|
40
42
|
* Heal one record. Terminal records are immutable (and never re-probed); a
|
|
41
43
|
* `running` record is resolved to completed/failed only when the remote `.exit`
|
|
42
44
|
* holds a code. Returns the (possibly updated) task.
|
|
43
45
|
*/
|
|
44
46
|
export declare function reconcileTask(task: HostTask): HostTask;
|
|
47
|
+
/**
|
|
48
|
+
* Async twin of {@link reconcileTask} for the daemon heartbeat tick (PHNX-3695):
|
|
49
|
+
* identical healing logic, non-blocking ssh read via {@link readRemoteExitAsync}.
|
|
50
|
+
*/
|
|
51
|
+
export declare function reconcileTaskAsync(task: HostTask): Promise<HostTask>;
|
|
45
52
|
/**
|
|
46
53
|
* Heal a list of records for a listing (`agents devices ps`). Only `running` tasks
|
|
47
54
|
* are probed; each host is reachability-checked ONCE (deduped by target) so a
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* record. We only ever CONFIRM completion; an unreachable host or an absent
|
|
11
11
|
* `.exit` leaves the record `running` (we never guess failure).
|
|
12
12
|
*/
|
|
13
|
-
import { sshExec } from '../ssh-exec.js';
|
|
13
|
+
import { sshExec, sshExecAsync } from '../ssh-exec.js';
|
|
14
14
|
import { updateTask, terminalPatch } from './tasks.js';
|
|
15
15
|
import { encodePowershell } from './remote-cmd.js';
|
|
16
16
|
/**
|
|
@@ -36,15 +36,29 @@ export function classifyExit(res) {
|
|
|
36
36
|
* $HOME-prefixed path with a safe (hex) basename — intentionally unquoted so the
|
|
37
37
|
* remote shell expands $HOME (same contract as progress.ts's fetch).
|
|
38
38
|
*/
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
function remoteExitCommand(remoteExit, remoteShell) {
|
|
40
|
+
return remoteShell === 'powershell'
|
|
41
41
|
? `powershell -NoProfile -EncodedCommand ${encodePowershell(`$path = Join-Path $HOME '${remoteExit.replace(/^\$HOME\//, '').replace(/'/g, "''")}'; if (Test-Path -LiteralPath $path) { Get-Content -LiteralPath $path -Raw }`)}`
|
|
42
42
|
: `cat ${remoteExit} 2>/dev/null`;
|
|
43
|
-
|
|
43
|
+
}
|
|
44
|
+
function remoteExitSshOpts(timeoutMs, identityFile) {
|
|
45
|
+
return {
|
|
44
46
|
timeoutMs,
|
|
45
47
|
multiplex: true,
|
|
46
48
|
extraSshArgs: identityFile ? ['-i', identityFile, '-o', 'IdentitiesOnly=yes'] : undefined,
|
|
47
|
-
}
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
export function readRemoteExit(target, remoteExit, timeoutMs = 6000, identityFile, remoteShell = 'posix') {
|
|
52
|
+
return classifyExit(sshExec(target, remoteExitCommand(remoteExit, remoteShell), remoteExitSshOpts(timeoutMs, identityFile)));
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Async twin of {@link readRemoteExit} for the daemon's heartbeat tick — the ssh
|
|
56
|
+
* read runs on the shared event loop, so it MUST NOT block it with a synchronous
|
|
57
|
+
* `spawnSync('ssh', …)` (PHNX-3695). `sshExecAsync` applies the same
|
|
58
|
+
* timeout/kill-grace bound.
|
|
59
|
+
*/
|
|
60
|
+
export async function readRemoteExitAsync(target, remoteExit, timeoutMs = 6000, identityFile, remoteShell = 'posix') {
|
|
61
|
+
return classifyExit(await sshExecAsync(target, remoteExitCommand(remoteExit, remoteShell), remoteExitSshOpts(timeoutMs, identityFile)));
|
|
48
62
|
}
|
|
49
63
|
/**
|
|
50
64
|
* Heal one record. Terminal records are immutable (and never re-probed); a
|
|
@@ -59,6 +73,18 @@ export function reconcileTask(task) {
|
|
|
59
73
|
return task;
|
|
60
74
|
return updateTask(task.id, terminalPatch(st.code)) ?? task;
|
|
61
75
|
}
|
|
76
|
+
/**
|
|
77
|
+
* Async twin of {@link reconcileTask} for the daemon heartbeat tick (PHNX-3695):
|
|
78
|
+
* identical healing logic, non-blocking ssh read via {@link readRemoteExitAsync}.
|
|
79
|
+
*/
|
|
80
|
+
export async function reconcileTaskAsync(task) {
|
|
81
|
+
if (task.status !== 'running')
|
|
82
|
+
return task;
|
|
83
|
+
const st = await readRemoteExitAsync(task.target, task.remoteExit, 6000, task.identityFile, task.remoteShell);
|
|
84
|
+
if (st.state !== 'done')
|
|
85
|
+
return task;
|
|
86
|
+
return updateTask(task.id, terminalPatch(st.code)) ?? task;
|
|
87
|
+
}
|
|
62
88
|
/**
|
|
63
89
|
* Heal a list of records for a listing (`agents devices ps`). Only `running` tasks
|
|
64
90
|
* are probed; each host is reachability-checked ONCE (deduped by target) so a
|
|
@@ -177,7 +177,7 @@ const globalBinaryStrategy = {
|
|
|
177
177
|
};
|
|
178
178
|
/**
|
|
179
179
|
* Harnesses installed by an official script that keeps a per-installation copy
|
|
180
|
-
* or symlink farm (grok, cursor, antigravity, hermes,
|
|
180
|
+
* or symlink farm (grok, cursor, antigravity, hermes, goose, …). The
|
|
181
181
|
* vendor artifact lands in a global location the installer owns, so the fetch
|
|
182
182
|
* itself is not reversible; what IS per-installation — the version dir's binary
|
|
183
183
|
* link farm — is staged and swapped so a failed re-import cannot strand the
|
package/dist/lib/mcp-registry.js
CHANGED
|
@@ -108,13 +108,6 @@ export const MCP_TARGETS = {
|
|
|
108
108
|
project: (cwd) => path.join(cwd, '.hermes', 'config.yaml'),
|
|
109
109
|
format: 'yaml',
|
|
110
110
|
},
|
|
111
|
-
pi: {
|
|
112
|
-
// omp reads user-scope MCP from ~/.omp/agent/.mcp.json and project MCP from
|
|
113
|
-
// <root>/.mcp.json (both the Claude schema).
|
|
114
|
-
home: (h) => path.join(h, '.omp', 'agent', '.mcp.json'),
|
|
115
|
-
project: (cwd) => path.join(cwd, '.mcp.json'),
|
|
116
|
-
format: 'claude-json',
|
|
117
|
-
},
|
|
118
111
|
muse: {
|
|
119
112
|
home: (h) => path.join(h, '.config', 'muse', 'settings.json'),
|
|
120
113
|
project: (cwd) => path.join(cwd, '.muse', 'settings.json'),
|
|
@@ -143,12 +136,6 @@ export const MCP_TARGETS = {
|
|
|
143
136
|
format: null,
|
|
144
137
|
unsupportedReason: 'Amp nests MCP under a settings key rather than a top-level map; schema not verified',
|
|
145
138
|
},
|
|
146
|
-
kiro: {
|
|
147
|
-
home: (h) => path.join(h, '.kiro', 'settings', 'mcp.json'),
|
|
148
|
-
project: (cwd) => path.join(cwd, '.kiro', 'settings', 'mcp.json'),
|
|
149
|
-
format: null,
|
|
150
|
-
unsupportedReason: 'mcp.json schema not verified against an installed Kiro',
|
|
151
|
-
},
|
|
152
139
|
goose: {
|
|
153
140
|
home: (h) => path.join(h, '.config', 'goose', 'config.yaml'),
|
|
154
141
|
project: (cwd) => path.join(cwd, '.goose', 'config.yaml'),
|
package/dist/lib/mcp.js
CHANGED
|
@@ -529,8 +529,8 @@ export function writeMcpConfig(agentId, configPath, servers, mode = 'overwrite')
|
|
|
529
529
|
}
|
|
530
530
|
const format = target.format;
|
|
531
531
|
switch (format) {
|
|
532
|
-
// Claude's `{ "mcpServers": {...} }` schema, shared by cursor, kimi, droid
|
|
533
|
-
//
|
|
532
|
+
// Claude's `{ "mcpServers": {...} }` schema, shared by cursor, kimi, droid
|
|
533
|
+
// and Oz (.warp/.mcp.json): stdio carries command/args/env,
|
|
534
534
|
// remote carries url + optional headers.
|
|
535
535
|
case 'claude-json': {
|
|
536
536
|
const config = readExistingConfig(configPath, JSON.parse);
|
package/dist/lib/model-tiers.js
CHANGED
|
@@ -127,7 +127,7 @@ function rankCatalog(agent, models) {
|
|
|
127
127
|
// Cursor and Pi (Oh My Pi) are cross-provider aggregators: their ids are
|
|
128
128
|
// provider-qualified (`anthropic/…`, `openai/…`) and span vendors, so price of
|
|
129
129
|
// the normalized base id is the only unifying rank signal.
|
|
130
|
-
const aggregator = agent === 'cursor'
|
|
130
|
+
const aggregator = agent === 'cursor';
|
|
131
131
|
const scored = usable.map((m) => {
|
|
132
132
|
const rawId = m.id;
|
|
133
133
|
const baseId = aggregator ? normalizeAggregatorId(rawId) : rawId;
|