@phnx-labs/agents-cli 1.22.46 → 1.22.47
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 +279 -0
- package/LICENSE +102 -182
- package/README.md +31 -18
- package/dist/cli/command-registry.d.ts +1 -1
- package/dist/cli/command-registry.js +4 -2
- package/dist/commands/accounts.js +56 -12
- package/dist/commands/auth.js +7 -5
- package/dist/commands/browser-sessions-picker.js +9 -7
- package/dist/commands/browser.js +266 -15
- package/dist/commands/config.js +26 -4
- package/dist/commands/cost.d.ts +1 -1
- package/dist/commands/detach.d.ts +13 -0
- package/dist/commands/detach.js +11 -8
- package/dist/commands/exec.js +55 -38
- package/dist/commands/feed-watch.d.ts +2 -0
- package/dist/commands/feed-watch.js +28 -0
- package/dist/commands/feed.js +27 -0
- package/dist/commands/feedback.js +3 -19
- package/dist/commands/insights.d.ts +1 -1
- package/dist/commands/insights.js +2 -2
- package/dist/commands/lease.js +3 -3
- package/dist/commands/routines.js +1 -1
- package/dist/commands/sessions-picker-factory.d.ts +2 -1
- package/dist/commands/sessions-picker-factory.js +1 -1
- package/dist/commands/sessions-stop.d.ts +24 -0
- package/dist/commands/sessions-stop.js +66 -0
- package/dist/commands/sessions-trace.d.ts +82 -0
- package/dist/commands/sessions-trace.js +387 -0
- package/dist/commands/sessions.js +4 -0
- package/dist/commands/ssh.d.ts +15 -0
- package/dist/commands/ssh.js +194 -16
- package/dist/commands/teams.js +34 -2
- package/dist/commands/view.d.ts +2 -0
- package/dist/commands/view.js +18 -31
- package/dist/commands/watchdog.js +9 -14
- package/dist/lib/account-capabilities.d.ts +4 -0
- package/dist/lib/account-capabilities.js +13 -9
- package/dist/lib/account-registry.d.ts +2 -0
- package/dist/lib/account-registry.js +30 -2
- package/dist/lib/accounting/rotate.d.ts +9 -0
- package/dist/lib/accounting/rotate.js +12 -2
- package/dist/lib/accounting/usage.d.ts +70 -3
- package/dist/lib/accounting/usage.js +235 -63
- package/dist/lib/agent-modes.js +29 -1
- package/dist/lib/agent-spec/agents.d.ts +4 -3
- package/dist/lib/agent-spec/agents.js +14 -6
- package/dist/lib/analytics/mix-commands.d.ts +1 -1
- package/dist/lib/analytics/mix-commands.js +2 -2
- package/dist/lib/auth-health.d.ts +13 -3
- package/dist/lib/auth-health.js +50 -8
- package/dist/lib/browser/devices.d.ts +19 -0
- package/dist/lib/browser/devices.js +23 -0
- package/dist/lib/browser/ipc.d.ts +1 -1
- package/dist/lib/browser/ipc.js +33 -7
- package/dist/lib/browser/profiles.d.ts +106 -1
- package/dist/lib/browser/profiles.js +300 -17
- package/dist/lib/browser/remote-control.d.ts +26 -2
- package/dist/lib/browser/remote-control.js +33 -3
- package/dist/lib/browser/runtime-state.d.ts +9 -0
- package/dist/lib/browser/runtime-state.js +24 -8
- package/dist/lib/browser/service.d.ts +37 -0
- package/dist/lib/browser/service.js +78 -20
- package/dist/lib/browser/sessions-list.d.ts +1 -3
- package/dist/lib/browser/sessions-list.js +3 -16
- package/dist/lib/browser/types.d.ts +12 -1
- package/dist/lib/codex-policy.d.ts +11 -1
- package/dist/lib/codex-policy.js +20 -5
- package/dist/lib/config-keys.d.ts +4 -3
- package/dist/lib/config-keys.js +17 -7
- package/dist/lib/daemon/runner.js +15 -2
- package/dist/lib/daemon-ticks.js +2 -2
- package/dist/lib/device-config.d.ts +25 -8
- package/dist/lib/device-config.js +90 -17
- package/dist/lib/devices/config-migration.d.ts +7 -0
- package/dist/lib/devices/config-migration.js +67 -3
- package/dist/lib/devices/health.d.ts +27 -2
- package/dist/lib/devices/health.js +39 -9
- package/dist/lib/devices/interactive-host.d.ts +15 -0
- package/dist/lib/devices/interactive-host.js +53 -0
- package/dist/lib/devices/registry.d.ts +61 -4
- package/dist/lib/devices/registry.js +94 -38
- package/dist/lib/devices/stats-cache.d.ts +6 -0
- package/dist/lib/devices/stats-cache.js +10 -2
- package/dist/lib/exec.d.ts +5 -3
- package/dist/lib/exec.js +32 -16
- package/dist/lib/feed/answer.d.ts +29 -0
- package/dist/lib/feed/answer.js +145 -0
- package/dist/lib/feed/attention.d.ts +83 -0
- package/dist/lib/feed/attention.js +0 -0
- package/dist/lib/feed/feed.d.ts +109 -0
- package/dist/lib/feed/feed.js +154 -3
- package/dist/lib/feed/pr-status.d.ts +14 -0
- package/dist/lib/feed/pr-status.js +47 -0
- package/dist/lib/feed/watch.d.ts +59 -0
- package/dist/lib/feed/watch.js +180 -0
- package/dist/lib/fleet/capture.js +6 -0
- package/dist/lib/fleet/remote-login.js +8 -3
- package/dist/lib/fleet/types.d.ts +17 -0
- package/dist/lib/harness/adapter.d.ts +11 -0
- package/dist/lib/harness/adapters/claude.js +38 -24
- package/dist/lib/harness/adapters/codex.js +2 -4
- package/dist/lib/hooks/cache.js +8 -0
- package/dist/lib/hooks/match.d.ts +3 -0
- package/dist/lib/hooks/match.js +25 -0
- package/dist/lib/hosts/passthrough.js +16 -0
- package/dist/lib/hosts/registry.js +7 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/install-menubar.d.ts +13 -0
- package/dist/lib/menubar/install-menubar.js +26 -2
- package/dist/lib/open-url.d.ts +60 -2
- package/dist/lib/open-url.js +167 -12
- package/dist/lib/refresh-coordinator.d.ts +1 -1
- package/dist/lib/refresh-coordinator.js +1 -1
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +2 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +13 -1
- package/dist/lib/session/active.d.ts +67 -0
- package/dist/lib/session/active.js +69 -1
- package/dist/lib/session/db.js +4 -2
- package/dist/lib/session/discover.d.ts +2 -0
- package/dist/lib/session/discover.js +12 -6
- package/dist/lib/session/prompt.d.ts +35 -0
- package/dist/lib/session/prompt.js +56 -0
- package/dist/lib/session/remote/watch.d.ts +1 -1
- package/dist/lib/session/remote/watch.js +6 -1
- package/dist/lib/session/render.js +7 -3
- package/dist/lib/session/session-cache.js +5 -1
- package/dist/lib/session/trajectory-compare.d.ts +47 -0
- package/dist/lib/session/trajectory-compare.js +116 -0
- package/dist/lib/session/trajectory-html.d.ts +15 -0
- package/dist/lib/session/trajectory-html.js +750 -0
- package/dist/lib/session/trajectory-lineage.d.ts +92 -0
- package/dist/lib/session/trajectory-lineage.js +195 -0
- package/dist/lib/session/trajectory-text.d.ts +37 -0
- package/dist/lib/session/trajectory-text.js +277 -0
- package/dist/lib/session/trajectory.d.ts +102 -0
- package/dist/lib/session/trajectory.js +335 -0
- package/dist/lib/startup/command-registry.d.ts +3 -1
- package/dist/lib/startup/command-registry.js +5 -2
- package/dist/lib/state.d.ts +1 -1
- package/dist/lib/state.js +4 -2
- package/dist/lib/teams/agents.js +9 -0
- package/dist/lib/terminal/inject.d.ts +19 -0
- package/dist/lib/terminal/inject.js +19 -9
- package/dist/lib/tmux/session.d.ts +46 -13
- package/dist/lib/tmux/session.js +173 -27
- package/dist/lib/types.d.ts +16 -0
- package/dist/lib/usage-backoff.d.ts +2 -1
- package/dist/lib/usage-backoff.js +60 -19
- package/dist/lib/usage-refresh.d.ts +17 -3
- package/dist/lib/usage-refresh.js +57 -11
- package/dist/lib/watchdog/log.d.ts +1 -1
- package/dist/lib/watchdog/log.js +1 -1
- package/dist/lib/watchdog/runner.d.ts +36 -45
- package/dist/lib/watchdog/runner.js +150 -119
- package/dist/lib/watchdog/service.d.ts +0 -1
- package/dist/lib/watchdog/service.js +0 -1
- package/dist/lib/watchdog/watchdog-agent.d.ts +38 -0
- package/dist/lib/watchdog/watchdog-agent.js +59 -0
- package/dist/lib/watchdog/watchdog.d.ts +11 -2
- package/dist/lib/watchdog/watchdog.js +37 -104
- package/package.json +2 -2
- package/dist/commands/usage.d.ts +0 -27
- package/dist/commands/usage.js +0 -96
|
@@ -17,8 +17,9 @@ import * as fs from 'fs/promises';
|
|
|
17
17
|
import * as fsSync from 'fs';
|
|
18
18
|
import * as path from 'path';
|
|
19
19
|
import lockfile from 'proper-lockfile';
|
|
20
|
-
import { getDevicesRegistryPath,
|
|
20
|
+
import { getDevicesRegistryPath, readMeta, updateMeta } from '../state.js';
|
|
21
21
|
import { atomicWriteJsonSync } from '../fs-atomic.js';
|
|
22
|
+
import { machineId } from '../machine-id.js';
|
|
22
23
|
/**
|
|
23
24
|
* Whether a fan-out should dial this device, honouring the preference stated on
|
|
24
25
|
* {@link DeviceProfile.reachability}: the live SSH probe wins over the cached
|
|
@@ -62,12 +63,46 @@ function registryPath() {
|
|
|
62
63
|
/** Valid logical device name: the ssh-alias charset, so it renders into an
|
|
63
64
|
* unambiguous `Host` stanza and is safe as an ssh target. */
|
|
64
65
|
const DEVICE_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
|
|
65
|
-
/**
|
|
66
|
+
/**
|
|
67
|
+
* `--device` values that mean "resolve me", not "connect to a box with this
|
|
68
|
+
* name". A real device registered under one of these would be unreachable,
|
|
69
|
+
* and pinning `interactive.host` to one is a misconfiguration that has to fail
|
|
70
|
+
* at WRITE time — otherwise the read side can only report "none is set", which
|
|
71
|
+
* tells the user to run the command they just ran.
|
|
72
|
+
*/
|
|
73
|
+
export const RESERVED_DEVICE_NAMES = new Set(['auto', 'interactive', 'all']);
|
|
74
|
+
/**
|
|
75
|
+
* Throw if `name` is not usable as an ssh alias (no spaces, quotes, etc.).
|
|
76
|
+
*
|
|
77
|
+
* SHAPE ONLY. Safe on read paths, which must keep working for a name that is
|
|
78
|
+
* already registered — including one this version would refuse to create.
|
|
79
|
+
*/
|
|
66
80
|
export function assertValidDeviceName(name) {
|
|
67
81
|
if (!DEVICE_NAME_RE.test(name)) {
|
|
68
82
|
throw new Error(`Invalid device name ${JSON.stringify(name)}. Use letters, digits, '.', '_', '-' (no spaces) — e.g. 'win-mini'.`);
|
|
69
83
|
}
|
|
70
84
|
}
|
|
85
|
+
/**
|
|
86
|
+
* Throw if `name` cannot be used for a NEW device: bad shape, or a reserved
|
|
87
|
+
* routing sentinel.
|
|
88
|
+
*
|
|
89
|
+
* Deliberately separate from {@link assertValidDeviceName}, and called from
|
|
90
|
+
* exactly two kinds of place: `agents devices add <name>`, and the config keys
|
|
91
|
+
* that point AT a device (`interactive.host`, `usage.primary-host`).
|
|
92
|
+
*
|
|
93
|
+
* Not from `upsertDevice`, `addIgnored` or the discovery writers. Those all
|
|
94
|
+
* receive tailnet node names the user never typed — `devices sync` upserts every
|
|
95
|
+
* observed node in a loop with no per-node catch, so one node named `auto` would
|
|
96
|
+
* abort the entire sync and register nothing after it. A name the fleet OBSERVES
|
|
97
|
+
* is not a name anyone CHOSE; only the second kind is policy's business.
|
|
98
|
+
*/
|
|
99
|
+
export function assertRegistrableDeviceName(name) {
|
|
100
|
+
assertValidDeviceName(name);
|
|
101
|
+
if (RESERVED_DEVICE_NAMES.has(name.trim().toLowerCase())) {
|
|
102
|
+
throw new Error(`${JSON.stringify(name)} is a reserved --device value, not a device name. ` +
|
|
103
|
+
`Reserved: ${[...RESERVED_DEVICE_NAMES].join(', ')}.`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
71
106
|
/** Map a Tailscale `OS` field to our platform enum. */
|
|
72
107
|
export function platformFromOs(os) {
|
|
73
108
|
switch ((os ?? '').toLowerCase()) {
|
|
@@ -246,55 +281,76 @@ export async function removeDevice(name) {
|
|
|
246
281
|
return true;
|
|
247
282
|
});
|
|
248
283
|
}
|
|
249
|
-
|
|
250
|
-
|
|
284
|
+
/**
|
|
285
|
+
* The full ignore-list entries — who dismissed a node, when, and on which box —
|
|
286
|
+
* the typed read side for `agents devices ignored`. Absent `fleet.ignored` =>
|
|
287
|
+
* []. A malformed block is a hard error for the same reason the registry is:
|
|
288
|
+
* silently returning [] would let the next write wipe the user's dismissals.
|
|
289
|
+
*/
|
|
290
|
+
export function loadIgnoredEntries(meta = readMeta()) {
|
|
291
|
+
const raw = meta.fleet?.ignored;
|
|
292
|
+
if (raw === undefined)
|
|
293
|
+
return [];
|
|
294
|
+
if (!Array.isArray(raw) ||
|
|
295
|
+
raw.some((e) => !e ||
|
|
296
|
+
typeof e.name !== 'string' ||
|
|
297
|
+
typeof e.ignoredAt !== 'string' ||
|
|
298
|
+
typeof e.ignoredOn !== 'string')) {
|
|
299
|
+
throw new Error(`Device ignore-list corrupted in agents.yaml (fleet.ignored): expected a list of { name, ignoredAt, ignoredOn } entries. Inspect and repair ~/.agents/agents.yaml.`);
|
|
300
|
+
}
|
|
301
|
+
return raw;
|
|
251
302
|
}
|
|
252
|
-
/** Load the set of ignored node names.
|
|
253
|
-
*
|
|
254
|
-
* [] would let the next write wipe the user's dismissals. */
|
|
303
|
+
/** Load the set of ignored node names. Same corruption contract as
|
|
304
|
+
* {@link loadIgnoredEntries}. */
|
|
255
305
|
export async function loadIgnored() {
|
|
256
|
-
|
|
257
|
-
let raw;
|
|
258
|
-
try {
|
|
259
|
-
raw = await fs.readFile(p, 'utf-8');
|
|
260
|
-
}
|
|
261
|
-
catch (err) {
|
|
262
|
-
if (err && err.code === 'ENOENT')
|
|
263
|
-
return new Set();
|
|
264
|
-
throw err;
|
|
265
|
-
}
|
|
266
|
-
try {
|
|
267
|
-
const parsed = JSON.parse(raw);
|
|
268
|
-
return new Set(Array.isArray(parsed.ignored) ? parsed.ignored : []);
|
|
269
|
-
}
|
|
270
|
-
catch (err) {
|
|
271
|
-
throw new Error(`Device ignore-list corrupted at ${p}: ${err?.message ?? err}. Inspect and restore from backup.`);
|
|
272
|
-
}
|
|
306
|
+
return new Set(loadIgnoredEntries().map((e) => e.name));
|
|
273
307
|
}
|
|
274
308
|
/** True if `name` is on the ignore-list. */
|
|
275
309
|
export async function isIgnored(name) {
|
|
276
310
|
return (await loadIgnored()).has(name);
|
|
277
311
|
}
|
|
312
|
+
/**
|
|
313
|
+
* Union `names` into the meta's ignore-list, stamping new entries with
|
|
314
|
+
* `ignoredAt` and THIS machine's id. Existing entries keep their original
|
|
315
|
+
* who/when, so re-adding a name is a true no-op. Returns the input unchanged
|
|
316
|
+
* when no name is new. Exported for the one-shot legacy-store migration in
|
|
317
|
+
* lib/devices/config-migration.ts.
|
|
318
|
+
*/
|
|
319
|
+
export function withIgnoredAdded(meta, names, ignoredAt) {
|
|
320
|
+
const entries = loadIgnoredEntries(meta); // throws on a corrupted block — never wipe it
|
|
321
|
+
const have = new Set(entries.map((e) => e.name));
|
|
322
|
+
const fresh = names.filter((n) => !have.has(n));
|
|
323
|
+
if (fresh.length === 0)
|
|
324
|
+
return meta;
|
|
325
|
+
const fleet = (meta.fleet ?? { devices: {} });
|
|
326
|
+
const ignored = [
|
|
327
|
+
...entries,
|
|
328
|
+
...fresh.map((name) => ({ name, ignoredAt, ignoredOn: machineId() })),
|
|
329
|
+
].sort((a, b) => a.name.localeCompare(b.name));
|
|
330
|
+
const nextFleet = { ...fleet, ignored };
|
|
331
|
+
return { ...meta, fleet: nextFleet };
|
|
332
|
+
}
|
|
278
333
|
/** Add a node name to the ignore-list. Idempotent. Returns the resulting set. */
|
|
279
334
|
export async function addIgnored(name) {
|
|
280
335
|
assertValidDeviceName(name);
|
|
281
|
-
const
|
|
282
|
-
return
|
|
283
|
-
const set = await loadIgnored();
|
|
284
|
-
set.add(name);
|
|
285
|
-
atomicWriteJsonSync(p, { ignored: [...set].sort(), updatedAt: new Date().toISOString() });
|
|
286
|
-
return set;
|
|
287
|
-
});
|
|
336
|
+
const meta = updateMeta((m) => withIgnoredAdded(m, [name], new Date().toISOString()));
|
|
337
|
+
return new Set(loadIgnoredEntries(meta).map((e) => e.name));
|
|
288
338
|
}
|
|
289
339
|
/** Remove a node name from the ignore-list (un-ignore). Returns false if it was
|
|
290
340
|
* not ignored. */
|
|
291
341
|
export async function removeIgnored(name) {
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
const
|
|
295
|
-
if (!
|
|
296
|
-
return
|
|
297
|
-
|
|
298
|
-
|
|
342
|
+
let removed = false;
|
|
343
|
+
updateMeta((m) => {
|
|
344
|
+
const fleet = m.fleet;
|
|
345
|
+
if (!fleet?.ignored)
|
|
346
|
+
return m;
|
|
347
|
+
const entries = loadIgnoredEntries(m);
|
|
348
|
+
const next = entries.filter((e) => e.name !== name);
|
|
349
|
+
if (next.length === entries.length)
|
|
350
|
+
return m;
|
|
351
|
+
removed = true;
|
|
352
|
+
const nextFleet = { ...fleet, ignored: next };
|
|
353
|
+
return { ...m, fleet: nextFleet };
|
|
299
354
|
});
|
|
355
|
+
return removed;
|
|
300
356
|
}
|
|
@@ -8,8 +8,12 @@ import type { DeviceProfile } from './registry.js';
|
|
|
8
8
|
* rewrites the cache, unreachable boxes included).
|
|
9
9
|
*/
|
|
10
10
|
export declare const STATS_STALE_MS: number;
|
|
11
|
+
/** Static hardware totals are valid for seven days. */
|
|
12
|
+
export declare const SPECS_STALE_MS: number;
|
|
11
13
|
/** True when a cached row is still within {@link STATS_STALE_MS}. */
|
|
12
14
|
export declare function isFreshDeviceStats(stats: DeviceStats, now?: number): boolean;
|
|
15
|
+
/** True when cached core, RAM-total, and disk-total facts remain current. */
|
|
16
|
+
export declare function isFreshDeviceSpecs(stats: DeviceStats, now?: number): boolean;
|
|
13
17
|
/** Read the whole cache (best-effort; a missing/corrupt file yields an empty map). */
|
|
14
18
|
export declare function readStatsCache(): Record<string, DeviceStats>;
|
|
15
19
|
/**
|
|
@@ -30,6 +34,8 @@ export interface FleetStatsResult {
|
|
|
30
34
|
export interface LoadFleetStatsOptions {
|
|
31
35
|
/** Skip the cache and live-probe every device (the `--refresh`/`--live` path). */
|
|
32
36
|
forceRefresh?: boolean;
|
|
37
|
+
/** Read only static hardware facts, whose cache lifetime is seven days. */
|
|
38
|
+
specsOnly?: boolean;
|
|
33
39
|
/** Device name of THIS machine — always probed locally (no ssh), never cached-served. */
|
|
34
40
|
selfName?: string;
|
|
35
41
|
/** Injectable probes + cache IO for tests (default to the real ssh/local/disk ones). */
|
|
@@ -37,10 +37,17 @@ const CACHE_FILE = '.fleet-stats.json';
|
|
|
37
37
|
* rewrites the cache, unreachable boxes included).
|
|
38
38
|
*/
|
|
39
39
|
export const STATS_STALE_MS = 3 * 60_000;
|
|
40
|
+
/** Static hardware totals are valid for seven days. */
|
|
41
|
+
export const SPECS_STALE_MS = 7 * 24 * 60 * 60_000;
|
|
40
42
|
/** True when a cached row is still within {@link STATS_STALE_MS}. */
|
|
41
43
|
export function isFreshDeviceStats(stats, now = Date.now()) {
|
|
42
44
|
return now - stats.fetchedAt <= STATS_STALE_MS;
|
|
43
45
|
}
|
|
46
|
+
/** True when cached core, RAM-total, and disk-total facts remain current. */
|
|
47
|
+
export function isFreshDeviceSpecs(stats, now = Date.now()) {
|
|
48
|
+
const fetchedAt = stats.specsFetchedAt ?? stats.fetchedAt;
|
|
49
|
+
return now - fetchedAt <= SPECS_STALE_MS;
|
|
50
|
+
}
|
|
44
51
|
function cacheFilePath() {
|
|
45
52
|
return path.join(getCacheDir(), CACHE_FILE);
|
|
46
53
|
}
|
|
@@ -93,13 +100,14 @@ export async function loadFleetStats(devices, opts = {}) {
|
|
|
93
100
|
const toProbe = [];
|
|
94
101
|
let servedFromCache = false;
|
|
95
102
|
for (const d of devices) {
|
|
96
|
-
if (d.name === self) {
|
|
103
|
+
if (d.name === self && !opts.specsOnly) {
|
|
97
104
|
// This machine is always probed locally — cheap, no ssh, always live.
|
|
98
105
|
toProbe.push(d);
|
|
99
106
|
continue;
|
|
100
107
|
}
|
|
101
108
|
const cached = cache[d.name];
|
|
102
|
-
|
|
109
|
+
const cacheFresh = cached && (opts.specsOnly ? isFreshDeviceSpecs(cached, now) : isFreshDeviceStats(cached, now));
|
|
110
|
+
if (cached && cacheFresh) {
|
|
103
111
|
stats.set(d.name, cached);
|
|
104
112
|
servedFromCache = true;
|
|
105
113
|
}
|
package/dist/lib/exec.d.ts
CHANGED
|
@@ -94,6 +94,8 @@ export type ExecEffort = 'low' | 'medium' | 'high' | 'xhigh' | 'max' | 'auto';
|
|
|
94
94
|
export interface ExecOptions {
|
|
95
95
|
agent: AgentId;
|
|
96
96
|
version?: string;
|
|
97
|
+
/** Version home whose native auth/config is overlaid onto this run's binary. */
|
|
98
|
+
configVersion?: string;
|
|
97
99
|
/** Omit to launch the CLI interactively -- no prompt, no --print, stdio fully inherited. */
|
|
98
100
|
prompt?: string;
|
|
99
101
|
/** Force interactive mode even when a prompt is provided. Wins over `headless`. */
|
|
@@ -398,7 +400,7 @@ export interface TmuxWrapContext {
|
|
|
398
400
|
raw: boolean;
|
|
399
401
|
/** The AGENTS_NO_TMUX=1 escape hatch. */
|
|
400
402
|
noTmuxEnv: boolean;
|
|
401
|
-
/** This device's `tmux.enabled` config —
|
|
403
|
+
/** This device's `tmux.enabled` config — true opts every eligible launch on this box into the wrap. */
|
|
402
404
|
configEnabled: boolean;
|
|
403
405
|
/** Whether a tmux binary is on PATH. */
|
|
404
406
|
tmuxAvailable: boolean;
|
|
@@ -407,7 +409,7 @@ export interface TmuxWrapContext {
|
|
|
407
409
|
* Decide whether to run an interactive agent INSIDE a detached tmux session on
|
|
408
410
|
* the shared socket (then attach the current TTY) instead of a bare spawn.
|
|
409
411
|
*
|
|
410
|
-
* tmux
|
|
412
|
+
* Opting into tmux wrapping gives every interactive agent an exact, unique `%pane` handle so
|
|
411
413
|
* `agents sessions --active` can tell co-located agents apart, and lets `agents
|
|
412
414
|
* focus` re-attach a live session without forking it. Pure so the gate is unit-
|
|
413
415
|
* tested independently of the (side-effecting) spawn.
|
|
@@ -418,7 +420,7 @@ export interface TmuxWrapContext {
|
|
|
418
420
|
* - not already in tmux — nesting tmux-in-tmux is pointless and confusing.
|
|
419
421
|
* - not --raw — explicit opt-out.
|
|
420
422
|
* - not AGENTS_NO_TMUX=1 — env opt-out (CI, scripts, the shim passthrough path).
|
|
421
|
-
* - tmux.enabled — this device
|
|
423
|
+
* - tmux.enabled — this device explicitly opted into addressable tmux panes.
|
|
422
424
|
* - tmux installed — otherwise there is nothing to wrap with.
|
|
423
425
|
*/
|
|
424
426
|
export declare function shouldWrapInTmux(ctx: TmuxWrapContext): boolean;
|
package/dist/lib/exec.js
CHANGED
|
@@ -28,7 +28,7 @@ import { recordRunName } from './session/run-names.js';
|
|
|
28
28
|
import { mailboxDir, isValidMailboxId } from './mailbox.js';
|
|
29
29
|
import { composeWin32CommandLine } from './platform/index.js';
|
|
30
30
|
import { isTmuxInstalled } from './tmux/binary.js';
|
|
31
|
-
import { isTmuxEnabled } from './device-config.js';
|
|
31
|
+
import { isTmuxEnabled, selfConfiguredDeviceRole } from './device-config.js';
|
|
32
32
|
import { machineId } from './machine-id.js';
|
|
33
33
|
import { shellQuote } from './ssh-exec.js';
|
|
34
34
|
import { codexEditWritableRoots, codexPolicyArgs } from './codex-policy.js';
|
|
@@ -293,17 +293,28 @@ export function buildExecEnv(options) {
|
|
|
293
293
|
// Resolve version/versionHome here (this module already imports
|
|
294
294
|
// installations/versions); the adapters must not import it themselves or they
|
|
295
295
|
// close a versions -> shims -> harness -> adapter import cycle.
|
|
296
|
-
const { version, versionHome } = resolveConfigVersion(options.agent, options.cwd || process.cwd(), options.version);
|
|
296
|
+
const { version, versionHome } = resolveConfigVersion(options.agent, options.cwd || process.cwd(), options.configVersion ?? options.version);
|
|
297
297
|
configAdapter.applyExecConfigEnv(result, {
|
|
298
298
|
agent: options.agent,
|
|
299
299
|
version,
|
|
300
300
|
versionHome,
|
|
301
301
|
interactive: resolveInteractive(options),
|
|
302
|
+
deviceRole: selfConfiguredDeviceRole(),
|
|
302
303
|
resolveClaudeSetupToken,
|
|
303
304
|
});
|
|
304
305
|
}
|
|
305
306
|
else {
|
|
306
307
|
stripForeignConfigDir(result);
|
|
308
|
+
// Grok's adapter owns the same GROK_HOME export in generated shims, but a
|
|
309
|
+
// labeled account deliberately runs one version's binary with another
|
|
310
|
+
// version's config. Apply that overlay here because the shim's binary-slot
|
|
311
|
+
// export cannot express a distinct account slot.
|
|
312
|
+
if (options.agent === 'grok' && options.configVersion) {
|
|
313
|
+
const { versionHome } = resolveConfigVersion(options.agent, options.cwd || process.cwd(), options.configVersion);
|
|
314
|
+
if (!versionHome)
|
|
315
|
+
throw new Error(`Cannot resolve Grok account config home for ${options.configVersion}.`);
|
|
316
|
+
result.GROK_HOME = path.join(versionHome, '.grok');
|
|
317
|
+
}
|
|
307
318
|
}
|
|
308
319
|
// Point the agent at its own mailbox so the PreToolUse `mailbox-inject` hook
|
|
309
320
|
// knows which box to drain and inject mid-run. Keyed by the session id — the
|
|
@@ -322,6 +333,7 @@ export function buildExecEnv(options) {
|
|
|
322
333
|
// every event the child emits. `options.sessionId` is the CHILD's id, so read the
|
|
323
334
|
// spawner from the live env; guard a same-session resume from naming itself parent.
|
|
324
335
|
// Local-spawn scope here; forwarding it across the `--device` SSH hop is Phase 4.
|
|
336
|
+
delete result.AGENTS_PARENT_SESSION_ID;
|
|
325
337
|
const spawnerSessionId = process.env.AGENTS_SESSION_ID || process.env.AGENT_SESSION_ID;
|
|
326
338
|
if (spawnerSessionId && spawnerSessionId !== options.sessionId) {
|
|
327
339
|
result.AGENTS_PARENT_SESSION_ID = spawnerSessionId;
|
|
@@ -388,6 +400,7 @@ export const AGENT_COMMANDS = {
|
|
|
388
400
|
// independent; legacy --sandbox flags cannot express that combination.
|
|
389
401
|
plan: [],
|
|
390
402
|
edit: [],
|
|
403
|
+
auto: [],
|
|
391
404
|
// skip = codex --yolo: drops the sandbox entirely and approves anything.
|
|
392
405
|
skip: ['--dangerously-bypass-approvals-and-sandbox'],
|
|
393
406
|
},
|
|
@@ -1163,7 +1176,7 @@ export function isPaneKnownAliveFromQueryResult(code, stdout) {
|
|
|
1163
1176
|
* Decide whether to run an interactive agent INSIDE a detached tmux session on
|
|
1164
1177
|
* the shared socket (then attach the current TTY) instead of a bare spawn.
|
|
1165
1178
|
*
|
|
1166
|
-
* tmux
|
|
1179
|
+
* Opting into tmux wrapping gives every interactive agent an exact, unique `%pane` handle so
|
|
1167
1180
|
* `agents sessions --active` can tell co-located agents apart, and lets `agents
|
|
1168
1181
|
* focus` re-attach a live session without forking it. Pure so the gate is unit-
|
|
1169
1182
|
* tested independently of the (side-effecting) spawn.
|
|
@@ -1174,7 +1187,7 @@ export function isPaneKnownAliveFromQueryResult(code, stdout) {
|
|
|
1174
1187
|
* - not already in tmux — nesting tmux-in-tmux is pointless and confusing.
|
|
1175
1188
|
* - not --raw — explicit opt-out.
|
|
1176
1189
|
* - not AGENTS_NO_TMUX=1 — env opt-out (CI, scripts, the shim passthrough path).
|
|
1177
|
-
* - tmux.enabled — this device
|
|
1190
|
+
* - tmux.enabled — this device explicitly opted into addressable tmux panes.
|
|
1178
1191
|
* - tmux installed — otherwise there is nothing to wrap with.
|
|
1179
1192
|
*/
|
|
1180
1193
|
export function shouldWrapInTmux(ctx) {
|
|
@@ -1282,11 +1295,14 @@ export function formatPaneTail(raw, maxLines = 30) {
|
|
|
1282
1295
|
* Lifecycle:
|
|
1283
1296
|
* 1. createSession() launches `sh -c 'exec env … agent'` detached, remain-on-exit
|
|
1284
1297
|
* on (global), and returns the pane id.
|
|
1285
|
-
* 2. A per-session `pane-died` hook
|
|
1286
|
-
*
|
|
1287
|
-
*
|
|
1288
|
-
*
|
|
1289
|
-
*
|
|
1298
|
+
* 2. A per-session `pane-died` hook tears the session down the instant the AGENT
|
|
1299
|
+
* pane exits: with a client attached it `detach-client`s so attach returns
|
|
1300
|
+
* instead of parking on a dead pane (then resolveAfterAttach reads the status
|
|
1301
|
+
* and kills); with no client attached it `kill-session`s outright, so a
|
|
1302
|
+
* wrapped agent that exits unattended can't leave a dead husk lingering on the
|
|
1303
|
+
* socket. The hook is guarded on `#{hook_pane}` so it fires ONLY for the agent
|
|
1304
|
+
* pane — user-created splits (Ctrl-b " / %) that the user exits are closed in
|
|
1305
|
+
* place (`kill-pane`) instead of tearing down the whole client, so exiting one
|
|
1290
1306
|
* split leaves the agent running full-window rather than kicking you out.
|
|
1291
1307
|
* 3. We record the agent pane's pid → session mapping (WITH the tmux pane) so the
|
|
1292
1308
|
* headless active-scan attributes it, then attach the TTY (blocking).
|
|
@@ -1303,7 +1319,7 @@ async function runInTmux(options, executable, args) {
|
|
|
1303
1319
|
const idSeed = (options.sessionId ?? randomUUID()).slice(0, 8);
|
|
1304
1320
|
const name = slugifyName(`ag-${options.agent}-${idSeed}`);
|
|
1305
1321
|
const RED = '\x1b[31m', GRAY = '\x1b[90m', OFF = '\x1b[0m';
|
|
1306
|
-
const NO_TMUX_TIP = `${GRAY}
|
|
1322
|
+
const NO_TMUX_TIP = `${GRAY} This run used the opt-in tmux wrap. Re-run with --no-tmux for a direct launch, or turn the wrap off: agents config set devices.${machineId()}.tmux off${OFF}\n\n`;
|
|
1307
1323
|
// Recap a dead pane's tail into THIS shell's stderr. The pane-died hook
|
|
1308
1324
|
// detaches the client the instant the agent exits, so a fast failure (a
|
|
1309
1325
|
// gutted install that dies with ENOENT, a bad flag, a crash on startup) would
|
|
@@ -1600,11 +1616,10 @@ async function spawnAgent(options) {
|
|
|
1600
1616
|
command: executable,
|
|
1601
1617
|
args: redactArgs(args.slice(0, 10)),
|
|
1602
1618
|
});
|
|
1603
|
-
// Interactive spawn-wrap: on macOS/Linux, run the
|
|
1604
|
-
// tmux session (then attach this TTY) so it gets
|
|
1605
|
-
//
|
|
1606
|
-
|
|
1607
|
-
if (shouldWrapInTmux({
|
|
1619
|
+
// Interactive spawn-wrap: when this device opts in on macOS/Linux, run the
|
|
1620
|
+
// agent INSIDE a shared-socket tmux session (then attach this TTY) so it gets
|
|
1621
|
+
// a unique, addressable %pane. Every failed guard keeps the bare spawn below.
|
|
1622
|
+
const wrapInTmux = shouldWrapInTmux({
|
|
1608
1623
|
interactive,
|
|
1609
1624
|
platform: process.platform,
|
|
1610
1625
|
inTmux: !!process.env.TMUX,
|
|
@@ -1612,7 +1627,8 @@ async function spawnAgent(options) {
|
|
|
1612
1627
|
noTmuxEnv: process.env.AGENTS_NO_TMUX === '1',
|
|
1613
1628
|
configEnabled: isTmuxEnabled(),
|
|
1614
1629
|
tmuxAvailable: isTmuxInstalled(),
|
|
1615
|
-
})
|
|
1630
|
+
});
|
|
1631
|
+
if (wrapInTmux) {
|
|
1616
1632
|
timer.mark('startup');
|
|
1617
1633
|
try {
|
|
1618
1634
|
const result = await runInTmux(options, executable, args);
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { type ActiveSession } from '../session/active.js';
|
|
2
|
+
import { type MessageReceipt } from './feed.js';
|
|
3
|
+
export interface VerifiedOperator {
|
|
4
|
+
id?: string;
|
|
5
|
+
verified: boolean;
|
|
6
|
+
label?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface FeedAnswerResult {
|
|
9
|
+
status: 'delivered' | 'already_answered';
|
|
10
|
+
receipt: MessageReceipt;
|
|
11
|
+
}
|
|
12
|
+
/** Atomically claim the first answer, then route it over the session's recorded reply rail. */
|
|
13
|
+
export declare function claimAndRouteAttentionAnswer(input: {
|
|
14
|
+
attentionKey: string;
|
|
15
|
+
choiceId?: string;
|
|
16
|
+
text?: string;
|
|
17
|
+
operator: VerifiedOperator;
|
|
18
|
+
feedRoot?: string;
|
|
19
|
+
mailboxRoot?: string;
|
|
20
|
+
sessions?: ActiveSession[];
|
|
21
|
+
}): Promise<FeedAnswerResult>;
|
|
22
|
+
/** Forward a fleet attention answer to the device that owns its scope. */
|
|
23
|
+
export declare function forwardFeedAnswer(input: {
|
|
24
|
+
host: string;
|
|
25
|
+
attentionKey: string;
|
|
26
|
+
choiceId?: string;
|
|
27
|
+
text?: string;
|
|
28
|
+
operatorId?: string;
|
|
29
|
+
}): Promise<FeedAnswerResult>;
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { getActiveSessions } from '../session/active.js';
|
|
3
|
+
import { resolveAnswerRoute, resumeArgv } from '../answer-router.js';
|
|
4
|
+
import { enqueue, mailboxDir } from '../mailbox.js';
|
|
5
|
+
import { injectIntoTerminal } from '../terminal/index.js';
|
|
6
|
+
import { verifyOperatorIdentity } from '../operator.js';
|
|
7
|
+
import { blockIdForSession, getAnswerRecord, publishBlock, readBlock, readResolution, recordAnswer, recordMessageReceipt, rollbackAnswerClaim, } from './feed.js';
|
|
8
|
+
import { reconcileAttention } from './attention.js';
|
|
9
|
+
import { readPullRequestStatus } from './pr-status.js';
|
|
10
|
+
import { getAgentsInvocation } from '../daemon/daemon.js';
|
|
11
|
+
function sessionForBlock(block, sessions) {
|
|
12
|
+
return sessions.find((session) => session.sessionId === block.sessionId || session.agentId === block.mailboxId);
|
|
13
|
+
}
|
|
14
|
+
function blockFromAttention(attention, session) {
|
|
15
|
+
return {
|
|
16
|
+
blockId: blockIdForSession(attention.sessionId), sessionId: attention.sessionId,
|
|
17
|
+
mailboxId: attention.mailboxId, host: attention.host, runtime: session.kind,
|
|
18
|
+
generation: attention.key.slice(attention.key.lastIndexOf('/') + 1), source: attention.source,
|
|
19
|
+
state: 'open', sourceCursor: attention.sourceCursor, project: attention.project,
|
|
20
|
+
ts: attention.openedAt, questions: [attention.question ?? { text: 'Continue from this attention item.' }],
|
|
21
|
+
kind: attention.kind === 'permission' ? 'notification' : attention.kind === 'declared' ? 'declared' : attention.source === 'system' ? 'control' : 'question',
|
|
22
|
+
safeDefault: attention.safeDefault,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
async function resolveBlock(attentionKey, sessions, root) {
|
|
26
|
+
const ownerHost = attentionKey.slice(0, attentionKey.indexOf('/'));
|
|
27
|
+
for (const session of sessions) {
|
|
28
|
+
if (!session.sessionId)
|
|
29
|
+
continue;
|
|
30
|
+
let block = readBlock(blockIdForSession(session.sessionId), root);
|
|
31
|
+
const projectedSession = { ...session, host: ownerHost };
|
|
32
|
+
const attention = reconcileAttention({ block, session: projectedSession, pullRequest: await readPullRequestStatus(session), resolution: readResolution(blockIdForSession(session.sessionId), root), nowMs: Date.now() });
|
|
33
|
+
if (attention?.key === attentionKey && block)
|
|
34
|
+
return { block, attention };
|
|
35
|
+
// The winning caller advances the block to answered before a concurrent
|
|
36
|
+
// loser resolves it. Reconstruct only this block's original generation so
|
|
37
|
+
// the loser can return already_answered without routing a second reply.
|
|
38
|
+
if (block) {
|
|
39
|
+
const original = reconcileAttention({ block: { ...block, state: 'open', answer: undefined }, session: projectedSession, nowMs: Date.now() });
|
|
40
|
+
if (original?.key === attentionKey && getAnswerRecord(block.blockId, root))
|
|
41
|
+
return { block, attention: original };
|
|
42
|
+
}
|
|
43
|
+
if (attention?.key === attentionKey) {
|
|
44
|
+
block = blockFromAttention(attention, session);
|
|
45
|
+
publishBlock(block, root);
|
|
46
|
+
return { block, attention };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
throw new Error(`No open attention item matches '${attentionKey}'.`);
|
|
50
|
+
}
|
|
51
|
+
/** Atomically claim the first answer, then route it over the session's recorded reply rail. */
|
|
52
|
+
export async function claimAndRouteAttentionAnswer(input) {
|
|
53
|
+
if ((input.choiceId == null) === (input.text == null))
|
|
54
|
+
throw new Error('Exactly one of choiceId or text is required.');
|
|
55
|
+
const sessions = input.sessions ?? await getActiveSessions();
|
|
56
|
+
const { block, attention } = await resolveBlock(input.attentionKey, sessions, input.feedRoot);
|
|
57
|
+
const choice = input.choiceId == null ? undefined : attention.choices?.find((item) => item.id === input.choiceId);
|
|
58
|
+
if (input.choiceId != null && !choice)
|
|
59
|
+
throw new Error(`Unknown choice '${input.choiceId}' for '${input.attentionKey}'.`);
|
|
60
|
+
const answer = input.text ?? choice?.deliveryKey ?? choice?.label;
|
|
61
|
+
if (!answer)
|
|
62
|
+
throw new Error('Answer is empty.');
|
|
63
|
+
const verified = input.operator.verified && verifyOperatorIdentity(input.operator.id);
|
|
64
|
+
const previousResolution = readResolution(block.blockId, input.feedRoot);
|
|
65
|
+
const claim = recordAnswer(block.blockId, {
|
|
66
|
+
answeredBy: input.operator.label,
|
|
67
|
+
answeredFrom: 'feed',
|
|
68
|
+
operatorId: input.operator.id,
|
|
69
|
+
verified,
|
|
70
|
+
}, input.feedRoot);
|
|
71
|
+
if (!claim.ok) {
|
|
72
|
+
if ('unauthorized' in claim)
|
|
73
|
+
throw new Error(claim.reason);
|
|
74
|
+
const existing = getAnswerRecord(block.blockId, input.feedRoot) ?? claim.existing;
|
|
75
|
+
return { status: 'already_answered', receipt: {
|
|
76
|
+
msgId: `answer-${block.blockId}`,
|
|
77
|
+
status: 'queued',
|
|
78
|
+
at: existing.answeredAt,
|
|
79
|
+
from: existing.answeredBy ?? existing.answeredFrom,
|
|
80
|
+
} };
|
|
81
|
+
}
|
|
82
|
+
const claimed = getAnswerRecord(block.blockId, input.feedRoot);
|
|
83
|
+
if (!claimed)
|
|
84
|
+
throw new Error(`Answer claim for '${input.attentionKey}' was not persisted.`);
|
|
85
|
+
const session = sessionForBlock(block, sessions);
|
|
86
|
+
const route = resolveAnswerRoute({ mailboxId: block.mailboxId, answer, block, session });
|
|
87
|
+
if (route.kind === 'refuse') {
|
|
88
|
+
rollbackAnswerClaim(block.blockId, claimed.answeredAt, block, previousResolution, input.feedRoot);
|
|
89
|
+
throw new Error(route.reason);
|
|
90
|
+
}
|
|
91
|
+
let msgId;
|
|
92
|
+
try {
|
|
93
|
+
if (route.kind === 'mailbox') {
|
|
94
|
+
msgId = enqueue(mailboxDir(block.mailboxId, input.mailboxRoot), {
|
|
95
|
+
to: block.mailboxId, text: answer, from: input.operator.label, blockId: block.blockId,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
else if (route.kind === 'resume') {
|
|
99
|
+
const invocation = getAgentsInvocation(resumeArgv(route));
|
|
100
|
+
const child = spawn(invocation.command, invocation.args, { stdio: 'inherit', env: process.env });
|
|
101
|
+
const code = await new Promise((resolve) => { child.once('error', () => resolve(1)); child.once('close', (value) => resolve(value ?? 1)); });
|
|
102
|
+
if (code !== 0)
|
|
103
|
+
throw new Error(`Resume delivery for '${input.attentionKey}' exited ${code}.`);
|
|
104
|
+
msgId = `resume-${Date.now()}`;
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
if (!route.inject || route.payload == null)
|
|
108
|
+
throw new Error(`Incomplete ${route.kind} reply rail.`);
|
|
109
|
+
const delivered = await injectIntoTerminal(route.inject, route.payload, { enter: true, combined: false });
|
|
110
|
+
if (!delivered.ok)
|
|
111
|
+
throw new Error(delivered.error ?? `Failed to deliver over ${route.kind}.`);
|
|
112
|
+
msgId = `inject-${Date.now()}`;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
rollbackAnswerClaim(block.blockId, claimed.answeredAt, block, previousResolution, input.feedRoot);
|
|
117
|
+
throw error;
|
|
118
|
+
}
|
|
119
|
+
const receipt = { msgId, status: 'queued', at: new Date().toISOString(), from: input.operator.label };
|
|
120
|
+
recordMessageReceipt(block.blockId, receipt, input.feedRoot);
|
|
121
|
+
return { status: 'delivered', receipt };
|
|
122
|
+
}
|
|
123
|
+
/** Forward a fleet attention answer to the device that owns its scope. */
|
|
124
|
+
export async function forwardFeedAnswer(input) {
|
|
125
|
+
const remoteArgs = ['feed', 'answer', input.attentionKey, '--json'];
|
|
126
|
+
if (input.choiceId != null)
|
|
127
|
+
remoteArgs.push('--choice', input.choiceId);
|
|
128
|
+
if (input.text != null)
|
|
129
|
+
remoteArgs.push('--text', input.text);
|
|
130
|
+
if (input.operatorId)
|
|
131
|
+
remoteArgs.push('--as', input.operatorId);
|
|
132
|
+
const invocation = getAgentsInvocation(['ssh', input.host, 'agents', ...remoteArgs]);
|
|
133
|
+
const child = spawn(invocation.command, invocation.args, { stdio: ['ignore', 'pipe', 'pipe'], env: process.env });
|
|
134
|
+
let stdout = '';
|
|
135
|
+
let stderr = '';
|
|
136
|
+
child.stdout.on('data', (chunk) => { stdout += String(chunk); });
|
|
137
|
+
child.stderr.on('data', (chunk) => { stderr += String(chunk); });
|
|
138
|
+
const code = await new Promise((resolve) => { child.once('error', () => resolve(1)); child.once('close', (value) => resolve(value ?? 1)); });
|
|
139
|
+
if (code !== 0)
|
|
140
|
+
throw new Error(stderr.trim() || `Remote feed answer on '${input.host}' exited ${code}.`);
|
|
141
|
+
const line = stdout.trim().split('\n').reverse().find((value) => value.startsWith('{'));
|
|
142
|
+
if (!line)
|
|
143
|
+
throw new Error(`Remote feed answer on '${input.host}' returned no JSON receipt.`);
|
|
144
|
+
return JSON.parse(line);
|
|
145
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { ActiveSession } from '../session/active.js';
|
|
2
|
+
import { type AttentionResolution, type AttentionSource, type AttentionState, type BlockOption, type BlockQuestion, type OpenBlock, type SourceCursor } from './feed.js';
|
|
3
|
+
/** What an attention item is asking of the operator. */
|
|
4
|
+
export type AttentionKind = 'question' | 'permission' | 'plan_review' | 'declared' | 'failure' | 'stall' | 'review';
|
|
5
|
+
/** How an answer can be routed back to the waiting agent. */
|
|
6
|
+
export type ReplyCapability = 'terminal' | 'tmux' | 'cloud' | 'team' | 'none';
|
|
7
|
+
/**
|
|
8
|
+
* One answerable choice on an attention item. Extends {@link BlockOption} with a
|
|
9
|
+
* stable `id` the UI echoes back when the operator picks it, and an optional
|
|
10
|
+
* `deliveryKey` (the harness-native keystroke / selection token) that Track B's
|
|
11
|
+
* answer router resolves the reply rail with.
|
|
12
|
+
*/
|
|
13
|
+
export interface AttentionChoice extends BlockOption {
|
|
14
|
+
id: string;
|
|
15
|
+
deliveryKey?: string;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* The canonical operator-facing attention record. One reconciled thing that needs
|
|
19
|
+
* a human, whatever produced it. This is the public envelope Tracks B and C
|
|
20
|
+
* consume — a stability contract, not an internal shape.
|
|
21
|
+
*/
|
|
22
|
+
export interface AttentionItem {
|
|
23
|
+
/** Stable identity: `host/session/generation`. A new generation is a new item. */
|
|
24
|
+
key: string;
|
|
25
|
+
sessionId: string;
|
|
26
|
+
mailboxId: string;
|
|
27
|
+
host: string;
|
|
28
|
+
project?: string;
|
|
29
|
+
kind: AttentionKind;
|
|
30
|
+
source: AttentionSource;
|
|
31
|
+
state: AttentionState;
|
|
32
|
+
/** ISO-8601 timestamp the ask opened. */
|
|
33
|
+
openedAt: string;
|
|
34
|
+
question?: BlockQuestion;
|
|
35
|
+
choices?: AttentionChoice[];
|
|
36
|
+
replyCapability: ReplyCapability;
|
|
37
|
+
safeDefault?: string;
|
|
38
|
+
/**
|
|
39
|
+
* Clusters IDENTICAL asks across different agents for batch triage. It is a hash
|
|
40
|
+
* of the RAW question intent (kind + verbatim question text + option labels) —
|
|
41
|
+
* deliberately NOT the UI-normalized display text, so two agents asking the same
|
|
42
|
+
* thing collapse even when the floor renders them differently.
|
|
43
|
+
*/
|
|
44
|
+
fingerprint: string;
|
|
45
|
+
/** Where in the source this generation sits — the fence a resolution compares against. */
|
|
46
|
+
sourceCursor?: SourceCursor;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* A pull-request attention signal, supplied BY the CLI (Track B refreshes it on a
|
|
50
|
+
* bounded TTL and hands it in). Track A only consumes the computed `needsHuman`
|
|
51
|
+
* flag — it never reaches into the extension's PR board or recomputes review
|
|
52
|
+
* state. Fields mirror the `gh pr view` projection so Track B can populate it.
|
|
53
|
+
*/
|
|
54
|
+
export interface PullRequestAttentionSignal {
|
|
55
|
+
number: number;
|
|
56
|
+
title?: string;
|
|
57
|
+
url?: string;
|
|
58
|
+
/** True when the PR is in a state that needs a human decision (review / merge). */
|
|
59
|
+
needsHuman: boolean;
|
|
60
|
+
reviewDecision?: string;
|
|
61
|
+
mergeable?: string;
|
|
62
|
+
state?: string;
|
|
63
|
+
isDraft?: boolean;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Fingerprint of an ask's raw intent. Uses the verbatim question text and option
|
|
67
|
+
* labels (NOT display-normalized text) so identical asks from different agents
|
|
68
|
+
* share a fingerprint for batch triage.
|
|
69
|
+
*/
|
|
70
|
+
export declare function attentionFingerprint(kind: AttentionKind, question?: BlockQuestion): string;
|
|
71
|
+
/**
|
|
72
|
+
* Reconcile the feed block ledger, the session lifecycle, a CLI-supplied PR
|
|
73
|
+
* signal, and the latest resolution tombstone into one attention item — or
|
|
74
|
+
* `undefined` when nothing needs a human. Pure; the extension never chooses
|
|
75
|
+
* authority.
|
|
76
|
+
*/
|
|
77
|
+
export declare function reconcileAttention(input: {
|
|
78
|
+
block?: OpenBlock;
|
|
79
|
+
session: ActiveSession;
|
|
80
|
+
pullRequest?: PullRequestAttentionSignal;
|
|
81
|
+
resolution?: AttentionResolution;
|
|
82
|
+
nowMs: number;
|
|
83
|
+
}): AttentionItem | undefined;
|
|
Binary file
|