@phnx-labs/agents-cli 1.22.88 → 1.22.90
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +159 -0
- package/dist/lib/accounts/migrate.js +99 -0
- package/dist/lib/answer-router.d.ts +8 -0
- package/dist/lib/answer-router.js +13 -1
- package/dist/lib/auth-mint.d.ts +22 -1
- package/dist/lib/auth-mint.js +85 -10
- 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 +10 -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/secrets-policy.d.ts +17 -0
- package/dist/lib/secrets-policy.js +14 -1
- package/dist/lib/self-update.d.ts +37 -11
- package/dist/lib/self-update.js +77 -20
- package/package.json +1 -1
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Attention desktop banners as a supervised periodic service (PHNX-4004).
|
|
3
|
+
*
|
|
4
|
+
* Each tick reconciles this host's live sessions into attention items through
|
|
5
|
+
* the CLI-owned reconciler (feed/attention.ts — never re-deriving detection) and
|
|
6
|
+
* posts ONE actionable desktop banner per attention key not yet notified. The
|
|
7
|
+
* banner carries the category, the attention key, the session id, and the
|
|
8
|
+
* answerable choices, so the macOS companion can offer Approve / Approve for
|
|
9
|
+
* session / Deny (permission), the options plus a typed reply (question),
|
|
10
|
+
* Approve / Send back (plan review), or Open terminal (a stall/failure), and
|
|
11
|
+
* route the answer back through `agents feed answer <key> --choice <id>`.
|
|
12
|
+
*
|
|
13
|
+
* Idempotency is a filesystem ledger, not memory: one sidecar file per notified
|
|
14
|
+
* key under `~/.agents/.history/feed/notified/`, so a daemon restart never
|
|
15
|
+
* re-posts a banner already sent — the ledger is the truth. It is pruned with
|
|
16
|
+
* the feed's 14-day retention rule.
|
|
17
|
+
*
|
|
18
|
+
* Reader-independent: unlike the session-state publisher this does NOT gate on a
|
|
19
|
+
* live `sessions watch` reader — an attention banner must fire whether or not
|
|
20
|
+
* anyone is watching the stream. `done` banners are deliberately NOT produced
|
|
21
|
+
* here; they ride the run process's own exit (`run-notify.ts`).
|
|
22
|
+
*/
|
|
23
|
+
import * as fs from 'fs';
|
|
24
|
+
import * as path from 'path';
|
|
25
|
+
import { atomicWriteFile } from '../fs-atomic.js';
|
|
26
|
+
import { getFeedDir } from '../state.js';
|
|
27
|
+
import { machineId } from '../machine-id.js';
|
|
28
|
+
import { getActiveSessions } from '../session/active.js';
|
|
29
|
+
import { blockIdForSession, readBlock, readResolution } from '../feed/feed.js';
|
|
30
|
+
import { reconcileAttention, harnessOf } from '../feed/attention.js';
|
|
31
|
+
import { notifyDesktop } from '../menubar/notify-desktop.js';
|
|
32
|
+
import { BasePeriodicService } from './service.js';
|
|
33
|
+
const ATTENTION_NOTIFY_TICK_MS = 5_000;
|
|
34
|
+
const ATTENTION_NOTIFY_DEADLINE_MS = 10_000;
|
|
35
|
+
/** Feed retention: a notified-ledger sidecar older than this is pruned. */
|
|
36
|
+
const LEDGER_RETENTION_MS = 14 * 24 * 60 * 60 * 1000;
|
|
37
|
+
/** Prune the ledger at most this often — the dir is tiny, but a dir walk every 5s is waste. */
|
|
38
|
+
const LEDGER_PRUNE_EVERY_MS = 60 * 60 * 1000;
|
|
39
|
+
/** UNUserNotificationCenter caps action buttons; the argv contract is ≤ 6 choices. */
|
|
40
|
+
const MAX_CHOICES = 6;
|
|
41
|
+
/** Body cap — a banner truncates anyway, and a wall of text is noise. */
|
|
42
|
+
const BODY_MAX = 200;
|
|
43
|
+
/** Which attention kinds produce a banner, and the category + title verb each carries. */
|
|
44
|
+
const BANNER_KINDS = {
|
|
45
|
+
permission: { category: 'permission', label: 'Command approval' },
|
|
46
|
+
question: { category: 'question', label: 'Question' },
|
|
47
|
+
plan_review: { category: 'plan_review', label: 'Plan review' },
|
|
48
|
+
stall: { category: 'failure', label: 'Failed' },
|
|
49
|
+
failure: { category: 'failure', label: 'Failed' },
|
|
50
|
+
};
|
|
51
|
+
function shorten(text, max = BODY_MAX) {
|
|
52
|
+
const flat = text.replace(/\s+/g, ' ').trim();
|
|
53
|
+
return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* The banner for one attention item, or `undefined` when its kind does not
|
|
57
|
+
* notify (`done` comes from the run process; `declared`/`review` are surfaced in
|
|
58
|
+
* the feed, not as a native banner). Pure — the service and its test both build
|
|
59
|
+
* through here, so what ships is what is asserted.
|
|
60
|
+
*/
|
|
61
|
+
export function buildAttentionNotification(item, session) {
|
|
62
|
+
const spec = BANNER_KINDS[item.kind];
|
|
63
|
+
if (!spec)
|
|
64
|
+
return undefined;
|
|
65
|
+
const shortId = item.sessionId ? item.sessionId.slice(0, 8) : 'session';
|
|
66
|
+
const who = session.label?.trim() || session.title?.trim() || shortId;
|
|
67
|
+
const n = {
|
|
68
|
+
title: `${who} · ${spec.label}`,
|
|
69
|
+
body: shorten(item.question?.text?.trim() || spec.label),
|
|
70
|
+
category: spec.category,
|
|
71
|
+
key: item.key,
|
|
72
|
+
};
|
|
73
|
+
const agent = harnessOf(session);
|
|
74
|
+
if (agent)
|
|
75
|
+
n.agent = agent;
|
|
76
|
+
if (item.sessionId)
|
|
77
|
+
n.sessionId = item.sessionId;
|
|
78
|
+
const subtitle = [item.host, item.project].filter(Boolean).join(' · ');
|
|
79
|
+
if (subtitle)
|
|
80
|
+
n.subtitle = subtitle;
|
|
81
|
+
// A stall/failure has no answerable option list — it offers one button that
|
|
82
|
+
// opens the session's terminal. Everything else carries the reconciled choices.
|
|
83
|
+
const choices = spec.category === 'failure'
|
|
84
|
+
? [{ id: 'open-terminal', label: 'Open terminal' }]
|
|
85
|
+
: (item.choices ?? []).slice(0, MAX_CHOICES).map((c) => ({ id: c.id, label: c.label }));
|
|
86
|
+
if (choices.length)
|
|
87
|
+
n.choices = choices;
|
|
88
|
+
return n;
|
|
89
|
+
}
|
|
90
|
+
export class AttentionNotifyService extends BasePeriodicService {
|
|
91
|
+
id = 'attention-notify';
|
|
92
|
+
intervalMs = ATTENTION_NOTIFY_TICK_MS;
|
|
93
|
+
deadlineMs = ATTENTION_NOTIFY_DEADLINE_MS;
|
|
94
|
+
getSessions;
|
|
95
|
+
notify;
|
|
96
|
+
feedRoot;
|
|
97
|
+
ledgerDir;
|
|
98
|
+
now;
|
|
99
|
+
lastPruneMs = 0;
|
|
100
|
+
constructor(opts = {}) {
|
|
101
|
+
super();
|
|
102
|
+
this.getSessions = opts.getSessions ?? (() => getActiveSessions());
|
|
103
|
+
this.notify = opts.notify ?? notifyDesktop;
|
|
104
|
+
this.feedRoot = opts.feedRoot;
|
|
105
|
+
this.ledgerDir = opts.ledgerDir ?? path.join(opts.feedRoot ?? getFeedDir(), 'notified');
|
|
106
|
+
this.now = opts.now ?? Date.now;
|
|
107
|
+
}
|
|
108
|
+
async onStart(_ctx) {
|
|
109
|
+
// No handles to open — each tick re-reads sessions and the ledger.
|
|
110
|
+
}
|
|
111
|
+
async onStop() {
|
|
112
|
+
// Nothing to release — the supervisor's timer teardown is the only cleanup.
|
|
113
|
+
}
|
|
114
|
+
async onTick(ctx) {
|
|
115
|
+
const sessions = await this.getSessions();
|
|
116
|
+
const host = machineId();
|
|
117
|
+
for (const session of sessions) {
|
|
118
|
+
if (!session.sessionId)
|
|
119
|
+
continue;
|
|
120
|
+
// ActiveSession.host names the terminal app; the reconciler's host is the
|
|
121
|
+
// device scope, so the attention key is fleet-routable (mirrors watch.ts).
|
|
122
|
+
const projected = { ...session, host };
|
|
123
|
+
const blockId = blockIdForSession(session.sessionId);
|
|
124
|
+
const item = reconcileAttention({
|
|
125
|
+
block: readBlock(blockId, this.feedRoot),
|
|
126
|
+
session: projected,
|
|
127
|
+
resolution: readResolution(blockId, this.feedRoot),
|
|
128
|
+
nowMs: this.now(),
|
|
129
|
+
});
|
|
130
|
+
if (!item || !BANNER_KINDS[item.kind])
|
|
131
|
+
continue;
|
|
132
|
+
if (await this.hasNotified(item.key))
|
|
133
|
+
continue;
|
|
134
|
+
const notification = buildAttentionNotification(item, projected);
|
|
135
|
+
if (!notification)
|
|
136
|
+
continue;
|
|
137
|
+
this.notify(notification);
|
|
138
|
+
await this.markNotified(item.key);
|
|
139
|
+
ctx.log('INFO', `attention-notify: posted ${item.kind} banner for ${item.key}`);
|
|
140
|
+
}
|
|
141
|
+
await this.pruneLedger();
|
|
142
|
+
}
|
|
143
|
+
/** Filesystem-safe sidecar name for an attention key (`host/session/generation`). */
|
|
144
|
+
ledgerPath(key) {
|
|
145
|
+
return path.join(this.ledgerDir, encodeURIComponent(key));
|
|
146
|
+
}
|
|
147
|
+
async hasNotified(key) {
|
|
148
|
+
// fs/promises has no existsSync; a stat that rejects with ENOENT is the miss.
|
|
149
|
+
try {
|
|
150
|
+
await fs.promises.stat(this.ledgerPath(key));
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
async markNotified(key) {
|
|
158
|
+
await fs.promises.mkdir(this.ledgerDir, { recursive: true });
|
|
159
|
+
await atomicWriteFile(this.ledgerPath(key), JSON.stringify({ key, postedAt: new Date(this.now()).toISOString() }), 'utf-8');
|
|
160
|
+
}
|
|
161
|
+
async pruneLedger() {
|
|
162
|
+
const nowMs = this.now();
|
|
163
|
+
if (nowMs - this.lastPruneMs < LEDGER_PRUNE_EVERY_MS)
|
|
164
|
+
return;
|
|
165
|
+
this.lastPruneMs = nowMs;
|
|
166
|
+
let names;
|
|
167
|
+
try {
|
|
168
|
+
names = await fs.promises.readdir(this.ledgerDir);
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
return; // No ledger dir yet — nothing to prune.
|
|
172
|
+
}
|
|
173
|
+
for (const name of names) {
|
|
174
|
+
const filePath = path.join(this.ledgerDir, name);
|
|
175
|
+
try {
|
|
176
|
+
const stat = await fs.promises.stat(filePath);
|
|
177
|
+
if (nowMs - stat.mtimeMs > LEDGER_RETENTION_MS)
|
|
178
|
+
await fs.promises.rm(filePath, { force: true });
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
// A file that vanished mid-walk is already gone; skip it.
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
@@ -36,6 +36,14 @@ export class AuthSyncService extends BasePeriodicService {
|
|
|
36
36
|
ctx.log('INFO', `auth-sync: provisioned worker slot(s) for ${slots.provisioned.join(', ')}`);
|
|
37
37
|
for (const err of slots.errors)
|
|
38
38
|
ctx.log('WARN', `auth-sync: worker slot ${err.accountId}: ${err.message}`);
|
|
39
|
+
// A row skipped for a key this box cannot read is the one silent outcome
|
|
40
|
+
// an operator needs to see: it is what "0 slots after the daemon restart"
|
|
41
|
+
// looked like on yosemite-m0 (2026-09-07), where the durable key was in
|
|
42
|
+
// the bundle but the `secrets` on the daemon's PATH could not decrypt it.
|
|
43
|
+
const waiting = slots.skipped.filter((s) => s.reason === 'durable key not synced yet');
|
|
44
|
+
if (waiting.length > 0) {
|
|
45
|
+
ctx.log('WARN', `auth-sync: ${waiting.length} registered account(s) have no readable durable key on this box yet; slot not provisioned (${waiting.map((s) => s.accountId).join(', ')})`);
|
|
46
|
+
}
|
|
39
47
|
}
|
|
40
48
|
catch (err) {
|
|
41
49
|
ctx.log('WARN', `auth-sync: worker slot reconcile: ${err.message}`);
|
|
@@ -67,6 +75,8 @@ export class AuthSyncService extends BasePeriodicService {
|
|
|
67
75
|
// the only transport — provisioning (above) writes only locally (invariant 1).
|
|
68
76
|
try {
|
|
69
77
|
const stores = await syncReservedStores();
|
|
78
|
+
if (stores.adopted.length > 0)
|
|
79
|
+
ctx.log('INFO', `auth-sync: adopted ${stores.adopted.length} legacy reserved item(s) into their bundle: ${stores.adopted.map((a) => `${a.bundle} ${a.key}`).join(', ')}`);
|
|
70
80
|
for (const p of stores.pushed)
|
|
71
81
|
ctx.log('INFO', `auth-sync: pushed ${p.bundle} (${p.keys.length} key(s)) to ${p.device}`);
|
|
72
82
|
for (const err of stores.errors)
|
|
@@ -45,6 +45,7 @@ import { AuthSyncService } from './auth-sync-service.js';
|
|
|
45
45
|
import { UsageSyncService } from './usage-sync-service.js';
|
|
46
46
|
import { StateDirCheckService } from './state-dir-check-service.js';
|
|
47
47
|
import { SessionStateService } from './session-state-service.js';
|
|
48
|
+
import { AttentionNotifyService } from './attention-notify-service.js';
|
|
48
49
|
import { WebhookReceiverService } from './webhook-receiver-service.js';
|
|
49
50
|
import { HeartbeatService } from './heartbeat-service.js';
|
|
50
51
|
import { TmuxReapService } from './tmux-reap-service.js';
|
|
@@ -1051,6 +1052,14 @@ export async function runDaemon() {
|
|
|
1051
1052
|
supervisor.register(new SessionSummarizerService());
|
|
1052
1053
|
else
|
|
1053
1054
|
log('INFO', 'Session summarizer service disabled');
|
|
1055
|
+
// Attention desktop banners (PHNX-4004) — posts one actionable native banner
|
|
1056
|
+
// per new attention key. Reader-independent: it fires whether or not a
|
|
1057
|
+
// `sessions watch` reader is present, and the notified-ledger is its
|
|
1058
|
+
// idempotency truth across daemon restarts.
|
|
1059
|
+
if (isEnabled('attention-notify'))
|
|
1060
|
+
supervisor.register(new AttentionNotifyService());
|
|
1061
|
+
else
|
|
1062
|
+
log('INFO', 'Attention-notify service disabled');
|
|
1054
1063
|
// Watchdog, device-probe, and self-heal are all periodic services managed
|
|
1055
1064
|
// by the ServiceSupervisor (RUSH-3193 P3). Each is gated the same way as
|
|
1056
1065
|
// the socket services above; state-dir-check is registered separately,
|
|
@@ -61,7 +61,12 @@ interface NpmLatestMetadata {
|
|
|
61
61
|
* branch exists in the exported logic itself.
|
|
62
62
|
*/
|
|
63
63
|
export interface SelfUpdateDeps {
|
|
64
|
+
/** The version this process BOOTED with (memoized at startup). */
|
|
64
65
|
currentVersion(): string;
|
|
66
|
+
/** The version of the install on disk right now — differs from `currentVersion` once another process upgraded it. */
|
|
67
|
+
installedVersion(): string;
|
|
68
|
+
/** True when the install on disk looks complete (see `installLooksSettled`) — the gate before relaunching onto a version another process wrote. */
|
|
69
|
+
installedIsSettled(): boolean;
|
|
65
70
|
isDevBuild(): boolean;
|
|
66
71
|
detectShadow(): boolean;
|
|
67
72
|
packageRoot(): string;
|
|
@@ -162,6 +167,10 @@ export declare function triggerSelfUpdateInBackground(ctx: DaemonContext, deps?:
|
|
|
162
167
|
* reason, or `null` to proceed to the (backgrounded) install path.
|
|
163
168
|
*/
|
|
164
169
|
export declare function selfUpdateSyncDeclineReason(deps?: SelfUpdateDeps): string | null;
|
|
170
|
+
export declare const DEV_BUILD_DECLINE = "dev build \u2014 self-update is a no-op";
|
|
171
|
+
export declare const SHADOW_DECLINE = "another agents binary shadows this install \u2014 self-update is a no-op";
|
|
172
|
+
/** True when the package on disk is a strictly newer release than the one this process booted with. */
|
|
173
|
+
export declare function installedIsNewerThanRunning(installed: string, running: string): boolean;
|
|
165
174
|
export declare class SelfUpdateService extends BasePeriodicService {
|
|
166
175
|
readonly id: DaemonServiceId;
|
|
167
176
|
readonly intervalMs: number;
|
|
@@ -34,11 +34,11 @@
|
|
|
34
34
|
* relaunch-loop on a broken install.
|
|
35
35
|
*/
|
|
36
36
|
import { BasePeriodicService } from './service.js';
|
|
37
|
-
import { getCliVersion } from '../version.js';
|
|
37
|
+
import { getCliVersion, getCliVersionFresh } from '../version.js';
|
|
38
38
|
import { isDevVersionStamp } from '../startup/dev-build.js';
|
|
39
39
|
import { detectAgentsBinaryShadows } from '../binary-shadow.js';
|
|
40
40
|
import { compareVersions } from '../agent-spec/primitives.js';
|
|
41
|
-
import { NPM_PACKAGE_NAME, deriveGlobalPrefix, detectPackageManager, downloadVerifiedTarball, ensureGlobalBinLinks, installPackageIntoPrefix, installPackageWithBun, refreshAliasShims, resolveRunningPackageRoot, sweepStaleInstallStaging, verifyInstalledVersion, } from '../self-update.js';
|
|
41
|
+
import { NPM_PACKAGE_NAME, deriveGlobalPrefix, detectPackageManager, downloadVerifiedTarball, ensureGlobalBinLinks, installPackageIntoPrefix, installLooksSettled, installPackageWithBun, refreshAliasShims, resolveRunningPackageRoot, sweepStaleInstallStaging, verifyInstalledVersion, } from '../self-update.js';
|
|
42
42
|
import { tryAutoPullSystemRepo } from '../git.js';
|
|
43
43
|
import { getSystemAgentsDir } from '../state.js';
|
|
44
44
|
import { runUmbrellaSync } from '../sync-umbrella.js';
|
|
@@ -151,6 +151,8 @@ export async function installAndVerifyDefault(metadata, packageRoot, signal) {
|
|
|
151
151
|
export function defaultSelfUpdateDeps() {
|
|
152
152
|
return {
|
|
153
153
|
currentVersion: () => getCliVersion(),
|
|
154
|
+
installedVersion: () => getCliVersionFresh(),
|
|
155
|
+
installedIsSettled: () => installLooksSettled(resolveRunningPackageRoot(__dirname)),
|
|
154
156
|
isDevBuild: () => isDevVersionStamp(getCliVersion()),
|
|
155
157
|
detectShadow: () => detectAgentsBinaryShadows().length > 0,
|
|
156
158
|
packageRoot: () => resolveRunningPackageRoot(__dirname),
|
|
@@ -214,10 +216,33 @@ export async function attemptSelfUpdateAndExit(ctx, signal, deps = defaultSelfUp
|
|
|
214
216
|
}
|
|
215
217
|
}
|
|
216
218
|
async function runSelfUpdateAttempt(ctx, signal, deps) {
|
|
219
|
+
// One decline source for the periodic tick and the on-demand IPC path
|
|
220
|
+
// (`selfUpdateSyncDeclineReason`): dev build; shadowed install — except when
|
|
221
|
+
// the install on disk is already newer than the running code, because a
|
|
222
|
+
// relaunch installs nothing and a second `agents` on the box is irrelevant to
|
|
223
|
+
// it (otherwise a shadowed worker never leaves the release it booted on).
|
|
217
224
|
const syncDecline = selfUpdateSyncDeclineReason(deps);
|
|
218
225
|
if (syncDecline)
|
|
219
226
|
return { updated: false, reason: syncDecline };
|
|
227
|
+
// Another agents process (an operator's `agents` command auto-updating, an
|
|
228
|
+
// `agents upgrade`, the installer) may already have replaced the install
|
|
229
|
+
// under this daemon. The code on disk is then newer than the code in memory
|
|
230
|
+
// and there is nothing to download — that path verified its install before
|
|
231
|
+
// it returned. Exit for the OS-supervisor relaunch, once the install has
|
|
232
|
+
// settled: npm's reify is atomic but bun's is not, so a version bump alone
|
|
233
|
+
// could be bun mid-extraction (`installLooksSettled`). Waiting one tick is
|
|
234
|
+
// cheap; relaunching into a half-written tree is a supervisor crash-loop.
|
|
220
235
|
const current = deps.currentVersion();
|
|
236
|
+
const installed = deps.installedVersion();
|
|
237
|
+
if (installedIsNewerThanRunning(installed, current)) {
|
|
238
|
+
if (!deps.installedIsSettled()) {
|
|
239
|
+
ctx.log('INFO', `self-update: the install on disk is ${installed} (this daemon is running ${current}) but it is still settling; relaunch deferred to the next tick`);
|
|
240
|
+
return { updated: false, reason: 'installed version still settling' };
|
|
241
|
+
}
|
|
242
|
+
ctx.log('INFO', `self-update: the install on disk is ${installed} but this daemon is still running ${current}; ` +
|
|
243
|
+
'exiting for OS-supervisor relaunch onto the installed code');
|
|
244
|
+
return { updated: true };
|
|
245
|
+
}
|
|
221
246
|
let metadata;
|
|
222
247
|
try {
|
|
223
248
|
metadata = await deps.fetchLatestMetadata(signal);
|
|
@@ -329,11 +354,24 @@ export function triggerSelfUpdateInBackground(ctx, deps = defaultSelfUpdateDeps(
|
|
|
329
354
|
*/
|
|
330
355
|
export function selfUpdateSyncDeclineReason(deps = defaultSelfUpdateDeps()) {
|
|
331
356
|
if (deps.isDevBuild())
|
|
332
|
-
return
|
|
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
|
/**
|
|
@@ -171,6 +171,11 @@ export declare function readDeliveryMemo(root?: string): Record<string, string>;
|
|
|
171
171
|
export declare function peerPresentKeys(peer: string, accounts: ReservedSyncAccount[], memo: Record<string, string>, legacyAuthReady: boolean): Record<string, ReadonlySet<string>>;
|
|
172
172
|
export interface ReservedStoreSyncResult {
|
|
173
173
|
publisher: string | null;
|
|
174
|
+
/** Legacy raw reserved items adopted into their bundle on this box before planning (local repair). */
|
|
175
|
+
adopted: Array<{
|
|
176
|
+
bundle: string;
|
|
177
|
+
key: string;
|
|
178
|
+
}>;
|
|
174
179
|
pushed: Array<{
|
|
175
180
|
device: string;
|
|
176
181
|
bundle: string;
|
|
@@ -197,6 +202,18 @@ export interface ReservedStoreSyncDeps {
|
|
|
197
202
|
localReady?: boolean;
|
|
198
203
|
/** Does THIS publisher hold (bundle, key) to push? Defaults to a local bundle read. */
|
|
199
204
|
hasLocalKey?: (bundle: string, key: string) => boolean;
|
|
205
|
+
/** Adopt pre-bundle raw reserved items locally before planning; defaults to `adoptLegacyReservedStoreItems`. */
|
|
206
|
+
adoptLegacy?: (meta: Pick<Meta, 'accounts' | 'deviceAccounts'>) => Promise<{
|
|
207
|
+
adopted: Array<{
|
|
208
|
+
bundle: string;
|
|
209
|
+
key: string;
|
|
210
|
+
}>;
|
|
211
|
+
errors: Array<{
|
|
212
|
+
bundle: string;
|
|
213
|
+
key: string;
|
|
214
|
+
message: string;
|
|
215
|
+
}>;
|
|
216
|
+
}>;
|
|
200
217
|
push?: (bundle: string, host: string) => Promise<PushBundleResult>;
|
|
201
218
|
sshTarget?: (device: DeviceProfile) => string;
|
|
202
219
|
}
|
|
@@ -375,11 +375,24 @@ function defaultHasLocalKey(bundle, key) {
|
|
|
375
375
|
* there is no second scheduler. The daemon runs this each tick.
|
|
376
376
|
*/
|
|
377
377
|
export async function syncReservedStores(deps = {}) {
|
|
378
|
-
const result = { publisher: null, pushed: [], skipped: [], errors: [] };
|
|
378
|
+
const result = { publisher: null, adopted: [], pushed: [], skipped: [], errors: [] };
|
|
379
379
|
const localName = deps.localName ?? machineId();
|
|
380
380
|
const localNorm = normalizeHost(localName);
|
|
381
381
|
const root = deps.userAgentsDir ?? getUserAgentsDir();
|
|
382
382
|
const meta = (deps.readMetaFn ?? readMeta)();
|
|
383
|
+
// Local repair first: a reserved key written by 1.22.84–1.22.89 is a bare
|
|
384
|
+
// file item with no bundle record, which the push below cannot read. Adopt
|
|
385
|
+
// it into its bundle here so the plan sees it; nothing leaves the box.
|
|
386
|
+
const adopt = deps.adoptLegacy ?? (async (m) => (await import('./auth-mint.js')).adoptLegacyReservedStoreItems(m));
|
|
387
|
+
try {
|
|
388
|
+
const adoption = await adopt(meta);
|
|
389
|
+
result.adopted.push(...adoption.adopted);
|
|
390
|
+
for (const err of adoption.errors)
|
|
391
|
+
result.errors.push({ device: localName, message: `adopt ${err.bundle} ${err.key}: ${err.message}` });
|
|
392
|
+
}
|
|
393
|
+
catch (err) {
|
|
394
|
+
result.errors.push({ device: localName, message: `adopt legacy reserved items: ${err.message}` });
|
|
395
|
+
}
|
|
383
396
|
const hasLocalKey = deps.hasLocalKey ?? defaultHasLocalKey;
|
|
384
397
|
const targets = reservedSyncTargets(meta).filter((t) => hasLocalKey(t.bundle, t.key));
|
|
385
398
|
const devices = (deps.listDevices ?? defaultDevices)().filter((d) => normalizeHost(d.name) !== localNorm);
|