@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.
Files changed (130) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +34 -9
  3. package/dist/bootstrap.js +4 -4
  4. package/dist/commands/accounts.js +23 -7
  5. package/dist/commands/exec.js +2 -2
  6. package/dist/commands/import.js +2 -2
  7. package/dist/commands/models.js +2 -2
  8. package/dist/commands/permissions.js +2 -2
  9. package/dist/commands/repo.js +2 -2
  10. package/dist/commands/rules.js +1 -1
  11. package/dist/commands/send.js +9 -3
  12. package/dist/commands/sessions-export.d.ts +5 -1
  13. package/dist/commands/sessions-export.js +100 -24
  14. package/dist/commands/sessions-import.d.ts +2 -1
  15. package/dist/commands/sessions-import.js +85 -21
  16. package/dist/commands/traces.js +1 -1
  17. package/dist/lib/account-capabilities.js +0 -2
  18. package/dist/lib/account-registry.d.ts +3 -3
  19. package/dist/lib/account-registry.js +25 -7
  20. package/dist/lib/accounting/usage-sync.d.ts +1 -1
  21. package/dist/lib/accounting/usage-sync.js +3 -3
  22. package/dist/lib/acp/client.d.ts +1 -1
  23. package/dist/lib/acp/client.js +12 -1
  24. package/dist/lib/acp/harnesses.js +1 -1
  25. package/dist/lib/add-dir.js +0 -2
  26. package/dist/lib/agent-cli-commands.js +0 -2
  27. package/dist/lib/agent-spec/agents.d.ts +1 -1
  28. package/dist/lib/agent-spec/agents.js +2 -83
  29. package/dist/lib/browser/ipc.d.ts +34 -0
  30. package/dist/lib/browser/ipc.js +149 -20
  31. package/dist/lib/browser/remote-control.d.ts +6 -3
  32. package/dist/lib/browser/remote-control.js +6 -3
  33. package/dist/lib/browser/service.d.ts +4 -1
  34. package/dist/lib/browser/service.js +7 -1
  35. package/dist/lib/browser/types.d.ts +3 -1
  36. package/dist/lib/channels/registry.d.ts +6 -0
  37. package/dist/lib/channels/send.d.ts +11 -2
  38. package/dist/lib/channels/send.js +11 -2
  39. package/dist/lib/cloud/rush.d.ts +10 -2
  40. package/dist/lib/cloud/rush.js +15 -9
  41. package/dist/lib/daemon/auth-sync-service.js +1 -1
  42. package/dist/lib/daemon/browser-task-reap-service.js +1 -1
  43. package/dist/lib/daemon/daemon.js +41 -11
  44. package/dist/lib/daemon/heartbeat-service.js +3 -3
  45. package/dist/lib/daemon/keychain-reap-service.js +1 -1
  46. package/dist/lib/daemon/runner.d.ts +18 -1
  47. package/dist/lib/daemon/runner.js +237 -80
  48. package/dist/lib/daemon/self-heal-service.js +13 -3
  49. package/dist/lib/daemon/self-update-service.d.ts +174 -0
  50. package/dist/lib/daemon/self-update-service.js +353 -0
  51. package/dist/lib/daemon/state-dir-check-service.js +3 -3
  52. package/dist/lib/daemon/usage-sync-service.js +1 -1
  53. package/dist/lib/daemon/watchdog-service.js +4 -4
  54. package/dist/lib/daemon-services.d.ts +1 -1
  55. package/dist/lib/daemon-services.js +5 -0
  56. package/dist/lib/device-config.d.ts +12 -1
  57. package/dist/lib/device-config.js +63 -13
  58. package/dist/lib/exec-bounded.d.ts +52 -0
  59. package/dist/lib/exec-bounded.js +113 -0
  60. package/dist/lib/exec.d.ts +2 -2
  61. package/dist/lib/exec.js +3 -34
  62. package/dist/lib/feed/events.d.ts +22 -14
  63. package/dist/lib/feed/events.js +84 -44
  64. package/dist/lib/feed-broadcast.d.ts +12 -29
  65. package/dist/lib/feed-broadcast.js +28 -27
  66. package/dist/lib/fleet-shared-state.d.ts +12 -5
  67. package/dist/lib/fleet-shared-state.js +50 -20
  68. package/dist/lib/fs-atomic.d.ts +11 -0
  69. package/dist/lib/fs-atomic.js +60 -0
  70. package/dist/lib/hooks/install.js +0 -87
  71. package/dist/lib/hosts/reconcile.d.ts +11 -4
  72. package/dist/lib/hosts/reconcile.js +31 -5
  73. package/dist/lib/installations/strategies.js +1 -1
  74. package/dist/lib/mcp-registry.js +0 -13
  75. package/dist/lib/mcp.js +2 -2
  76. package/dist/lib/model-tiers.js +1 -1
  77. package/dist/lib/models.js +0 -63
  78. package/dist/lib/notify.d.ts +11 -0
  79. package/dist/lib/notify.js +17 -4
  80. package/dist/lib/owner-message.d.ts +46 -3
  81. package/dist/lib/owner-message.js +26 -6
  82. package/dist/lib/permissions-registry.d.ts +0 -2
  83. package/dist/lib/permissions-registry.js +3 -50
  84. package/dist/lib/permissions.d.ts +3 -17
  85. package/dist/lib/permissions.js +4 -73
  86. package/dist/lib/project-resources.d.ts +12 -0
  87. package/dist/lib/project-resources.js +129 -0
  88. package/dist/lib/routine-process-cleanup.d.ts +2 -2
  89. package/dist/lib/routine-process-cleanup.js +45 -34
  90. package/dist/lib/rush-session.d.ts +19 -0
  91. package/dist/lib/rush-session.js +24 -0
  92. package/dist/lib/secrets/drivers/rush.js +2 -1
  93. package/dist/lib/secrets/reaper.d.ts +2 -2
  94. package/dist/lib/secrets/reaper.js +13 -10
  95. package/dist/lib/secrets/reserved-sync.d.ts +1 -1
  96. package/dist/lib/secrets/reserved-sync.js +4 -4
  97. package/dist/lib/self-update.d.ts +21 -8
  98. package/dist/lib/self-update.js +54 -31
  99. package/dist/lib/session/cloud.js +2 -1
  100. package/dist/lib/session/sync/backend.d.ts +61 -0
  101. package/dist/lib/session/sync/backend.js +89 -0
  102. package/dist/lib/session/sync/managed-config.d.ts +29 -0
  103. package/dist/lib/session/sync/managed-config.js +23 -0
  104. package/dist/lib/session/sync/managed-key.d.ts +45 -0
  105. package/dist/lib/session/sync/managed-key.js +128 -0
  106. package/dist/lib/session/sync/net-client.d.ts +65 -0
  107. package/dist/lib/session/sync/net-client.js +117 -0
  108. package/dist/lib/session/sync/provision.d.ts +19 -0
  109. package/dist/lib/session/sync/provision.js +38 -0
  110. package/dist/lib/session/sync/r2.d.ts +5 -2
  111. package/dist/lib/session/sync/r2.js +5 -2
  112. package/dist/lib/session/sync/worker-template.d.ts +6 -0
  113. package/dist/lib/session/sync/worker-template.js +847 -0
  114. package/dist/lib/sink-format.d.ts +34 -0
  115. package/dist/lib/sink-format.js +17 -0
  116. package/dist/lib/staleness/detectors/permissions.js +0 -20
  117. package/dist/lib/staleness/writers/commands.js +1 -1
  118. package/dist/lib/staleness/writers/hooks.js +2 -2
  119. package/dist/lib/subagents-registry.js +2 -12
  120. package/dist/lib/subagents.d.ts +0 -10
  121. package/dist/lib/subagents.js +0 -37
  122. package/dist/lib/tmux/orphan-reap.js +6 -4
  123. package/dist/lib/tmux/session.js +4 -1
  124. package/dist/lib/traces/classify.d.ts +8 -1
  125. package/dist/lib/traces/insights.d.ts +13 -1
  126. package/dist/lib/traces/insights.js +78 -3
  127. package/dist/lib/traces/sync.js +8 -3
  128. package/dist/lib/traces/worker-template.js +9 -5
  129. package/dist/lib/types.d.ts +1 -1
  130. 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 (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({
@@ -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 current = {
51
- version: FLEET_SHARED_STATE_VERSION,
52
- device,
53
- };
71
+ let raw = '';
54
72
  try {
55
- const raw = fs.readFileSync(file, 'utf-8').trim();
56
- if (raw)
57
- current = parseFleetSharedDeviceState(raw, device);
73
+ raw = fs.readFileSync(file, 'utf-8');
58
74
  }
59
- catch {
60
- // The owning device repairs its own malformed file from authoritative
61
- // local state. Peer files are never repaired by this writer.
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
- const next = {
64
- ...current,
65
- ...(patch.usage !== undefined ? { usage: patch.usage } : {}),
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 };
@@ -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>;
@@ -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
- * Read a task's remote `.exit` over ssh and classify it. `remoteExit` is a
35
- * $HOME-prefixed path with a safe (hex) basename — intentionally unquoted so the
36
- * remote shell expands $HOME (same contract as progress.ts's fetch).
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 readRemoteExit(target: string, remoteExit: string, timeoutMs?: number, identityFile?: string, remoteShell?: 'posix' | 'powershell'): RemoteExitState;
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
- export function readRemoteExit(target, remoteExit, timeoutMs = 6000, identityFile, remoteShell = 'posix') {
40
- const command = remoteShell === 'powershell'
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
- return classifyExit(sshExec(target, command, {
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, kiro, goose, …). The
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
@@ -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
- // omp (.mcp.json) and Oz (.warp/.mcp.json): stdio carries command/args/env,
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);
@@ -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' || agent === 'pi';
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;