@phnx-labs/agents-cli 1.22.88 → 1.22.89

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 CHANGED
@@ -1,5 +1,75 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.22.89
4
+
5
+ - **Worker daemons now actually relaunch onto a new agents-cli release, so a fix
6
+ that ships reaches every worker without a hand `agents daemon restart`
7
+ (PHNX-3940 rollout).** Two defects kept every fleet daemon on the code it
8
+ booted with: `resolveRunningPackageRoot(__dirname)` resolved one level up from
9
+ the calling module, which from `dist/lib/daemon/self-update-service.js` is
10
+ `dist/lib`, so every self-update tick failed with `… is not an npm-managed
11
+ install` (eight Linux workers logged it for days and ran 1.22.79 code while the
12
+ disk sat at 1.22.88); and the tick only exited after an install it performed
13
+ itself, so an install written by an operator's `agents` command never relaunched
14
+ the daemon, and on a box with a second `agents` on PATH the tick declined
15
+ silently. The resolver now walks up to the `package.json` naming this package;
16
+ the tick compares the on-disk version with the version it booted with and, when
17
+ the disk is newer and the install has settled (`package.json` at rest for a
18
+ minute and every `bin` entry present, because bun's write is not atomic), exits
19
+ for the OS-supervisor relaunch without downloading anything. The shadow decline
20
+ is logged once per daemon process, and `auth-sync` logs accounts skipped
21
+ because their durable key is not readable on the box. Source:
22
+ `cli/src/lib/self-update.ts`, `cli/src/lib/daemon/self-update-service.ts`,
23
+ `cli/src/lib/daemon/auth-sync-service.ts`.
24
+
25
+ - **AGI Menu palette: real projects, Linear from the binding, pin, live screenshots, image paste (PHNX-4001).**
26
+ The `Cmd-Shift-O` palette now picks a **project**, not a directory: it lists every
27
+ `agents projects` definition by name (with a type-ahead filter and the root as a
28
+ tooltip) instead of the last eight session working directories, so a project you
29
+ have not worked in today is still one keystroke away, and two definitions that
30
+ share a checkout — `prix` and `rush` both live in the same monorepo — stay two
31
+ entries. Dispatch passes `--project <name>`; recent session cwds now only offer a
32
+ worktree or subdirectory *inside* the chosen project. The ticket list is scoped by
33
+ the project's own Linear **binding** rather than by matching folder names against
34
+ Linear project names, which is why picking `agi` now shows the AGI board instead of
35
+ "no Linear project matches agents-cli"; a project with no binding says so and prints
36
+ `agents projects link <name> --linear`. The palette can be **pinned** (header button,
37
+ or press `Cmd-Shift-O` while it is already focused) so it survives another app
38
+ taking focus. The screenshot strip is **live** — a shot taken while the palette is
39
+ open appears within about a second, with no re-summon — and images can be pasted
40
+ with `Cmd-V` or dropped onto the panel; `Backspace` on a focused thumbnail removes
41
+ it. Attachments now travel to the agent as `<host>:<abs-path>` references (the same
42
+ form `Cmd-Shift-V` types), so a dispatch to another device can fetch them. The
43
+ ticket list starts folded and `Cmd-T` toggles it, remembered. On a machine with no
44
+ projects defined the palette falls back to recent session working directories, and
45
+ with neither a project nor a recent directory it now refuses to dispatch instead of
46
+ launching an agent with no working directory (which resolved to `/`).
47
+ Source: `cli/menubar/Sources/MenubarHelper/PromptPanel.swift`,
48
+ `cli/menubar/Sources/MenubarHelper/ScreenshotWatcher.swift`,
49
+ `cli/menubar/Sources/MenubarHelper/LinearTickets.swift`.
50
+
51
+ - **The CLI now posts one actionable desktop banner per new attention item, so a
52
+ session asking a question, a permission prompt, or a plan review is answerable
53
+ straight from the notification (PHNX-4004).** A new supervised `attention-notify`
54
+ daemon service (tick 5 s, reader-independent) reconciles this host's live
55
+ sessions each tick and posts a banner carrying the category, the attention key,
56
+ the session id, and the answerable choices — Approve / Approve for session / Deny
57
+ for a permission, the options plus a typed reply for a question, Approve / Send
58
+ back for a plan review, Open terminal for a stall — which the macOS helper routes
59
+ back through `agents feed answer <key> --choice <id>`. A `deny`/`send-back`
60
+ choice delivers a real Escape keystroke (no trailing Enter) rather than the
61
+ letters "esc", so cancelling a permission or plan prompt never confirms it.
62
+ `agents run --notify` finish banners now carry `category: done|failure`; the
63
+ finish-notification shape also carries the session id and open-report / open-pr
64
+ choices when the caller supplies a session id, report path, or PR url.
65
+ Idempotency is a filesystem ledger under `~/.agents/.history/feed/notified/`,
66
+ pruned at 14 days, so a daemon restart never re-posts a banner already sent. The
67
+ `--notify` argv gains `--category`, `--key`, `--session`, and repeated
68
+ `--choice <id>=<label>` (see `docs/menubar.md` → Actionable notifications).
69
+ Source: `src/lib/menubar/notify-desktop.ts`,
70
+ `src/lib/daemon/attention-notify-service.ts`, `src/lib/feed/attention.ts`,
71
+ `src/lib/answer-router.ts`, `src/lib/run-notify.ts`.
72
+
3
73
  ## 1.22.88
4
74
 
5
75
  - **`agents run` no longer fails with `secrets request failed: spawnSync sh
@@ -32,6 +32,13 @@ export interface AnswerRoute {
32
32
  sessionId: string;
33
33
  agent: string;
34
34
  };
35
+ /**
36
+ * Whether to append Enter after the injected payload. Default (omitted) is
37
+ * true. A cancel keystroke (Escape, from a deny / send-back choice) sets this
38
+ * false — the ESC byte itself dismisses the prompt, and a trailing newline
39
+ * would submit a stray empty line into the composer behind it.
40
+ */
41
+ enter?: boolean;
35
42
  }
36
43
  export interface AnswerRouterInput {
37
44
  /** Resolved mailbox / session id. */
@@ -56,6 +63,7 @@ export declare function matchOptionIndex(answer: string, options: Array<Pick<Blo
56
63
  export declare function keystrokesForAnswer(answer: string, options?: Array<Pick<BlockOption, 'label'>>): {
57
64
  payload: string;
58
65
  matched: 'option' | 'free-text' | 'other';
66
+ enter?: boolean;
59
67
  };
60
68
  /** True when the session is waiting on user input (parked on a question/plan). */
61
69
  export declare function isParkedOnInput(session: ActiveSession | null | undefined): boolean;
@@ -1,5 +1,7 @@
1
1
  import { addressabilityRecoveryHint } from './terminal/resolve.js';
2
2
  import { injectTargetFromReplyRail } from './session/inject.js';
3
+ /** The Escape keystroke as its raw control byte — what a PTY/tmux/iterm rail reads as a real Escape. */
4
+ const ESCAPE_KEY = '\u001b';
3
5
  /**
4
6
  * Match free-text answer against question options.
5
7
  * Exact (case-insensitive) > startsWith > includes. Returns 0-based index or -1.
@@ -31,6 +33,15 @@ export function keystrokesForAnswer(answer, options) {
31
33
  // AskUserQuestion / plan select-lists are 1-indexed digits.
32
34
  return { payload: `${idx + 1}`, matched: 'option' };
33
35
  }
36
+ // A symbolic cancel token — the `esc` deliveryKey a deny / send-back choice
37
+ // carries — is the Escape KEY, not the letters "esc". Send the ESC control
38
+ // byte (a real cancel on every raw pty/tmux/iterm rail) and suppress the
39
+ // trailing Enter, so the prompt is dismissed rather than confirmed by a stray
40
+ // newline. Runs after the option match so an option literally labelled "esc"
41
+ // still wins as a selection.
42
+ if (/^(?:esc|escape)$/i.test(answer.trim())) {
43
+ return { payload: ESCAPE_KEY, matched: 'other', enter: false };
44
+ }
34
45
  if (options?.length) {
35
46
  const otherIdx = options.findIndex((o) => /^other$/i.test((o.label ?? '').trim()));
36
47
  if (otherIdx >= 0) {
@@ -92,7 +103,7 @@ export function resolveAnswerRoute(input) {
92
103
  session?.question?.options?.map((o) => ({ label: o.label }));
93
104
  if (openQ && parked && session) {
94
105
  const inject = injectTargetForSession(session);
95
- const { payload, matched } = keystrokesForAnswer(answer, options);
106
+ const { payload, matched, enter } = keystrokesForAnswer(answer, options);
96
107
  if (inject) {
97
108
  const kind = inject.backend === 'tmux' ? 'tmux'
98
109
  : inject.backend === 'iterm' ? 'iterm'
@@ -103,6 +114,7 @@ export function resolveAnswerRoute(input) {
103
114
  reason: `Parked on open question — drive ${inject.backend} selection (${matched}).`,
104
115
  payload,
105
116
  inject,
117
+ ...(enter === false ? { enter: false } : {}),
106
118
  };
107
119
  }
108
120
  // Headless / no rail: re-enter via resume.
@@ -0,0 +1,66 @@
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 { type ActiveSession } from '../session/active.js';
24
+ import { type AttentionItem } from '../feed/attention.js';
25
+ import { type DesktopNotification } from '../menubar/notify-desktop.js';
26
+ import { BasePeriodicService, type DaemonContext } from './service.js';
27
+ import type { DaemonServiceId } from '../daemon-services.js';
28
+ /**
29
+ * The banner for one attention item, or `undefined` when its kind does not
30
+ * notify (`done` comes from the run process; `declared`/`review` are surfaced in
31
+ * the feed, not as a native banner). Pure — the service and its test both build
32
+ * through here, so what ships is what is asserted.
33
+ */
34
+ export declare function buildAttentionNotification(item: AttentionItem, session: ActiveSession): DesktopNotification | undefined;
35
+ export interface AttentionNotifyServiceOptions {
36
+ /** Live-session source (default: the real active-session query). Injected in tests. */
37
+ getSessions?: () => Promise<ActiveSession[]>;
38
+ /** Notifier (default: the real desktop notifier). Injected in tests to capture posts. */
39
+ notify?: (n: DesktopNotification) => void;
40
+ /** Feed store root (default: the real feed dir). Overridden in tests. */
41
+ feedRoot?: string;
42
+ /** Notified-ledger dir (default: `<feedRoot>/notified`). Overridden in tests. */
43
+ ledgerDir?: string;
44
+ /** Clock (default: `Date.now`). Injected in tests. */
45
+ now?: () => number;
46
+ }
47
+ export declare class AttentionNotifyService extends BasePeriodicService {
48
+ readonly id: DaemonServiceId;
49
+ readonly intervalMs = 5000;
50
+ readonly deadlineMs = 10000;
51
+ private readonly getSessions;
52
+ private readonly notify;
53
+ private readonly feedRoot?;
54
+ private readonly ledgerDir;
55
+ private readonly now;
56
+ private lastPruneMs;
57
+ constructor(opts?: AttentionNotifyServiceOptions);
58
+ protected onStart(_ctx: DaemonContext): Promise<void>;
59
+ protected onStop(): Promise<void>;
60
+ protected onTick(ctx: DaemonContext): Promise<void>;
61
+ /** Filesystem-safe sidecar name for an attention key (`host/session/generation`). */
62
+ private ledgerPath;
63
+ private hasNotified;
64
+ private markNotified;
65
+ private pruneLedger;
66
+ }
@@ -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}`);
@@ -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
  /**
@@ -104,18 +104,22 @@ export declare function resolveMultiInstallInventory(runningRoot: string, runnin
104
104
  /**
105
105
  * The on-disk package root of the copy that is currently running.
106
106
  *
107
- * For a plain JS install this is just `<__dirname>/..`. Under the compiled
108
- * standalone binary (shipped since 1.20.53) it is not: Bun sets `__dirname` to
109
- * its embedded virtual FS, so `<__dirname>/..` yields `/$bunfs` — a path that
110
- * exists nowhere. That phantom value was reported as a second install by the
111
- * multi-install check, and rejected by deriveGlobalPrefix as "not an
112
- * npm-managed install", so every self-upgrade from a compiled copy failed.
107
+ * For a plain JS install, walk up from the CALLING module's directory to the
108
+ * directory whose package.json names this package. Never assume a fixed depth:
109
+ * `<__dirname>/..` is the root only for a module directly under `dist/`, and
110
+ * from `dist/lib/daemon/self-update-service.js` it answered `dist/lib`, which
111
+ * deriveGlobalPrefix rejected as "not an npm-managed install" on every daemon
112
+ * self-update tick (2026-09-07 fleet incident).
113
113
  *
114
- * The physical executable is `process.execPath`, which ships inside the
115
- * package (`<packageRoot>/dist/bin/agents`). Walk up from it to the directory
116
- * whose package.json actually names this package rather than assuming a fixed
117
- * depth, so a change to the dist layout surfaces as a clear throw here instead
118
- * of a wrong prefix that npm would happily install into.
114
+ * Under the compiled standalone binary (shipped since 1.20.53) `__dirname` is
115
+ * Bun's embedded virtual FS, so walking up from it yields `/$bunfs` — a path
116
+ * that exists nowhere. That phantom value was reported as a second install by
117
+ * the multi-install check and rejected by deriveGlobalPrefix, so every
118
+ * self-upgrade from a compiled copy failed. The physical executable is
119
+ * `process.execPath`, which ships inside the package
120
+ * (`<packageRoot>/dist/bin/agents`); walk up from it instead. Either way a
121
+ * change to the dist layout surfaces as a clear throw here rather than a wrong
122
+ * prefix that npm would happily install into.
119
123
  */
120
124
  export declare function resolveRunningPackageRoot(dirname: string, execPath?: string): string;
121
125
  /**
@@ -175,8 +179,30 @@ export declare function installPackageIntoPrefix(spec: string, prefix: string, s
175
179
  * alias shims afterwards via refreshAliasShims() rather than relying on the
176
180
  * package's postinstall hook.
177
181
  *
182
+ * Unlike npm's arborist (see {@link sweepStaleInstallStaging}: retire-rename,
183
+ * then one final rename), bun's write into the package directory is NOT known
184
+ * to be atomic — files may land incrementally. Anything that trusts a version
185
+ * bump on disk written by ANOTHER process (the daemon's stale-install relaunch
186
+ * in `daemon/self-update-service.ts`) must therefore gate on
187
+ * {@link installLooksSettled} rather than on the version alone.
188
+ *
178
189
  * `signal` behaves exactly as documented on {@link installPackageIntoPrefix}.
179
190
  */
191
+ /** How long an install's package.json must have been at rest before a foreign version bump is trusted. */
192
+ export declare const INSTALL_SETTLE_MS = 60000;
193
+ /**
194
+ * True when the install at `packageRoot` looks complete: its package.json has
195
+ * not been modified for at least `settleMs`, and every `bin` entry it declares
196
+ * exists on disk. This is the guard for trusting a version bump that a
197
+ * DIFFERENT process wrote (an operator's `agents` auto-update, `agents
198
+ * upgrade`, the installer) without re-running that process's own verification:
199
+ * npm's reify is an atomic rename, but bun's is not (see
200
+ * {@link installPackageWithBun}), so a reader can catch bun mid-extraction —
201
+ * package.json present, `dist/` still landing. An install finishes in seconds;
202
+ * a minute of quiet plus the bin entry present rules that window out. Any
203
+ * read error means "not settled".
204
+ */
205
+ export declare function installLooksSettled(packageRoot: string, settleMs?: number, now?: number): boolean;
180
206
  export declare function installPackageWithBun(spec: string, signal?: AbortSignal): Promise<void>;
181
207
  /**
182
208
  * Verify a downloaded tarball's bytes against a Subresource Integrity (SRI)
@@ -240,37 +240,59 @@ function isPackageRoot(dir) {
240
240
  /**
241
241
  * The on-disk package root of the copy that is currently running.
242
242
  *
243
- * For a plain JS install this is just `<__dirname>/..`. Under the compiled
244
- * standalone binary (shipped since 1.20.53) it is not: Bun sets `__dirname` to
245
- * its embedded virtual FS, so `<__dirname>/..` yields `/$bunfs` — a path that
246
- * exists nowhere. That phantom value was reported as a second install by the
247
- * multi-install check, and rejected by deriveGlobalPrefix as "not an
248
- * npm-managed install", so every self-upgrade from a compiled copy failed.
243
+ * For a plain JS install, walk up from the CALLING module's directory to the
244
+ * directory whose package.json names this package. Never assume a fixed depth:
245
+ * `<__dirname>/..` is the root only for a module directly under `dist/`, and
246
+ * from `dist/lib/daemon/self-update-service.js` it answered `dist/lib`, which
247
+ * deriveGlobalPrefix rejected as "not an npm-managed install" on every daemon
248
+ * self-update tick (2026-09-07 fleet incident).
249
249
  *
250
- * The physical executable is `process.execPath`, which ships inside the
251
- * package (`<packageRoot>/dist/bin/agents`). Walk up from it to the directory
252
- * whose package.json actually names this package rather than assuming a fixed
253
- * depth, so a change to the dist layout surfaces as a clear throw here instead
254
- * of a wrong prefix that npm would happily install into.
250
+ * Under the compiled standalone binary (shipped since 1.20.53) `__dirname` is
251
+ * Bun's embedded virtual FS, so walking up from it yields `/$bunfs` — a path
252
+ * that exists nowhere. That phantom value was reported as a second install by
253
+ * the multi-install check and rejected by deriveGlobalPrefix, so every
254
+ * self-upgrade from a compiled copy failed. The physical executable is
255
+ * `process.execPath`, which ships inside the package
256
+ * (`<packageRoot>/dist/bin/agents`); walk up from it instead. Either way a
257
+ * change to the dist layout surfaces as a clear throw here rather than a wrong
258
+ * prefix that npm would happily install into.
255
259
  */
256
260
  export function resolveRunningPackageRoot(dirname, execPath = process.execPath) {
257
- const fromDirname = path.resolve(dirname, '..');
258
- if (!isBunVirtualPath(fromDirname))
259
- return fromDirname;
261
+ if (!isBunVirtualPath(dirname)) {
262
+ // Walk up from the CALLING module's directory to the package.json that
263
+ // names this package. The previous `path.resolve(dirname, '..')` was only
264
+ // right for a module one level below the root (dist/bootstrap.js); from
265
+ // dist/lib/daemon/self-update-service.js it answered `dist/lib`, so
266
+ // `deriveGlobalPrefix` threw "not an npm-managed install" on every daemon
267
+ // self-update tick, fleet-wide, and no daemon ever relaunched onto a
268
+ // release (2026-09-07: eight workers still running 1.22.79 code four
269
+ // releases later).
270
+ const found = findPackageRootAbove(dirname);
271
+ if (found)
272
+ return found;
273
+ throw new Error(`Cannot locate the running agents-cli install: no ${NPM_PACKAGE_NAME} package.json above ` +
274
+ `${dirname}. Reinstall with: npm install -g ${NPM_PACKAGE_NAME}`);
275
+ }
260
276
  if (!execPath || isBunVirtualPath(execPath)) {
261
- throw new Error(`Cannot locate the running agents-cli install: __dirname is the Bun virtual path ${fromDirname} ` +
277
+ throw new Error(`Cannot locate the running agents-cli install: __dirname is the Bun virtual path ${dirname} ` +
262
278
  `and process.execPath (${execPath || '(empty)'}) is not a real file. ` +
263
279
  `Reinstall with: npm install -g ${NPM_PACKAGE_NAME}`);
264
280
  }
265
- let dir = path.dirname(path.resolve(execPath));
281
+ const found = findPackageRootAbove(path.dirname(path.resolve(execPath)));
282
+ if (found)
283
+ return found;
284
+ throw new Error(`Cannot locate the running agents-cli install: no ${NPM_PACKAGE_NAME} package.json above ` +
285
+ `${execPath}. Reinstall with: npm install -g ${NPM_PACKAGE_NAME}`);
286
+ }
287
+ /** Nearest ancestor of `start` (inclusive) whose package.json names this package, or null. */
288
+ function findPackageRootAbove(start) {
289
+ let dir = path.resolve(start);
266
290
  for (;;) {
267
291
  if (isPackageRoot(dir))
268
292
  return dir;
269
293
  const parent = path.dirname(dir);
270
- if (parent === dir) {
271
- throw new Error(`Cannot locate the running agents-cli install: no ${NPM_PACKAGE_NAME} package.json above ` +
272
- `${execPath}. Reinstall with: npm install -g ${NPM_PACKAGE_NAME}`);
273
- }
294
+ if (parent === dir)
295
+ return null;
274
296
  dir = parent;
275
297
  }
276
298
  }
@@ -376,8 +398,43 @@ export async function installPackageIntoPrefix(spec, prefix, signal) {
376
398
  * alias shims afterwards via refreshAliasShims() rather than relying on the
377
399
  * package's postinstall hook.
378
400
  *
401
+ * Unlike npm's arborist (see {@link sweepStaleInstallStaging}: retire-rename,
402
+ * then one final rename), bun's write into the package directory is NOT known
403
+ * to be atomic — files may land incrementally. Anything that trusts a version
404
+ * bump on disk written by ANOTHER process (the daemon's stale-install relaunch
405
+ * in `daemon/self-update-service.ts`) must therefore gate on
406
+ * {@link installLooksSettled} rather than on the version alone.
407
+ *
379
408
  * `signal` behaves exactly as documented on {@link installPackageIntoPrefix}.
380
409
  */
410
+ /** How long an install's package.json must have been at rest before a foreign version bump is trusted. */
411
+ export const INSTALL_SETTLE_MS = 60_000;
412
+ /**
413
+ * True when the install at `packageRoot` looks complete: its package.json has
414
+ * not been modified for at least `settleMs`, and every `bin` entry it declares
415
+ * exists on disk. This is the guard for trusting a version bump that a
416
+ * DIFFERENT process wrote (an operator's `agents` auto-update, `agents
417
+ * upgrade`, the installer) without re-running that process's own verification:
418
+ * npm's reify is an atomic rename, but bun's is not (see
419
+ * {@link installPackageWithBun}), so a reader can catch bun mid-extraction —
420
+ * package.json present, `dist/` still landing. An install finishes in seconds;
421
+ * a minute of quiet plus the bin entry present rules that window out. Any
422
+ * read error means "not settled".
423
+ */
424
+ export function installLooksSettled(packageRoot, settleMs = INSTALL_SETTLE_MS, now = Date.now()) {
425
+ try {
426
+ const pkgJsonPath = path.join(packageRoot, 'package.json');
427
+ const stat = fs.statSync(pkgJsonPath);
428
+ if (now - stat.mtimeMs < settleMs)
429
+ return false;
430
+ const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8'));
431
+ const bins = typeof pkg.bin === 'string' ? [pkg.bin] : Object.values(pkg.bin ?? {});
432
+ return bins.every((rel) => fs.existsSync(path.join(packageRoot, rel)));
433
+ }
434
+ catch {
435
+ return false;
436
+ }
437
+ }
381
438
  export async function installPackageWithBun(spec, signal) {
382
439
  const { execFile } = await import('child_process');
383
440
  const { promisify } = await import('util');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.22.88",
3
+ "version": "1.22.89",
4
4
  "description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",