@phnx-labs/agents-cli 1.22.88 → 1.22.90

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.
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Attention desktop banners as a supervised periodic service (PHNX-4004).
3
+ *
4
+ * Each tick reconciles this host's live sessions into attention items through
5
+ * the CLI-owned reconciler (feed/attention.ts — never re-deriving detection) and
6
+ * posts ONE actionable desktop banner per attention key not yet notified. The
7
+ * banner carries the category, the attention key, the session id, and the
8
+ * answerable choices, so the macOS companion can offer Approve / Approve for
9
+ * session / Deny (permission), the options plus a typed reply (question),
10
+ * Approve / Send back (plan review), or Open terminal (a stall/failure), and
11
+ * route the answer back through `agents feed answer <key> --choice <id>`.
12
+ *
13
+ * Idempotency is a filesystem ledger, not memory: one sidecar file per notified
14
+ * key under `~/.agents/.history/feed/notified/`, so a daemon restart never
15
+ * re-posts a banner already sent — the ledger is the truth. It is pruned with
16
+ * the feed's 14-day retention rule.
17
+ *
18
+ * Reader-independent: unlike the session-state publisher this does NOT gate on a
19
+ * live `sessions watch` reader — an attention banner must fire whether or not
20
+ * anyone is watching the stream. `done` banners are deliberately NOT produced
21
+ * here; they ride the run process's own exit (`run-notify.ts`).
22
+ */
23
+ import * as fs from 'fs';
24
+ import * as path from 'path';
25
+ import { atomicWriteFile } from '../fs-atomic.js';
26
+ import { getFeedDir } from '../state.js';
27
+ import { machineId } from '../machine-id.js';
28
+ import { getActiveSessions } from '../session/active.js';
29
+ import { blockIdForSession, readBlock, readResolution } from '../feed/feed.js';
30
+ import { reconcileAttention, harnessOf } from '../feed/attention.js';
31
+ import { notifyDesktop } from '../menubar/notify-desktop.js';
32
+ import { BasePeriodicService } from './service.js';
33
+ const ATTENTION_NOTIFY_TICK_MS = 5_000;
34
+ const ATTENTION_NOTIFY_DEADLINE_MS = 10_000;
35
+ /** Feed retention: a notified-ledger sidecar older than this is pruned. */
36
+ const LEDGER_RETENTION_MS = 14 * 24 * 60 * 60 * 1000;
37
+ /** Prune the ledger at most this often — the dir is tiny, but a dir walk every 5s is waste. */
38
+ const LEDGER_PRUNE_EVERY_MS = 60 * 60 * 1000;
39
+ /** UNUserNotificationCenter caps action buttons; the argv contract is ≤ 6 choices. */
40
+ const MAX_CHOICES = 6;
41
+ /** Body cap — a banner truncates anyway, and a wall of text is noise. */
42
+ const BODY_MAX = 200;
43
+ /** Which attention kinds produce a banner, and the category + title verb each carries. */
44
+ const BANNER_KINDS = {
45
+ permission: { category: 'permission', label: 'Command approval' },
46
+ question: { category: 'question', label: 'Question' },
47
+ plan_review: { category: 'plan_review', label: 'Plan review' },
48
+ stall: { category: 'failure', label: 'Failed' },
49
+ failure: { category: 'failure', label: 'Failed' },
50
+ };
51
+ function shorten(text, max = BODY_MAX) {
52
+ const flat = text.replace(/\s+/g, ' ').trim();
53
+ return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
54
+ }
55
+ /**
56
+ * The banner for one attention item, or `undefined` when its kind does not
57
+ * notify (`done` comes from the run process; `declared`/`review` are surfaced in
58
+ * the feed, not as a native banner). Pure — the service and its test both build
59
+ * through here, so what ships is what is asserted.
60
+ */
61
+ export function buildAttentionNotification(item, session) {
62
+ const spec = BANNER_KINDS[item.kind];
63
+ if (!spec)
64
+ return undefined;
65
+ const shortId = item.sessionId ? item.sessionId.slice(0, 8) : 'session';
66
+ const who = session.label?.trim() || session.title?.trim() || shortId;
67
+ const n = {
68
+ title: `${who} · ${spec.label}`,
69
+ body: shorten(item.question?.text?.trim() || spec.label),
70
+ category: spec.category,
71
+ key: item.key,
72
+ };
73
+ const agent = harnessOf(session);
74
+ if (agent)
75
+ n.agent = agent;
76
+ if (item.sessionId)
77
+ n.sessionId = item.sessionId;
78
+ const subtitle = [item.host, item.project].filter(Boolean).join(' · ');
79
+ if (subtitle)
80
+ n.subtitle = subtitle;
81
+ // A stall/failure has no answerable option list — it offers one button that
82
+ // opens the session's terminal. Everything else carries the reconciled choices.
83
+ const choices = spec.category === 'failure'
84
+ ? [{ id: 'open-terminal', label: 'Open terminal' }]
85
+ : (item.choices ?? []).slice(0, MAX_CHOICES).map((c) => ({ id: c.id, label: c.label }));
86
+ if (choices.length)
87
+ n.choices = choices;
88
+ return n;
89
+ }
90
+ export class AttentionNotifyService extends BasePeriodicService {
91
+ id = 'attention-notify';
92
+ intervalMs = ATTENTION_NOTIFY_TICK_MS;
93
+ deadlineMs = ATTENTION_NOTIFY_DEADLINE_MS;
94
+ getSessions;
95
+ notify;
96
+ feedRoot;
97
+ ledgerDir;
98
+ now;
99
+ lastPruneMs = 0;
100
+ constructor(opts = {}) {
101
+ super();
102
+ this.getSessions = opts.getSessions ?? (() => getActiveSessions());
103
+ this.notify = opts.notify ?? notifyDesktop;
104
+ this.feedRoot = opts.feedRoot;
105
+ this.ledgerDir = opts.ledgerDir ?? path.join(opts.feedRoot ?? getFeedDir(), 'notified');
106
+ this.now = opts.now ?? Date.now;
107
+ }
108
+ async onStart(_ctx) {
109
+ // No handles to open — each tick re-reads sessions and the ledger.
110
+ }
111
+ async onStop() {
112
+ // Nothing to release — the supervisor's timer teardown is the only cleanup.
113
+ }
114
+ async onTick(ctx) {
115
+ const sessions = await this.getSessions();
116
+ const host = machineId();
117
+ for (const session of sessions) {
118
+ if (!session.sessionId)
119
+ continue;
120
+ // ActiveSession.host names the terminal app; the reconciler's host is the
121
+ // device scope, so the attention key is fleet-routable (mirrors watch.ts).
122
+ const projected = { ...session, host };
123
+ const blockId = blockIdForSession(session.sessionId);
124
+ const item = reconcileAttention({
125
+ block: readBlock(blockId, this.feedRoot),
126
+ session: projected,
127
+ resolution: readResolution(blockId, this.feedRoot),
128
+ nowMs: this.now(),
129
+ });
130
+ if (!item || !BANNER_KINDS[item.kind])
131
+ continue;
132
+ if (await this.hasNotified(item.key))
133
+ continue;
134
+ const notification = buildAttentionNotification(item, projected);
135
+ if (!notification)
136
+ continue;
137
+ this.notify(notification);
138
+ await this.markNotified(item.key);
139
+ ctx.log('INFO', `attention-notify: posted ${item.kind} banner for ${item.key}`);
140
+ }
141
+ await this.pruneLedger();
142
+ }
143
+ /** Filesystem-safe sidecar name for an attention key (`host/session/generation`). */
144
+ ledgerPath(key) {
145
+ return path.join(this.ledgerDir, encodeURIComponent(key));
146
+ }
147
+ async hasNotified(key) {
148
+ // fs/promises has no existsSync; a stat that rejects with ENOENT is the miss.
149
+ try {
150
+ await fs.promises.stat(this.ledgerPath(key));
151
+ return true;
152
+ }
153
+ catch {
154
+ return false;
155
+ }
156
+ }
157
+ async markNotified(key) {
158
+ await fs.promises.mkdir(this.ledgerDir, { recursive: true });
159
+ await atomicWriteFile(this.ledgerPath(key), JSON.stringify({ key, postedAt: new Date(this.now()).toISOString() }), 'utf-8');
160
+ }
161
+ async pruneLedger() {
162
+ const nowMs = this.now();
163
+ if (nowMs - this.lastPruneMs < LEDGER_PRUNE_EVERY_MS)
164
+ return;
165
+ this.lastPruneMs = nowMs;
166
+ let names;
167
+ try {
168
+ names = await fs.promises.readdir(this.ledgerDir);
169
+ }
170
+ catch {
171
+ return; // No ledger dir yet — nothing to prune.
172
+ }
173
+ for (const name of names) {
174
+ const filePath = path.join(this.ledgerDir, name);
175
+ try {
176
+ const stat = await fs.promises.stat(filePath);
177
+ if (nowMs - stat.mtimeMs > LEDGER_RETENTION_MS)
178
+ await fs.promises.rm(filePath, { force: true });
179
+ }
180
+ catch {
181
+ // A file that vanished mid-walk is already gone; skip it.
182
+ }
183
+ }
184
+ }
185
+ }
@@ -36,6 +36,14 @@ export class AuthSyncService extends BasePeriodicService {
36
36
  ctx.log('INFO', `auth-sync: provisioned worker slot(s) for ${slots.provisioned.join(', ')}`);
37
37
  for (const err of slots.errors)
38
38
  ctx.log('WARN', `auth-sync: worker slot ${err.accountId}: ${err.message}`);
39
+ // A row skipped for a key this box cannot read is the one silent outcome
40
+ // an operator needs to see: it is what "0 slots after the daemon restart"
41
+ // looked like on yosemite-m0 (2026-09-07), where the durable key was in
42
+ // the bundle but the `secrets` on the daemon's PATH could not decrypt it.
43
+ const waiting = slots.skipped.filter((s) => s.reason === 'durable key not synced yet');
44
+ if (waiting.length > 0) {
45
+ ctx.log('WARN', `auth-sync: ${waiting.length} registered account(s) have no readable durable key on this box yet; slot not provisioned (${waiting.map((s) => s.accountId).join(', ')})`);
46
+ }
39
47
  }
40
48
  catch (err) {
41
49
  ctx.log('WARN', `auth-sync: worker slot reconcile: ${err.message}`);
@@ -67,6 +75,8 @@ export class AuthSyncService extends BasePeriodicService {
67
75
  // the only transport — provisioning (above) writes only locally (invariant 1).
68
76
  try {
69
77
  const stores = await syncReservedStores();
78
+ if (stores.adopted.length > 0)
79
+ ctx.log('INFO', `auth-sync: adopted ${stores.adopted.length} legacy reserved item(s) into their bundle: ${stores.adopted.map((a) => `${a.bundle} ${a.key}`).join(', ')}`);
70
80
  for (const p of stores.pushed)
71
81
  ctx.log('INFO', `auth-sync: pushed ${p.bundle} (${p.keys.length} key(s)) to ${p.device}`);
72
82
  for (const err of stores.errors)
@@ -45,6 +45,7 @@ import { AuthSyncService } from './auth-sync-service.js';
45
45
  import { UsageSyncService } from './usage-sync-service.js';
46
46
  import { StateDirCheckService } from './state-dir-check-service.js';
47
47
  import { SessionStateService } from './session-state-service.js';
48
+ import { AttentionNotifyService } from './attention-notify-service.js';
48
49
  import { WebhookReceiverService } from './webhook-receiver-service.js';
49
50
  import { HeartbeatService } from './heartbeat-service.js';
50
51
  import { TmuxReapService } from './tmux-reap-service.js';
@@ -1051,6 +1052,14 @@ export async function runDaemon() {
1051
1052
  supervisor.register(new SessionSummarizerService());
1052
1053
  else
1053
1054
  log('INFO', 'Session summarizer service disabled');
1055
+ // Attention desktop banners (PHNX-4004) — posts one actionable native banner
1056
+ // per new attention key. Reader-independent: it fires whether or not a
1057
+ // `sessions watch` reader is present, and the notified-ledger is its
1058
+ // idempotency truth across daemon restarts.
1059
+ if (isEnabled('attention-notify'))
1060
+ supervisor.register(new AttentionNotifyService());
1061
+ else
1062
+ log('INFO', 'Attention-notify service disabled');
1054
1063
  // Watchdog, device-probe, and self-heal are all periodic services managed
1055
1064
  // by the ServiceSupervisor (RUSH-3193 P3). Each is gated the same way as
1056
1065
  // the socket services above; state-dir-check is registered separately,
@@ -61,7 +61,12 @@ interface NpmLatestMetadata {
61
61
  * branch exists in the exported logic itself.
62
62
  */
63
63
  export interface SelfUpdateDeps {
64
+ /** The version this process BOOTED with (memoized at startup). */
64
65
  currentVersion(): string;
66
+ /** The version of the install on disk right now — differs from `currentVersion` once another process upgraded it. */
67
+ installedVersion(): string;
68
+ /** True when the install on disk looks complete (see `installLooksSettled`) — the gate before relaunching onto a version another process wrote. */
69
+ installedIsSettled(): boolean;
65
70
  isDevBuild(): boolean;
66
71
  detectShadow(): boolean;
67
72
  packageRoot(): string;
@@ -162,6 +167,10 @@ export declare function triggerSelfUpdateInBackground(ctx: DaemonContext, deps?:
162
167
  * reason, or `null` to proceed to the (backgrounded) install path.
163
168
  */
164
169
  export declare function selfUpdateSyncDeclineReason(deps?: SelfUpdateDeps): string | null;
170
+ export declare const DEV_BUILD_DECLINE = "dev build \u2014 self-update is a no-op";
171
+ export declare const SHADOW_DECLINE = "another agents binary shadows this install \u2014 self-update is a no-op";
172
+ /** True when the package on disk is a strictly newer release than the one this process booted with. */
173
+ export declare function installedIsNewerThanRunning(installed: string, running: string): boolean;
165
174
  export declare class SelfUpdateService extends BasePeriodicService {
166
175
  readonly id: DaemonServiceId;
167
176
  readonly intervalMs: number;
@@ -34,11 +34,11 @@
34
34
  * relaunch-loop on a broken install.
35
35
  */
36
36
  import { BasePeriodicService } from './service.js';
37
- import { getCliVersion } from '../version.js';
37
+ import { getCliVersion, getCliVersionFresh } from '../version.js';
38
38
  import { isDevVersionStamp } from '../startup/dev-build.js';
39
39
  import { detectAgentsBinaryShadows } from '../binary-shadow.js';
40
40
  import { compareVersions } from '../agent-spec/primitives.js';
41
- import { NPM_PACKAGE_NAME, deriveGlobalPrefix, detectPackageManager, downloadVerifiedTarball, ensureGlobalBinLinks, installPackageIntoPrefix, installPackageWithBun, refreshAliasShims, resolveRunningPackageRoot, sweepStaleInstallStaging, verifyInstalledVersion, } from '../self-update.js';
41
+ import { NPM_PACKAGE_NAME, deriveGlobalPrefix, detectPackageManager, downloadVerifiedTarball, ensureGlobalBinLinks, installPackageIntoPrefix, installLooksSettled, installPackageWithBun, refreshAliasShims, resolveRunningPackageRoot, sweepStaleInstallStaging, verifyInstalledVersion, } from '../self-update.js';
42
42
  import { tryAutoPullSystemRepo } from '../git.js';
43
43
  import { getSystemAgentsDir } from '../state.js';
44
44
  import { runUmbrellaSync } from '../sync-umbrella.js';
@@ -151,6 +151,8 @@ export async function installAndVerifyDefault(metadata, packageRoot, signal) {
151
151
  export function defaultSelfUpdateDeps() {
152
152
  return {
153
153
  currentVersion: () => getCliVersion(),
154
+ installedVersion: () => getCliVersionFresh(),
155
+ installedIsSettled: () => installLooksSettled(resolveRunningPackageRoot(__dirname)),
154
156
  isDevBuild: () => isDevVersionStamp(getCliVersion()),
155
157
  detectShadow: () => detectAgentsBinaryShadows().length > 0,
156
158
  packageRoot: () => resolveRunningPackageRoot(__dirname),
@@ -214,10 +216,33 @@ export async function attemptSelfUpdateAndExit(ctx, signal, deps = defaultSelfUp
214
216
  }
215
217
  }
216
218
  async function runSelfUpdateAttempt(ctx, signal, deps) {
219
+ // One decline source for the periodic tick and the on-demand IPC path
220
+ // (`selfUpdateSyncDeclineReason`): dev build; shadowed install — except when
221
+ // the install on disk is already newer than the running code, because a
222
+ // relaunch installs nothing and a second `agents` on the box is irrelevant to
223
+ // it (otherwise a shadowed worker never leaves the release it booted on).
217
224
  const syncDecline = selfUpdateSyncDeclineReason(deps);
218
225
  if (syncDecline)
219
226
  return { updated: false, reason: syncDecline };
227
+ // Another agents process (an operator's `agents` command auto-updating, an
228
+ // `agents upgrade`, the installer) may already have replaced the install
229
+ // under this daemon. The code on disk is then newer than the code in memory
230
+ // and there is nothing to download — that path verified its install before
231
+ // it returned. Exit for the OS-supervisor relaunch, once the install has
232
+ // settled: npm's reify is atomic but bun's is not, so a version bump alone
233
+ // could be bun mid-extraction (`installLooksSettled`). Waiting one tick is
234
+ // cheap; relaunching into a half-written tree is a supervisor crash-loop.
220
235
  const current = deps.currentVersion();
236
+ const installed = deps.installedVersion();
237
+ if (installedIsNewerThanRunning(installed, current)) {
238
+ if (!deps.installedIsSettled()) {
239
+ ctx.log('INFO', `self-update: the install on disk is ${installed} (this daemon is running ${current}) but it is still settling; relaunch deferred to the next tick`);
240
+ return { updated: false, reason: 'installed version still settling' };
241
+ }
242
+ ctx.log('INFO', `self-update: the install on disk is ${installed} but this daemon is still running ${current}; ` +
243
+ 'exiting for OS-supervisor relaunch onto the installed code');
244
+ return { updated: true };
245
+ }
221
246
  let metadata;
222
247
  try {
223
248
  metadata = await deps.fetchLatestMetadata(signal);
@@ -329,11 +354,24 @@ export function triggerSelfUpdateInBackground(ctx, deps = defaultSelfUpdateDeps(
329
354
  */
330
355
  export function selfUpdateSyncDeclineReason(deps = defaultSelfUpdateDeps()) {
331
356
  if (deps.isDevBuild())
332
- return 'dev build — self-update is a no-op';
333
- if (deps.detectShadow())
334
- return 'another agents binary shadows this install — self-update is a no-op';
357
+ return DEV_BUILD_DECLINE;
358
+ // A stale install is never declined by a shadow: the relaunch installs nothing.
359
+ // (Whether that install has SETTLED is the attempt's call, not a decline.)
360
+ if (deps.detectShadow() && !installedIsNewerThanRunning(deps.installedVersion(), deps.currentVersion())) {
361
+ return SHADOW_DECLINE;
362
+ }
335
363
  return null;
336
364
  }
365
+ export const DEV_BUILD_DECLINE = 'dev build — self-update is a no-op';
366
+ export const SHADOW_DECLINE = 'another agents binary shadows this install — self-update is a no-op';
367
+ /** True when the package on disk is a strictly newer release than the one this process booted with. */
368
+ export function installedIsNewerThanRunning(installed, running) {
369
+ if (installed === 'unknown' || running === 'unknown')
370
+ return false;
371
+ return compareVersions(installed, running) > 0;
372
+ }
373
+ /** The shadow decline is logged once per daemon process — a silent permanent no-op is how eight workers sat on stale code unnoticed (2026-09-07). */
374
+ let shadowDeclineLogged = false;
337
375
  export class SelfUpdateService extends BasePeriodicService {
338
376
  id = 'self-update';
339
377
  intervalMs = SELF_UPDATE_TICK_MS;
@@ -347,7 +385,14 @@ export class SelfUpdateService extends BasePeriodicService {
347
385
  }
348
386
  async onTick(ctx, signal) {
349
387
  const outcome = await attemptSelfUpdateAndExit(ctx, signal);
350
- if (outcome.updated)
388
+ if (outcome.updated) {
351
389
  scheduleSelfUpdateExit();
390
+ return;
391
+ }
392
+ if (outcome.reason === SHADOW_DECLINE && !shadowDeclineLogged) {
393
+ shadowDeclineLogged = true;
394
+ ctx.log('WARN', `self-update: ${outcome.reason}; this daemon will only move to a new release after another agents ` +
395
+ 'process upgrades the install (`agents doctor` lists the shadow copy)');
396
+ }
352
397
  }
353
398
  }
@@ -7,7 +7,7 @@
7
7
  * enabled without pulling in the whole daemon lifecycle.
8
8
  */
9
9
  /** Every service the daemon can host. IDs are kebab-case and stable. */
10
- export type DaemonServiceId = 'scheduler' | 'catchup' | 'monitors' | 'browser-ipc' | 'webhook-receiver' | 'self-heal' | 'self-update' | 'account-state' | 'account-auth' | 'watchdog' | 'device-probe' | 'state-dir-check' | 'session-index' | 'auth-sync' | 'usage-sync' | 'daemon-heartbeat' | 'tmux-reap' | 'browser-task-reap' | 'session-state' | 'session-summarizer' | 'harness-update';
10
+ export type DaemonServiceId = 'scheduler' | 'catchup' | 'monitors' | 'browser-ipc' | 'webhook-receiver' | 'self-heal' | 'self-update' | 'account-state' | 'account-auth' | 'watchdog' | 'device-probe' | 'state-dir-check' | 'session-index' | 'auth-sync' | 'usage-sync' | 'daemon-heartbeat' | 'tmux-reap' | 'browser-task-reap' | 'session-state' | 'session-summarizer' | 'attention-notify' | 'harness-update';
11
11
  /** Human-readable metadata for each service. */
12
12
  export interface DaemonServiceDef {
13
13
  id: DaemonServiceId;
@@ -102,6 +102,11 @@ export const DAEMON_SERVICES = [
102
102
  title: 'Session summarizer',
103
103
  description: 'Computes a per-session goal / progress checkpoints / checklist off the request path and delivers them on the session stream. Off unless summarizer.enabled and a local model endpoint are configured (PHNX-3939).',
104
104
  },
105
+ {
106
+ id: 'attention-notify',
107
+ title: 'Attention desktop banners',
108
+ description: 'Posts one actionable desktop banner per new attention item (question / permission / plan review / stall) so the macOS helper can answer it through agents feed answer (PHNX-4004).',
109
+ },
105
110
  {
106
111
  id: 'auth-sync',
107
112
  title: 'Auth bundle sync',
@@ -106,7 +106,7 @@ export async function claimAndRouteAttentionAnswer(input) {
106
106
  else {
107
107
  if (!route.inject || route.payload == null)
108
108
  throw new Error(`Incomplete ${route.kind} reply rail.`);
109
- const delivered = await injectIntoTerminal(route.inject, route.payload, { enter: true, combined: false });
109
+ const delivered = await injectIntoTerminal(route.inject, route.payload, { enter: route.enter ?? true, combined: false });
110
110
  if (!delivered.ok)
111
111
  throw new Error(delivered.error ?? `Failed to deliver over ${route.kind}.`);
112
112
  msgId = `inject-${Date.now()}`;
@@ -68,6 +68,8 @@ export interface PullRequestAttentionSignal {
68
68
  * share a fingerprint for batch triage.
69
69
  */
70
70
  export declare function attentionFingerprint(kind: AttentionKind, question?: BlockQuestion): string;
71
+ /** Harness id behind a session — the profile name when set, else the host process. */
72
+ export declare function harnessOf(session: ActiveSession): string;
71
73
  /**
72
74
  * Reconcile the feed block ledger, the session lifecycle, a CLI-supplied PR
73
75
  * signal, and the latest resolution tombstone into one attention item — or
Binary file
@@ -18,6 +18,13 @@ export declare function atomicWriteFileSync(filePath: string, content: string, o
18
18
  * only the `JSON.stringify`.
19
19
  */
20
20
  export declare function atomicWriteJsonSync(filePath: string, data: unknown): void;
21
+ /**
22
+ * Async counterpart of {@link atomicWriteFileSync} for callers on the daemon's
23
+ * shared event loop (PHNX-3695): a `writeFileSync`/`renameSync` on a tick freezes
24
+ * every service and the browser IPC server until the disk write returns. Same
25
+ * tmp-then-rename atomicity, but every syscall is awaited via `fs/promises`.
26
+ */
27
+ export declare function atomicWriteFile(filePath: string, content: string, options?: fs.WriteFileOptions): Promise<void>;
21
28
  /**
22
29
  * Acquires an exclusive proper-lockfile lock on filePath, runs fn, then
23
30
  * releases the lock. Retries with capped linear back-off until either the lock
@@ -64,6 +64,26 @@ export function atomicWriteFileSync(filePath, content, options = 'utf-8') {
64
64
  export function atomicWriteJsonSync(filePath, data) {
65
65
  atomicWriteFileSync(filePath, JSON.stringify(data, null, 2));
66
66
  }
67
+ /**
68
+ * Async counterpart of {@link atomicWriteFileSync} for callers on the daemon's
69
+ * shared event loop (PHNX-3695): a `writeFileSync`/`renameSync` on a tick freezes
70
+ * every service and the browser IPC server until the disk write returns. Same
71
+ * tmp-then-rename atomicity, but every syscall is awaited via `fs/promises`.
72
+ */
73
+ export async function atomicWriteFile(filePath, content, options = 'utf-8') {
74
+ const tmpPath = `${filePath}.tmp-${process.pid}-${randomBytes(8).toString('hex')}`;
75
+ await fs.promises.writeFile(tmpPath, content, options);
76
+ try {
77
+ await fs.promises.rename(tmpPath, filePath);
78
+ }
79
+ catch (err) {
80
+ try {
81
+ await fs.promises.unlink(tmpPath);
82
+ }
83
+ catch { /* best-effort cleanup */ }
84
+ throw err;
85
+ }
86
+ }
67
87
  export function withFileLock(filePath, fn, opts = {}) {
68
88
  let release = null;
69
89
  let lastError;
@@ -47,6 +47,33 @@ export interface DesktopNotification {
47
47
  * repeating the left one. macOS-only; osascript / notify-send carry no image.
48
48
  */
49
49
  agent?: string;
50
+ /**
51
+ * What the banner is asking of the operator, so the companion can pick the
52
+ * UNUserNotificationCenter category (its action buttons) — a `permission`
53
+ * banner offers Approve / Approve for session / Deny; a `question` offers the
54
+ * options plus a typed reply; a `plan_review` offers Approve / Send back; a
55
+ * `done`/`failure` banner offers open-report / open-pr / open-terminal.
56
+ * macOS-only (the companion decides the buttons); osascript / notify-send
57
+ * carry no actions. See {@link choices} for the answerable set.
58
+ */
59
+ category?: 'permission' | 'question' | 'plan_review' | 'done' | 'failure';
60
+ /**
61
+ * The attention key (`AttentionItem.key`) the companion hands to
62
+ * `agents feed answer <key>` when the operator picks a choice. The stable
63
+ * handle the reply rail resolves against; carried verbatim in argv.
64
+ */
65
+ key?: string;
66
+ /** Session this banner belongs to, so the companion can open/focus it. */
67
+ sessionId?: string;
68
+ /**
69
+ * Ordered, answerable choices (≤ 6). Each `id` is `[a-z0-9-]+` and is what the
70
+ * companion echoes back to `agents feed answer --choice <id>`; `label` is the
71
+ * plain-text button caption. Carried one `--choice <id>=<label>` per entry.
72
+ */
73
+ choices?: {
74
+ id: string;
75
+ label: string;
76
+ }[];
50
77
  }
51
78
  /** Argv for the "AGI Menu" one-shot notify mode. Exported for tests. */
52
79
  export declare function buildMenubarNotifyArgs(n: DesktopNotification): string[];
@@ -41,6 +41,17 @@ export function buildMenubarNotifyArgs(n) {
41
41
  args.push('--action', n.action);
42
42
  if (n.agent)
43
43
  args.push('--agent', n.agent);
44
+ if (n.category)
45
+ args.push('--category', n.category);
46
+ if (n.key)
47
+ args.push('--key', n.key);
48
+ if (n.sessionId)
49
+ args.push('--session', n.sessionId);
50
+ // Each choice is one argv pair `id=label`. ids are `[a-z0-9-]+` and labels are
51
+ // plain text; every field is its own argv entry (the child never sees a shell),
52
+ // so an `=` inside a label is inert — the companion splits on the FIRST `=`.
53
+ for (const c of n.choices ?? [])
54
+ args.push('--choice', `${c.id}=${c.label}`);
44
55
  return args;
45
56
  }
46
57
  /** AppleScript for the osascript degradation path. Exported for tests. */
@@ -12,6 +12,10 @@ export interface RunNotifyContext {
12
12
  host?: string;
13
13
  /** Clickable target — a PR/ticket URL the caller already knows. */
14
14
  url?: string;
15
+ /** Session id the run coined, so the banner can open/focus it. */
16
+ sessionId?: string;
17
+ /** Report/log path the run produced — becomes the `open-report` choice + `open:` action. */
18
+ reportPath?: string;
15
19
  }
16
20
  /**
17
21
  * The finish notification for one run. Pure — the exit handler and the tests
@@ -31,17 +31,36 @@ export function buildRunFinishNotification(ctx, exitCode) {
31
31
  const label = ctx.name?.trim() || ctx.agent;
32
32
  const project = ctx.cwd ? path.basename(ctx.cwd) : undefined;
33
33
  const where = [project, ctx.host].filter(Boolean).join(' · ');
34
+ const ok = exitCode === 0;
34
35
  const n = {
35
- title: exitCode === 0 ? `${label} finished` : `${label} failed`,
36
+ title: ok ? `${label} finished` : `${label} failed`,
36
37
  body: shorten(ctx.prompt?.trim() || `${ctx.agent} run`),
37
38
  // The harness that ran becomes the banner's right-hand avatar, so a finished
38
39
  // run is identifiable at a glance even when `--name` renamed the title.
39
40
  agent: ctx.agent,
41
+ // Categorize so the companion can offer the run's follow-up buttons; a
42
+ // failed run gets the failure category, a clean one the done category.
43
+ category: ok ? 'done' : 'failure',
40
44
  };
41
45
  if (where)
42
46
  n.subtitle = where;
43
- if (ctx.url)
47
+ if (ctx.sessionId)
48
+ n.sessionId = ctx.sessionId;
49
+ // The primary click target: a report opens the report file, else the PR url.
50
+ if (ctx.reportPath)
51
+ n.action = `open:${ctx.reportPath}`;
52
+ else if (ctx.url)
44
53
  n.action = `url:${ctx.url}`;
54
+ // Follow-up buttons the companion renders: open the report when one exists,
55
+ // open the PR when a url is known. `open-report` reuses the `open:` action
56
+ // resolution; `open-pr` opens `ctx.url`.
57
+ const choices = [];
58
+ if (ctx.reportPath)
59
+ choices.push({ id: 'open-report', label: 'Open report' });
60
+ if (ctx.url)
61
+ choices.push({ id: 'open-pr', label: 'Open PR' });
62
+ if (choices.length)
63
+ n.choices = choices;
45
64
  return n;
46
65
  }
47
66
  /**
@@ -171,6 +171,11 @@ export declare function readDeliveryMemo(root?: string): Record<string, string>;
171
171
  export declare function peerPresentKeys(peer: string, accounts: ReservedSyncAccount[], memo: Record<string, string>, legacyAuthReady: boolean): Record<string, ReadonlySet<string>>;
172
172
  export interface ReservedStoreSyncResult {
173
173
  publisher: string | null;
174
+ /** Legacy raw reserved items adopted into their bundle on this box before planning (local repair). */
175
+ adopted: Array<{
176
+ bundle: string;
177
+ key: string;
178
+ }>;
174
179
  pushed: Array<{
175
180
  device: string;
176
181
  bundle: string;
@@ -197,6 +202,18 @@ export interface ReservedStoreSyncDeps {
197
202
  localReady?: boolean;
198
203
  /** Does THIS publisher hold (bundle, key) to push? Defaults to a local bundle read. */
199
204
  hasLocalKey?: (bundle: string, key: string) => boolean;
205
+ /** Adopt pre-bundle raw reserved items locally before planning; defaults to `adoptLegacyReservedStoreItems`. */
206
+ adoptLegacy?: (meta: Pick<Meta, 'accounts' | 'deviceAccounts'>) => Promise<{
207
+ adopted: Array<{
208
+ bundle: string;
209
+ key: string;
210
+ }>;
211
+ errors: Array<{
212
+ bundle: string;
213
+ key: string;
214
+ message: string;
215
+ }>;
216
+ }>;
200
217
  push?: (bundle: string, host: string) => Promise<PushBundleResult>;
201
218
  sshTarget?: (device: DeviceProfile) => string;
202
219
  }
@@ -375,11 +375,24 @@ function defaultHasLocalKey(bundle, key) {
375
375
  * there is no second scheduler. The daemon runs this each tick.
376
376
  */
377
377
  export async function syncReservedStores(deps = {}) {
378
- const result = { publisher: null, pushed: [], skipped: [], errors: [] };
378
+ const result = { publisher: null, adopted: [], pushed: [], skipped: [], errors: [] };
379
379
  const localName = deps.localName ?? machineId();
380
380
  const localNorm = normalizeHost(localName);
381
381
  const root = deps.userAgentsDir ?? getUserAgentsDir();
382
382
  const meta = (deps.readMetaFn ?? readMeta)();
383
+ // Local repair first: a reserved key written by 1.22.84–1.22.89 is a bare
384
+ // file item with no bundle record, which the push below cannot read. Adopt
385
+ // it into its bundle here so the plan sees it; nothing leaves the box.
386
+ const adopt = deps.adoptLegacy ?? (async (m) => (await import('./auth-mint.js')).adoptLegacyReservedStoreItems(m));
387
+ try {
388
+ const adoption = await adopt(meta);
389
+ result.adopted.push(...adoption.adopted);
390
+ for (const err of adoption.errors)
391
+ result.errors.push({ device: localName, message: `adopt ${err.bundle} ${err.key}: ${err.message}` });
392
+ }
393
+ catch (err) {
394
+ result.errors.push({ device: localName, message: `adopt legacy reserved items: ${err.message}` });
395
+ }
383
396
  const hasLocalKey = deps.hasLocalKey ?? defaultHasLocalKey;
384
397
  const targets = reservedSyncTargets(meta).filter((t) => hasLocalKey(t.bundle, t.key));
385
398
  const devices = (deps.listDevices ?? defaultDevices)().filter((d) => normalizeHost(d.name) !== localNorm);