@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 +70 -0
- package/dist/lib/answer-router.d.ts +8 -0
- package/dist/lib/answer-router.js +13 -1
- package/dist/lib/daemon/attention-notify-service.d.ts +66 -0
- package/dist/lib/daemon/attention-notify-service.js +185 -0
- package/dist/lib/daemon/auth-sync-service.js +8 -0
- package/dist/lib/daemon/daemon.js +9 -0
- package/dist/lib/daemon/self-update-service.d.ts +9 -0
- package/dist/lib/daemon/self-update-service.js +51 -6
- package/dist/lib/daemon-services.d.ts +1 -1
- package/dist/lib/daemon-services.js +5 -0
- package/dist/lib/feed/answer.js +1 -1
- package/dist/lib/feed/attention.d.ts +2 -0
- package/dist/lib/feed/attention.js +0 -0
- package/dist/lib/fs-atomic.d.ts +7 -0
- package/dist/lib/fs-atomic.js +20 -0
- package/dist/lib/menubar/notify-desktop.d.ts +27 -0
- package/dist/lib/menubar/notify-desktop.js +11 -0
- package/dist/lib/run-notify.d.ts +4 -0
- package/dist/lib/run-notify.js +21 -2
- package/dist/lib/self-update.d.ts +37 -11
- package/dist/lib/self-update.js +77 -20
- package/package.json +1 -1
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
|
|
333
|
-
|
|
334
|
-
|
|
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',
|
package/dist/lib/feed/answer.js
CHANGED
|
@@ -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
|
package/dist/lib/fs-atomic.d.ts
CHANGED
|
@@ -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
|
package/dist/lib/fs-atomic.js
CHANGED
|
@@ -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. */
|
package/dist/lib/run-notify.d.ts
CHANGED
|
@@ -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
|
package/dist/lib/run-notify.js
CHANGED
|
@@ -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:
|
|
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.
|
|
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
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
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
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
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)
|
package/dist/lib/self-update.js
CHANGED
|
@@ -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
|
|
244
|
-
*
|
|
245
|
-
*
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
*
|
|
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
|
-
*
|
|
251
|
-
*
|
|
252
|
-
*
|
|
253
|
-
*
|
|
254
|
-
*
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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 ${
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|