@phnx-labs/agents-cli 1.22.45 → 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 +303 -0
- package/LICENSE +102 -182
- package/README.md +54 -17
- package/dist/cli/command-registry.d.ts +2 -1
- package/dist/cli/command-registry.js +6 -2
- package/dist/commands/accounts.js +56 -12
- package/dist/commands/auth.d.ts +2 -0
- package/dist/commands/auth.js +229 -0
- 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 +19 -32
- 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 +92 -6
- package/dist/lib/accounting/usage.js +329 -75
- 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 +20 -7
- 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/cli-resources.js +17 -15
- 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/harness-inventory.js +20 -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 +25 -3
- package/dist/lib/exec.js +75 -22
- 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/identity/client.d.ts +53 -0
- package/dist/lib/identity/client.js +106 -0
- package/dist/lib/identity/index.d.ts +115 -0
- package/dist/lib/identity/index.js +82 -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/probe.d.ts +8 -0
- package/dist/lib/probe.js +105 -0
- 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 +5 -2
- package/dist/lib/startup/command-registry.js +8 -5
- 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/view-types.d.ts +2 -2
- 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
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { Meta } from '../types.js';
|
|
2
|
+
import type { IgnoredDeviceEntry } from '../fleet/types.js';
|
|
1
3
|
/** Operating-system family of a device, used to pick the remote shell. */
|
|
2
4
|
export type DevicePlatform = 'windows' | 'linux' | 'macos' | 'unknown';
|
|
3
5
|
/** Remote shell dialect derived from the platform. */
|
|
@@ -84,8 +86,36 @@ export interface DeviceProfile {
|
|
|
84
86
|
export declare function isDialableDevice(d: DeviceProfile): boolean;
|
|
85
87
|
/** Map of device name to profile. */
|
|
86
88
|
export type DeviceRegistry = Record<string, DeviceProfile>;
|
|
87
|
-
/**
|
|
89
|
+
/**
|
|
90
|
+
* `--device` values that mean "resolve me", not "connect to a box with this
|
|
91
|
+
* name". A real device registered under one of these would be unreachable,
|
|
92
|
+
* and pinning `interactive.host` to one is a misconfiguration that has to fail
|
|
93
|
+
* at WRITE time — otherwise the read side can only report "none is set", which
|
|
94
|
+
* tells the user to run the command they just ran.
|
|
95
|
+
*/
|
|
96
|
+
export declare const RESERVED_DEVICE_NAMES: Set<string>;
|
|
97
|
+
/**
|
|
98
|
+
* Throw if `name` is not usable as an ssh alias (no spaces, quotes, etc.).
|
|
99
|
+
*
|
|
100
|
+
* SHAPE ONLY. Safe on read paths, which must keep working for a name that is
|
|
101
|
+
* already registered — including one this version would refuse to create.
|
|
102
|
+
*/
|
|
88
103
|
export declare function assertValidDeviceName(name: string): void;
|
|
104
|
+
/**
|
|
105
|
+
* Throw if `name` cannot be used for a NEW device: bad shape, or a reserved
|
|
106
|
+
* routing sentinel.
|
|
107
|
+
*
|
|
108
|
+
* Deliberately separate from {@link assertValidDeviceName}, and called from
|
|
109
|
+
* exactly two kinds of place: `agents devices add <name>`, and the config keys
|
|
110
|
+
* that point AT a device (`interactive.host`, `usage.primary-host`).
|
|
111
|
+
*
|
|
112
|
+
* Not from `upsertDevice`, `addIgnored` or the discovery writers. Those all
|
|
113
|
+
* receive tailnet node names the user never typed — `devices sync` upserts every
|
|
114
|
+
* observed node in a loop with no per-node catch, so one node named `auto` would
|
|
115
|
+
* abort the entire sync and register nothing after it. A name the fleet OBSERVES
|
|
116
|
+
* is not a name anyone CHOSE; only the second kind is policy's business.
|
|
117
|
+
*/
|
|
118
|
+
export declare function assertRegistrableDeviceName(name: string): void;
|
|
89
119
|
/** Map a Tailscale `OS` field to our platform enum. */
|
|
90
120
|
export declare function platformFromOs(os: string | undefined): DevicePlatform;
|
|
91
121
|
/** The remote shell a platform speaks. */
|
|
@@ -132,12 +162,39 @@ export declare function upsertDevice(name: string, input: DeviceInput): Promise<
|
|
|
132
162
|
export declare function writeReachability(updates: Record<string, DeviceReachability>): Promise<string[]>;
|
|
133
163
|
/** Remove a device. Returns false if it was not registered. */
|
|
134
164
|
export declare function removeDevice(name: string): Promise<boolean>;
|
|
135
|
-
/**
|
|
136
|
-
*
|
|
137
|
-
*
|
|
165
|
+
/**
|
|
166
|
+
* The ignore-list: tailscale node names the user explicitly dismissed from
|
|
167
|
+
* auto-discovery. A dismissed node is deliberately NOT a device (it never
|
|
168
|
+
* enters the registry), so it has no per-device doc — its home is the central,
|
|
169
|
+
* TRACKED `~/.agents/agents.yaml` under a `fleet.ignored` list, which syncs
|
|
170
|
+
* fleet-wide via `agents repo push/pull`: a dismissal on one box stops the
|
|
171
|
+
* suggestion on every box (RUSH-3062). Auto-discovery (`runDeviceSync`'s
|
|
172
|
+
* pending diff) subtracts this set, so an ignored node never re-surfaces as a
|
|
173
|
+
* suggestion. Writes go through `updateMeta` — the central-config write path
|
|
174
|
+
* (withMetaLock + atomic write) — so two agents writing concurrently cannot
|
|
175
|
+
* corrupt it, and no second lock is hand-rolled here.
|
|
176
|
+
*/
|
|
177
|
+
export type { IgnoredDeviceEntry } from '../fleet/types.js';
|
|
178
|
+
/**
|
|
179
|
+
* The full ignore-list entries — who dismissed a node, when, and on which box —
|
|
180
|
+
* the typed read side for `agents devices ignored`. Absent `fleet.ignored` =>
|
|
181
|
+
* []. A malformed block is a hard error for the same reason the registry is:
|
|
182
|
+
* silently returning [] would let the next write wipe the user's dismissals.
|
|
183
|
+
*/
|
|
184
|
+
export declare function loadIgnoredEntries(meta?: Meta): IgnoredDeviceEntry[];
|
|
185
|
+
/** Load the set of ignored node names. Same corruption contract as
|
|
186
|
+
* {@link loadIgnoredEntries}. */
|
|
138
187
|
export declare function loadIgnored(): Promise<Set<string>>;
|
|
139
188
|
/** True if `name` is on the ignore-list. */
|
|
140
189
|
export declare function isIgnored(name: string): Promise<boolean>;
|
|
190
|
+
/**
|
|
191
|
+
* Union `names` into the meta's ignore-list, stamping new entries with
|
|
192
|
+
* `ignoredAt` and THIS machine's id. Existing entries keep their original
|
|
193
|
+
* who/when, so re-adding a name is a true no-op. Returns the input unchanged
|
|
194
|
+
* when no name is new. Exported for the one-shot legacy-store migration in
|
|
195
|
+
* lib/devices/config-migration.ts.
|
|
196
|
+
*/
|
|
197
|
+
export declare function withIgnoredAdded(meta: Meta, names: string[], ignoredAt: string): Meta;
|
|
141
198
|
/** Add a node name to the ignore-list. Idempotent. Returns the resulting set. */
|
|
142
199
|
export declare function addIgnored(name: string): Promise<Set<string>>;
|
|
143
200
|
/** Remove a node name from the ignore-list (un-ignore). Returns false if it was
|
|
@@ -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;
|
|
@@ -482,6 +484,26 @@ export declare const UNKNOWN_OUTCOME_EXIT_CODE = 1;
|
|
|
482
484
|
export declare const RATE_LIMIT_PATTERNS: RegExp[];
|
|
483
485
|
/** Return true if the text contains any known rate-limit or overload indicator. */
|
|
484
486
|
export declare function detectRateLimit(text: string): boolean;
|
|
487
|
+
export declare function detectOutOfCredits(text: string): boolean;
|
|
488
|
+
/**
|
|
489
|
+
* Classify what a Claude run's output + exit code means for the account's
|
|
490
|
+
* persisted refusal marker. Pure and exported so the persist/clear decision is
|
|
491
|
+
* unit-tested on the real path (runWithFallback can't be driven with a real
|
|
492
|
+
* `claude` spawn in tests). Precedence: a session-limit reset wins (it carries a
|
|
493
|
+
* clock), then a clock-less billing exhaustion, then a clean success clears any
|
|
494
|
+
* stale marker; anything else leaves the marker untouched.
|
|
495
|
+
*/
|
|
496
|
+
export type ClaudeRefusalAction = {
|
|
497
|
+
action: 'note_session';
|
|
498
|
+
resetsAt: Date;
|
|
499
|
+
} | {
|
|
500
|
+
action: 'note_out_of_credits';
|
|
501
|
+
} | {
|
|
502
|
+
action: 'clear';
|
|
503
|
+
} | {
|
|
504
|
+
action: 'none';
|
|
505
|
+
};
|
|
506
|
+
export declare function classifyClaudeRunRefusal(output: string, exitCode: number): ClaudeRefusalAction;
|
|
485
507
|
/**
|
|
486
508
|
* Patterns that indicate an authentication failure — the agent is logged out,
|
|
487
509
|
* its token was revoked, or the session expired. These are the user-visible
|
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';
|
|
@@ -38,7 +38,7 @@ import { applyActiveRulesPresetAtRun } from './rules/run-sync.js';
|
|
|
38
38
|
import { resolveHarnessAdapter, stripForeignConfigDir } from './harness/index.js';
|
|
39
39
|
import { resolveConfigVersion } from './harness/exec-config-version.js';
|
|
40
40
|
import { getAccountInfo } from './agents.js';
|
|
41
|
-
import { getUsageLookupKey, noteClaudeSessionLimit, parseClaudeSessionLimitReset } from './accounting/usage.js';
|
|
41
|
+
import { getUsageLookupKey, noteClaudeSessionLimit, noteClaudeOutOfCredits, clearClaudeAccountRefusal, parseClaudeSessionLimitReset } from './accounting/usage.js';
|
|
42
42
|
/**
|
|
43
43
|
* Map a raw mode string (CLI flag, YAML field, env var) to the canonical Mode.
|
|
44
44
|
*
|
|
@@ -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);
|
|
@@ -1865,6 +1881,29 @@ export const RATE_LIMIT_PATTERNS = [
|
|
|
1865
1881
|
export function detectRateLimit(text) {
|
|
1866
1882
|
return RATE_LIMIT_PATTERNS.some(pattern => pattern.test(text));
|
|
1867
1883
|
}
|
|
1884
|
+
/**
|
|
1885
|
+
* Narrow detector for a BILLING exhaustion — tokens/credits run out or the
|
|
1886
|
+
* monthly spend cap is hit — as opposed to a time-window rate limit. This class
|
|
1887
|
+
* does NOT recover on a clock, so rotation must remember it per-account
|
|
1888
|
+
* (noteClaudeOutOfCredits) until a later successful run clears it.
|
|
1889
|
+
*/
|
|
1890
|
+
const OUT_OF_CREDITS_PATTERNS = [
|
|
1891
|
+
/out of (?:usage )?credits/i,
|
|
1892
|
+
/spend[\s-]?limit/i,
|
|
1893
|
+
];
|
|
1894
|
+
export function detectOutOfCredits(text) {
|
|
1895
|
+
return OUT_OF_CREDITS_PATTERNS.some(pattern => pattern.test(text));
|
|
1896
|
+
}
|
|
1897
|
+
export function classifyClaudeRunRefusal(output, exitCode) {
|
|
1898
|
+
const sessionLimitReset = parseClaudeSessionLimitReset(output);
|
|
1899
|
+
if (sessionLimitReset)
|
|
1900
|
+
return { action: 'note_session', resetsAt: sessionLimitReset };
|
|
1901
|
+
if (detectOutOfCredits(output))
|
|
1902
|
+
return { action: 'note_out_of_credits' };
|
|
1903
|
+
if (exitCode === 0)
|
|
1904
|
+
return { action: 'clear' };
|
|
1905
|
+
return { action: 'none' };
|
|
1906
|
+
}
|
|
1868
1907
|
/**
|
|
1869
1908
|
* Patterns that indicate an authentication failure — the agent is logged out,
|
|
1870
1909
|
* its token was revoked, or the session expired. These are the user-visible
|
|
@@ -2086,12 +2125,26 @@ export async function runWithFallback(options) {
|
|
|
2086
2125
|
throw err;
|
|
2087
2126
|
}
|
|
2088
2127
|
const output = `${result.stderr}\n${result.stdout}`;
|
|
2128
|
+
// Persist a per-account refusal marker so rotation stops re-picking a
|
|
2129
|
+
// known-dead account: a session-limit recovers on its clock, a billing
|
|
2130
|
+
// exhaustion (tokens/credits) recovers only on a later successful run, and a
|
|
2131
|
+
// clean run clears any stale marker. Decision extracted + unit-tested in
|
|
2132
|
+
// classifyClaudeRunRefusal.
|
|
2089
2133
|
const sessionLimitReset = agent === 'claude' ? parseClaudeSessionLimitReset(output) : null;
|
|
2090
|
-
if (
|
|
2091
|
-
const
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2134
|
+
if (agent === 'claude' && version) {
|
|
2135
|
+
const refusal = classifyClaudeRunRefusal(output, result.exitCode ?? 1);
|
|
2136
|
+
if (refusal.action !== 'none') {
|
|
2137
|
+
const account = await getAccountInfo(agent, getVersionHomePath(agent, version));
|
|
2138
|
+
const usageKey = getUsageLookupKey(account);
|
|
2139
|
+
if (usageKey) {
|
|
2140
|
+
if (refusal.action === 'note_session')
|
|
2141
|
+
noteClaudeSessionLimit(usageKey, refusal.resetsAt);
|
|
2142
|
+
else if (refusal.action === 'note_out_of_credits')
|
|
2143
|
+
noteClaudeOutOfCredits(usageKey);
|
|
2144
|
+
else if (refusal.action === 'clear')
|
|
2145
|
+
clearClaudeAccountRefusal(usageKey);
|
|
2146
|
+
}
|
|
2147
|
+
}
|
|
2095
2148
|
}
|
|
2096
2149
|
if (result.exitCode === 0 && !sessionLimitReset)
|
|
2097
2150
|
return 0;
|
|
@@ -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>;
|