@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
|
@@ -9,6 +9,22 @@
|
|
|
9
9
|
* pure provenance — `listSessions()` always reconciles them against
|
|
10
10
|
* `tmux list-sessions` and prunes stale entries on the fly.
|
|
11
11
|
*/
|
|
12
|
+
/**
|
|
13
|
+
* Ten times tmux's 2,000-line default, while keeping per-pane memory bounded:
|
|
14
|
+
* history storage scales with pane width, so a 200-column pane retains at most
|
|
15
|
+
* four million grid cells rather than an unbounded agent transcript.
|
|
16
|
+
*/
|
|
17
|
+
export declare const AGENTS_TMUX_HISTORY_LIMIT = 20000;
|
|
18
|
+
/**
|
|
19
|
+
* Bump when the generated startup config changes, so an ALREADY-RUNNING server
|
|
20
|
+
* picks the new settings up. tmux reads `-f` only when it actually starts a
|
|
21
|
+
* server: a second `new-session -f other.conf` against a live server exits 0
|
|
22
|
+
* and silently applies nothing (verified against tmux 3.4). Since agents-cli
|
|
23
|
+
* uses one long-lived shared socket, the normal state at upgrade is a running
|
|
24
|
+
* server — so without this reconcile every existing machine would get none of
|
|
25
|
+
* these defaults, silently. Same shape as AGENT_HOOK_SCHEMA below.
|
|
26
|
+
*/
|
|
27
|
+
export declare const AGENTS_TMUX_CONFIG_SCHEMA = 1;
|
|
12
28
|
/** Provenance written alongside each live tmux session. */
|
|
13
29
|
export interface SessionMeta {
|
|
14
30
|
name: string;
|
|
@@ -62,6 +78,8 @@ export interface ListedSession {
|
|
|
62
78
|
attached: boolean;
|
|
63
79
|
meta?: SessionMeta;
|
|
64
80
|
}
|
|
81
|
+
/** Build the atomic server-configuration + session-creation command. */
|
|
82
|
+
export declare function buildCreateSessionArgs(opts: CreateSessionOptions, startupConfig: string): string[];
|
|
65
83
|
export declare class TmuxSessionError extends Error {
|
|
66
84
|
readonly cause?: unknown | undefined;
|
|
67
85
|
constructor(message: string, cause?: unknown | undefined);
|
|
@@ -213,21 +231,36 @@ export declare function setSessionHook(name: string, hook: string, command: stri
|
|
|
213
231
|
* variant could stall the hook on a loaded CI runner, letting the dead
|
|
214
232
|
* split survive until the test's wait timeout expired (flake in CI shard
|
|
215
233
|
* 3: pane-guarded pane-died hook / user split exit).
|
|
234
|
+
* v6 — the agent-pane branch is now `session_attached`-aware: with a client
|
|
235
|
+
* attached it `detach-client`s (the interactive path, so runInTmux's
|
|
236
|
+
* attach returns and reads the exit status, EXEC-23b unchanged); with NO
|
|
237
|
+
* client it `kill-session`s outright. A `remain-on-exit` pane with no
|
|
238
|
+
* attach client to reap it otherwise lingers as a dead husk until the
|
|
239
|
+
* daemon's periodic `reapDeadTmuxPanes` sweep — so a run whose terminal
|
|
240
|
+
* was closed, or a `/exit` after the user detached, left an idle-looking
|
|
241
|
+
* dead session on the socket. Killing it the instant the agent exits is
|
|
242
|
+
* the "one close tears down every layer" contract (#5a).
|
|
216
243
|
*/
|
|
217
|
-
export declare const AGENT_HOOK_SCHEMA =
|
|
244
|
+
export declare const AGENT_HOOK_SCHEMA = 6;
|
|
218
245
|
/**
|
|
219
|
-
* The guarded `pane-died` hook.
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
*
|
|
246
|
+
* The guarded `pane-died` hook. When the AGENT pane dies, tear the whole session
|
|
247
|
+
* down — but HOW depends on whether a client is attached:
|
|
248
|
+
* - attached (`#{session_attached}`): `detach-client` so the blocking attach in
|
|
249
|
+
* runInTmux returns and `resolveAfterAttach` reads the exit status off the
|
|
250
|
+
* still-`remain-on-exit` dead pane, then kills the session (EXEC-23b, unchanged);
|
|
251
|
+
* - no client: `kill-session` outright. Without this the dead pane sits under
|
|
252
|
+
* `remain-on-exit` as an idle-looking husk until the daemon's periodic
|
|
253
|
+
* `reapDeadTmuxPanes` sweep — the "second exit" / orphaned-idle session bug
|
|
254
|
+
* when a wrapped agent exits with nobody attached (a closed terminal, or a
|
|
255
|
+
* `/exit` after the user detached). `detach-client` on an unattached session
|
|
256
|
+
* is a no-op, so the old hook left the husk; `kill-session` ends it at once.
|
|
257
|
+
* A user split's death still runs the else-branch, closing just that split via
|
|
258
|
+
* `run-shell -b -C` with an explicit `-t #{hook_pane}` target: tmux format-expands
|
|
259
|
+
* the command at fire time and executes it inside the server command queue in the
|
|
260
|
+
* background, so the event pane is always the one killed without launching a second
|
|
261
|
+
* tmux client against the same socket and without stalling the hook on a loaded
|
|
262
|
+
* runner. Single source of truth: both the spawn-wrap (exec.ts) and the daemon
|
|
263
|
+
* reconcile build the hook here, so the two can never drift.
|
|
231
264
|
*/
|
|
232
265
|
export declare function agentPaneDiedHook(sessionName: string, agentPane: string): string;
|
|
233
266
|
/** Stamp a session's hook-schema marker to the current version. */
|
package/dist/lib/tmux/session.js
CHANGED
|
@@ -10,10 +10,94 @@
|
|
|
10
10
|
* `tmux list-sessions` and prunes stale entries on the fly.
|
|
11
11
|
*/
|
|
12
12
|
import * as fs from 'fs';
|
|
13
|
+
import * as os from 'os';
|
|
14
|
+
import * as path from 'path';
|
|
13
15
|
import { runTmux, TmuxCommandError } from './binary.js';
|
|
14
16
|
import { ensureTmuxDir, getDefaultSocketPath, getSessionMetaPath } from './paths.js';
|
|
15
17
|
/** Tmux session names must not contain `.` or `:` — those are reserved for window/pane addressing. */
|
|
16
18
|
const VALID_NAME = /^[A-Za-z0-9_-]{1,64}$/;
|
|
19
|
+
/**
|
|
20
|
+
* Ten times tmux's 2,000-line default, while keeping per-pane memory bounded:
|
|
21
|
+
* history storage scales with pane width, so a 200-column pane retains at most
|
|
22
|
+
* four million grid cells rather than an unbounded agent transcript.
|
|
23
|
+
*/
|
|
24
|
+
export const AGENTS_TMUX_HISTORY_LIMIT = 20_000;
|
|
25
|
+
/**
|
|
26
|
+
* Bump when the generated startup config changes, so an ALREADY-RUNNING server
|
|
27
|
+
* picks the new settings up. tmux reads `-f` only when it actually starts a
|
|
28
|
+
* server: a second `new-session -f other.conf` against a live server exits 0
|
|
29
|
+
* and silently applies nothing (verified against tmux 3.4). Since agents-cli
|
|
30
|
+
* uses one long-lived shared socket, the normal state at upgrade is a running
|
|
31
|
+
* server — so without this reconcile every existing machine would get none of
|
|
32
|
+
* these defaults, silently. Same shape as AGENT_HOOK_SCHEMA below.
|
|
33
|
+
*/
|
|
34
|
+
export const AGENTS_TMUX_CONFIG_SCHEMA = 1;
|
|
35
|
+
/** Server-scoped user-option recording which AGENTS_TMUX_CONFIG_SCHEMA is applied. */
|
|
36
|
+
const CONFIG_SCHEMA_OPTION = '@ag_tmux_config_schema';
|
|
37
|
+
let startupConfigSequence = 0;
|
|
38
|
+
function tmuxConfigArgument(value) {
|
|
39
|
+
return `"${value.replace(/([\\"$])/g, '\\$1')}"`;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* tmux loads `-f` instead of its normal user config, so put agents-cli's
|
|
43
|
+
* defaults first and explicitly source the first user config tmux would have
|
|
44
|
+
* selected afterward. Configuration files execute in order; this makes these
|
|
45
|
+
* settings defaults while preserving every option or binding the user sets.
|
|
46
|
+
*/
|
|
47
|
+
function writeStartupConfig(env) {
|
|
48
|
+
const effectiveEnv = env ?? process.env;
|
|
49
|
+
const home = effectiveEnv.HOME ?? os.homedir();
|
|
50
|
+
const candidates = [
|
|
51
|
+
path.join(home, '.tmux.conf'),
|
|
52
|
+
...(effectiveEnv.XDG_CONFIG_HOME
|
|
53
|
+
? [path.join(effectiveEnv.XDG_CONFIG_HOME, 'tmux', 'tmux.conf')]
|
|
54
|
+
: []),
|
|
55
|
+
path.join(home, '.config', 'tmux', 'tmux.conf'),
|
|
56
|
+
];
|
|
57
|
+
// tmux's own start_cfg() sources EVERY entry of TMUX_CONF that exists, not
|
|
58
|
+
// just the first — a user with both ~/.tmux.conf and ~/.config/tmux/tmux.conf
|
|
59
|
+
// gets both. Sourcing only the first would silently drop the second and break
|
|
60
|
+
// the "never override a user-set value" contract this file exists to keep.
|
|
61
|
+
const userConfigs = candidates.filter((candidate) => fs.existsSync(candidate));
|
|
62
|
+
const startupConfig = path.join(ensureTmuxDir(), `startup-${process.pid}-${startupConfigSequence++}.conf`);
|
|
63
|
+
const lines = [
|
|
64
|
+
// Stamp the schema HERE as well as in reconcileServerConfig. Without it a
|
|
65
|
+
// cold start leaves the option unset, the post-new-session check sees a
|
|
66
|
+
// stale stamp, and the reconcile re-sources the user's config a second time
|
|
67
|
+
// on launch #1 — firing any run-shell side effect (TPM) twice.
|
|
68
|
+
`set-option -g ${CONFIG_SCHEMA_OPTION} ${AGENTS_TMUX_CONFIG_SCHEMA}`,
|
|
69
|
+
'set-option -g mouse on',
|
|
70
|
+
'set-option -s set-clipboard on',
|
|
71
|
+
`set-option -g history-limit ${AGENTS_TMUX_HISTORY_LIMIT}`,
|
|
72
|
+
'bind-key -T copy-mode MouseDragEnd1Pane send-keys -X copy-selection-no-clear',
|
|
73
|
+
'bind-key -T copy-mode-vi MouseDragEnd1Pane send-keys -X copy-selection-no-clear',
|
|
74
|
+
];
|
|
75
|
+
for (const userConfig of userConfigs) {
|
|
76
|
+
lines.push(`source-file -q ${tmuxConfigArgument(userConfig)}`);
|
|
77
|
+
}
|
|
78
|
+
fs.writeFileSync(startupConfig, `${lines.join('\n')}\n`, { mode: 0o600 });
|
|
79
|
+
return startupConfig;
|
|
80
|
+
}
|
|
81
|
+
/** Build the atomic server-configuration + session-creation command. */
|
|
82
|
+
export function buildCreateSessionArgs(opts, startupConfig) {
|
|
83
|
+
const args = [
|
|
84
|
+
'-f', startupConfig,
|
|
85
|
+
'set-option', '-g', 'remain-on-exit', 'on', ';',
|
|
86
|
+
'new-session', '-d', '-s', opts.name, '-P', '-F', '#{pane_id}',
|
|
87
|
+
];
|
|
88
|
+
if (opts.width)
|
|
89
|
+
args.push('-x', String(opts.width));
|
|
90
|
+
if (opts.height)
|
|
91
|
+
args.push('-y', String(opts.height));
|
|
92
|
+
if (opts.cwd)
|
|
93
|
+
args.push('-c', opts.cwd);
|
|
94
|
+
// Separator + child command. tmux passes the rest verbatim to exec, so no
|
|
95
|
+
// shell escaping is required — array args end-to-end.
|
|
96
|
+
if (opts.cmd) {
|
|
97
|
+
args.push('--', 'sh', '-c', opts.cmd);
|
|
98
|
+
}
|
|
99
|
+
return args;
|
|
100
|
+
}
|
|
17
101
|
export class TmuxSessionError extends Error {
|
|
18
102
|
cause;
|
|
19
103
|
constructor(message, cause) {
|
|
@@ -44,6 +128,41 @@ export async function hasSession(name, socket) {
|
|
|
44
128
|
});
|
|
45
129
|
return res.code === 0;
|
|
46
130
|
}
|
|
131
|
+
/** Which AGENTS_TMUX_CONFIG_SCHEMA a live server has applied; undefined when none. */
|
|
132
|
+
async function appliedConfigSchema(socket) {
|
|
133
|
+
const res = await runTmux({
|
|
134
|
+
socket,
|
|
135
|
+
args: ['show-options', '-gv', CONFIG_SCHEMA_OPTION],
|
|
136
|
+
throwOnError: false,
|
|
137
|
+
}).catch(() => null);
|
|
138
|
+
if (!res || res.code !== 0)
|
|
139
|
+
return undefined;
|
|
140
|
+
const n = Number(res.stdout.trim());
|
|
141
|
+
return Number.isFinite(n) ? n : undefined;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Apply the generated startup config to an already-running server and stamp it.
|
|
145
|
+
* Failure is surfaced, not swallowed: a server left without these settings is
|
|
146
|
+
* the silent no-op this exists to prevent.
|
|
147
|
+
*/
|
|
148
|
+
async function reconcileServerConfig(socket, env) {
|
|
149
|
+
const conf = writeStartupConfig(env);
|
|
150
|
+
try {
|
|
151
|
+
const res = await runTmux({ socket, args: ['source-file', conf], throwOnError: false, env });
|
|
152
|
+
if (res.code !== 0) {
|
|
153
|
+
throw new TmuxSessionError(`could not apply agents-cli tmux settings to the running server (${res.stderr.trim() || `exit ${res.code}`})`);
|
|
154
|
+
}
|
|
155
|
+
await runTmux({
|
|
156
|
+
socket,
|
|
157
|
+
args: ['set-option', '-g', CONFIG_SCHEMA_OPTION, String(AGENTS_TMUX_CONFIG_SCHEMA)],
|
|
158
|
+
throwOnError: false,
|
|
159
|
+
env,
|
|
160
|
+
}).catch(() => { });
|
|
161
|
+
}
|
|
162
|
+
finally {
|
|
163
|
+
fs.rmSync(conf, { force: true });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
47
166
|
/**
|
|
48
167
|
* Create a new detached session. Throws when the name is already taken unless
|
|
49
168
|
* `replace` or `attachExisting` is set.
|
|
@@ -58,6 +177,13 @@ export async function createSession(opts) {
|
|
|
58
177
|
const existed = await hasSession(opts.name, socket);
|
|
59
178
|
if (existed) {
|
|
60
179
|
if (opts.attachExisting) {
|
|
180
|
+
// Reuse of a live session is precisely the already-running-server case,
|
|
181
|
+
// so it needs the same reconcile the create path gets — otherwise
|
|
182
|
+
// `agents tmux new --attach-existing` silently keeps a pre-fix server
|
|
183
|
+
// with none of these settings.
|
|
184
|
+
if ((await appliedConfigSchema(socket)) !== AGENTS_TMUX_CONFIG_SCHEMA) {
|
|
185
|
+
await reconcileServerConfig(socket, opts.env);
|
|
186
|
+
}
|
|
61
187
|
const meta = readSessionMeta(opts.name);
|
|
62
188
|
return meta ?? {
|
|
63
189
|
name: opts.name,
|
|
@@ -71,28 +197,32 @@ export async function createSession(opts) {
|
|
|
71
197
|
}
|
|
72
198
|
await killSession(opts.name, socket);
|
|
73
199
|
}
|
|
74
|
-
//
|
|
200
|
+
// Configure the agents-cli tmux server BEFORE the child command can finish — a fast-exiting
|
|
75
201
|
// cmd (e.g. `echo BRIEF && true`) would otherwise collapse the only session,
|
|
76
202
|
// exit the server, and the follow-up `set-option` would race with "no
|
|
77
203
|
// server running". Server-wide (`-g`) is applied in the same tmux
|
|
78
204
|
// invocation as new-session so they share one server lifetime.
|
|
79
205
|
// `-P -F '#{pane_id}'` prints the new session's first pane id on stdout so we
|
|
80
206
|
// can record the exact `%N` handle without a follow-up `list-panes`.
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
// shell escaping is required — array args end-to-end.
|
|
90
|
-
if (opts.cmd) {
|
|
91
|
-
args.push('--', 'sh', '-c', opts.cmd);
|
|
207
|
+
const startupConfig = writeStartupConfig(opts.env);
|
|
208
|
+
const args = buildCreateSessionArgs(opts, startupConfig);
|
|
209
|
+
let res;
|
|
210
|
+
try {
|
|
211
|
+
res = await runTmux({ socket, args, env: opts.env });
|
|
212
|
+
}
|
|
213
|
+
finally {
|
|
214
|
+
fs.rmSync(startupConfig, { force: true });
|
|
92
215
|
}
|
|
93
|
-
const res = await runTmux({ socket, args, env: opts.env });
|
|
94
216
|
// Only the new-session command in the `;`-chained invocation emits output.
|
|
95
217
|
const pane = /^%\d+$/.test(res.stdout.trim()) ? res.stdout.trim() : undefined;
|
|
218
|
+
// If the server was ALREADY running, its `-f` was ignored, so the config above
|
|
219
|
+
// never applied. Re-source it — the generated file ends by sourcing the user's
|
|
220
|
+
// own tmux.conf, so re-applying preserves the same precedence a cold start
|
|
221
|
+
// gives. Gated on a generation stamp so a user config with side effects (tpm's
|
|
222
|
+
// run-shell, for one) is not re-executed on every single launch.
|
|
223
|
+
if (existed || (await appliedConfigSchema(socket)) !== AGENTS_TMUX_CONFIG_SCHEMA) {
|
|
224
|
+
await reconcileServerConfig(socket, opts.env);
|
|
225
|
+
}
|
|
96
226
|
// Keep the agent pane around after its process exits (so runInTmux can read
|
|
97
227
|
// the exit status and capture the final error), but do NOT keep that behavior
|
|
98
228
|
// for user-created splits. Apply remain-on-exit to the agent pane only, then
|
|
@@ -402,8 +532,17 @@ export async function setSessionHook(name, hook, command, socket, timeoutMs) {
|
|
|
402
532
|
* variant could stall the hook on a loaded CI runner, letting the dead
|
|
403
533
|
* split survive until the test's wait timeout expired (flake in CI shard
|
|
404
534
|
* 3: pane-guarded pane-died hook / user split exit).
|
|
535
|
+
* v6 — the agent-pane branch is now `session_attached`-aware: with a client
|
|
536
|
+
* attached it `detach-client`s (the interactive path, so runInTmux's
|
|
537
|
+
* attach returns and reads the exit status, EXEC-23b unchanged); with NO
|
|
538
|
+
* client it `kill-session`s outright. A `remain-on-exit` pane with no
|
|
539
|
+
* attach client to reap it otherwise lingers as a dead husk until the
|
|
540
|
+
* daemon's periodic `reapDeadTmuxPanes` sweep — so a run whose terminal
|
|
541
|
+
* was closed, or a `/exit` after the user detached, left an idle-looking
|
|
542
|
+
* dead session on the socket. Killing it the instant the agent exits is
|
|
543
|
+
* the "one close tears down every layer" contract (#5a).
|
|
405
544
|
*/
|
|
406
|
-
export const AGENT_HOOK_SCHEMA =
|
|
545
|
+
export const AGENT_HOOK_SCHEMA = 6;
|
|
407
546
|
/** Per-session tmux user-option that records which AGENT_HOOK_SCHEMA a session's hook is at. */
|
|
408
547
|
const HOOK_SCHEMA_OPTION = '@ag_hook_schema';
|
|
409
548
|
/**
|
|
@@ -416,21 +555,28 @@ const HOOK_SCHEMA_OPTION = '@ag_hook_schema';
|
|
|
416
555
|
*/
|
|
417
556
|
const TMUX_HOOK_REPAIR_TIMEOUT_MS = 5_000;
|
|
418
557
|
/**
|
|
419
|
-
* The guarded `pane-died` hook.
|
|
420
|
-
*
|
|
421
|
-
*
|
|
422
|
-
*
|
|
423
|
-
*
|
|
424
|
-
*
|
|
425
|
-
*
|
|
426
|
-
*
|
|
427
|
-
*
|
|
428
|
-
*
|
|
429
|
-
*
|
|
430
|
-
*
|
|
558
|
+
* The guarded `pane-died` hook. When the AGENT pane dies, tear the whole session
|
|
559
|
+
* down — but HOW depends on whether a client is attached:
|
|
560
|
+
* - attached (`#{session_attached}`): `detach-client` so the blocking attach in
|
|
561
|
+
* runInTmux returns and `resolveAfterAttach` reads the exit status off the
|
|
562
|
+
* still-`remain-on-exit` dead pane, then kills the session (EXEC-23b, unchanged);
|
|
563
|
+
* - no client: `kill-session` outright. Without this the dead pane sits under
|
|
564
|
+
* `remain-on-exit` as an idle-looking husk until the daemon's periodic
|
|
565
|
+
* `reapDeadTmuxPanes` sweep — the "second exit" / orphaned-idle session bug
|
|
566
|
+
* when a wrapped agent exits with nobody attached (a closed terminal, or a
|
|
567
|
+
* `/exit` after the user detached). `detach-client` on an unattached session
|
|
568
|
+
* is a no-op, so the old hook left the husk; `kill-session` ends it at once.
|
|
569
|
+
* A user split's death still runs the else-branch, closing just that split via
|
|
570
|
+
* `run-shell -b -C` with an explicit `-t #{hook_pane}` target: tmux format-expands
|
|
571
|
+
* the command at fire time and executes it inside the server command queue in the
|
|
572
|
+
* background, so the event pane is always the one killed without launching a second
|
|
573
|
+
* tmux client against the same socket and without stalling the hook on a loaded
|
|
574
|
+
* runner. Single source of truth: both the spawn-wrap (exec.ts) and the daemon
|
|
575
|
+
* reconcile build the hook here, so the two can never drift.
|
|
431
576
|
*/
|
|
432
577
|
export function agentPaneDiedHook(sessionName, agentPane) {
|
|
433
|
-
|
|
578
|
+
const agentPaneAction = `if -F '#{session_attached}' 'detach-client -s =${sessionName}' 'kill-session -t =${sessionName}'`;
|
|
579
|
+
return `if -F '#{==:#{hook_pane},${agentPane}}' "${agentPaneAction}" 'run-shell -b -C "kill-pane -t #{hook_pane}"'`;
|
|
434
580
|
}
|
|
435
581
|
/** Stamp a session's hook-schema marker to the current version. */
|
|
436
582
|
export async function markSessionHookSchema(name, socket, timeoutMs) {
|
package/dist/lib/types.d.ts
CHANGED
|
@@ -277,6 +277,22 @@ export interface HookMatches {
|
|
|
277
277
|
git_dirty?: boolean;
|
|
278
278
|
cwd_includes?: string | string[];
|
|
279
279
|
project_has?: string;
|
|
280
|
+
/**
|
|
281
|
+
* Permission modes the hook fires in (e.g. `plan`). Unlike the other
|
|
282
|
+
* predicates this one is fail-open on absence: an input that carries no
|
|
283
|
+
* permission_mode/permissionMode field passes, because only some harnesses
|
|
284
|
+
* (Claude Code) report the live mode — an explicit non-listed value skips.
|
|
285
|
+
*/
|
|
286
|
+
permission_mode?: string | string[];
|
|
287
|
+
/**
|
|
288
|
+
* Permission modes the hook must NOT fire in (e.g. `plan`). The inverse of
|
|
289
|
+
* `permission_mode`, and the correct predicate for gating a guard off in one
|
|
290
|
+
* mode: expressing that with the allowlist means enumerating every other
|
|
291
|
+
* mode, which silently stops firing when a harness adds or renames one. Same
|
|
292
|
+
* fail-open-on-absence rule — an input with no mode field still fires — so an
|
|
293
|
+
* unknown mode errs toward running the hook, never toward skipping it.
|
|
294
|
+
*/
|
|
295
|
+
permission_mode_not?: string | string[];
|
|
280
296
|
}
|
|
281
297
|
/**
|
|
282
298
|
* Cache scoping. Determines which cache file a hook invocation reads/writes:
|
|
@@ -15,6 +15,7 @@ export declare function parseRetryAfterMs(header: string | null | undefined, now
|
|
|
15
15
|
export declare function noteUsageRateLimited(agent: AgentId, retryAfter: string | null | undefined, opts?: {
|
|
16
16
|
now?: number;
|
|
17
17
|
fallbackMs?: number;
|
|
18
|
+
account?: string | null;
|
|
18
19
|
}): void;
|
|
19
20
|
/**
|
|
20
21
|
* Epoch ms until which `agent`'s usage endpoint should not be called, or null
|
|
@@ -24,6 +25,6 @@ export declare function noteUsageRateLimited(agent: AgentId, retryAfter: string
|
|
|
24
25
|
* Sweeps elapsed files while it is here: they can only accumulate at the rate
|
|
25
26
|
* penalties are issued, and this is the one place that already lists them.
|
|
26
27
|
*/
|
|
27
|
-
export declare function usageRateLimitedUntil(agent: AgentId, now?: number): number | null;
|
|
28
|
+
export declare function usageRateLimitedUntil(agent: AgentId, now?: number, account?: string | null): number | null;
|
|
28
29
|
/** Human-readable remaining backoff, for the error a skipped read returns. */
|
|
29
30
|
export declare function formatBackoffRemaining(untilMs: number, now?: number): string;
|
|
@@ -39,9 +39,15 @@
|
|
|
39
39
|
* one — monotonicity is structural rather than argued, and there is no lock to
|
|
40
40
|
* go stale on a path every usage read touches. Elapsed files are swept on read.
|
|
41
41
|
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
* account
|
|
42
|
+
* Scoped per ACCOUNT when the caller knows one, per provider otherwise
|
|
43
|
+
* (RUSH-3036). The first cut was deliberately provider-wide — the 2026-08-03
|
|
44
|
+
* incident 429'd every account on the box at once, so per-provider looked
|
|
45
|
+
* sufficient. It was not: the quotas proved per-account, and a provider-wide
|
|
46
|
+
* park let the first throttled account in a refresh pass starve every account
|
|
47
|
+
* after it in the loop's fixed iteration order — the same four accounts read
|
|
48
|
+
* 'usage unavailable' across passes while their siblings refreshed. An
|
|
49
|
+
* account-scoped penalty parks only that account; a penalty recorded with no
|
|
50
|
+
* account identity stays provider-wide and still parks everything.
|
|
45
51
|
*/
|
|
46
52
|
import * as fs from 'fs';
|
|
47
53
|
import * as path from 'path';
|
|
@@ -84,8 +90,26 @@ export function parseRetryAfterMs(header, now = Date.now()) {
|
|
|
84
90
|
const ms = at - now;
|
|
85
91
|
return ms > 0 ? Math.min(ms, MAX_BACKOFF_MS) : null;
|
|
86
92
|
}
|
|
87
|
-
/**
|
|
88
|
-
|
|
93
|
+
/**
|
|
94
|
+
* File-name scope for a penalty. Provider-wide penalties are `<agent>`;
|
|
95
|
+
* account-scoped ones are `<agent>@<slug>` (RUSH-3036), where the slug is the
|
|
96
|
+
* account's usage key with path separators neutralized (usage keys carry `:`,
|
|
97
|
+
* `=`, `.` and `@` freely — all filename-safe on the platforms we run on).
|
|
98
|
+
* `@` cannot collide with the provider scope because provider files are always
|
|
99
|
+
* `<agent>.<digits>` and agent ids never contain `@`.
|
|
100
|
+
*/
|
|
101
|
+
function backoffScope(agent, account) {
|
|
102
|
+
if (!account)
|
|
103
|
+
return agent;
|
|
104
|
+
return `${agent}@${account.replace(/[/\\]/g, '_')}`;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Every recorded deadline for `scope`. A file belongs to the scope only when
|
|
108
|
+
* everything after `<scope>.` is pure digits — account slugs contain dots
|
|
109
|
+
* (emails inside usage keys), so a bare prefix match would let scope
|
|
110
|
+
* `claude@a` swallow `claude@a.b`'s files.
|
|
111
|
+
*/
|
|
112
|
+
function deadlinesFor(scope) {
|
|
89
113
|
let names;
|
|
90
114
|
try {
|
|
91
115
|
names = fs.readdirSync(backoffDir());
|
|
@@ -94,12 +118,15 @@ function deadlinesFor(agent) {
|
|
|
94
118
|
// No directory yet: nothing is throttled.
|
|
95
119
|
return [];
|
|
96
120
|
}
|
|
97
|
-
const prefix = `${
|
|
121
|
+
const prefix = `${scope}.`;
|
|
98
122
|
const out = [];
|
|
99
123
|
for (const name of names) {
|
|
100
124
|
if (!name.startsWith(prefix))
|
|
101
125
|
continue;
|
|
102
|
-
const
|
|
126
|
+
const rest = name.slice(prefix.length);
|
|
127
|
+
if (!/^\d+$/.test(rest))
|
|
128
|
+
continue;
|
|
129
|
+
const at = Number(rest);
|
|
103
130
|
if (Number.isFinite(at))
|
|
104
131
|
out.push(at);
|
|
105
132
|
}
|
|
@@ -120,7 +147,14 @@ export function noteUsageRateLimited(agent, retryAfter, opts) {
|
|
|
120
147
|
fs.mkdirSync(backoffDir(), { recursive: true });
|
|
121
148
|
// Empty file: the name carries the whole value, so there is no content a
|
|
122
149
|
// concurrent reader could catch half-written, and no document to merge.
|
|
123
|
-
|
|
150
|
+
//
|
|
151
|
+
// With an account, the penalty is scoped to THAT account (RUSH-3036): the
|
|
152
|
+
// observed 429s are per-account quotas, and a provider-wide park let the
|
|
153
|
+
// first throttled account starve every account after it in the refresh
|
|
154
|
+
// loop's fixed order — the same 4 accounts stayed 'usage unavailable'
|
|
155
|
+
// across passes while their siblings refreshed. Callers with no account
|
|
156
|
+
// identity still record provider-wide, which continues to park everything.
|
|
157
|
+
fs.writeFileSync(path.join(backoffDir(), `${backoffScope(agent, opts?.account)}.${deadline}`), '');
|
|
124
158
|
}
|
|
125
159
|
catch {
|
|
126
160
|
// Best-effort. An unwritable cache dir costs the cross-process backoff, not
|
|
@@ -135,19 +169,26 @@ export function noteUsageRateLimited(agent, retryAfter, opts) {
|
|
|
135
169
|
* Sweeps elapsed files while it is here: they can only accumulate at the rate
|
|
136
170
|
* penalties are issued, and this is the one place that already lists them.
|
|
137
171
|
*/
|
|
138
|
-
export function usageRateLimitedUntil(agent, now = Date.now()) {
|
|
172
|
+
export function usageRateLimitedUntil(agent, now = Date.now(), account) {
|
|
173
|
+
// A provider-wide penalty parks every account; an account-scoped read also
|
|
174
|
+
// honors that account's own penalties. A bare (no-account) read deliberately
|
|
175
|
+
// ignores account-scoped penalties — one pinned account must not park its
|
|
176
|
+
// siblings (RUSH-3036).
|
|
177
|
+
const scopes = account ? [backoffScope(agent, null), backoffScope(agent, account)] : [backoffScope(agent, null)];
|
|
139
178
|
let latest = null;
|
|
140
|
-
for (const
|
|
141
|
-
|
|
142
|
-
if (
|
|
143
|
-
latest
|
|
144
|
-
|
|
145
|
-
else {
|
|
146
|
-
try {
|
|
147
|
-
fs.rmSync(path.join(backoffDir(), `${agent}.${at}`), { force: true });
|
|
179
|
+
for (const scope of scopes) {
|
|
180
|
+
for (const at of deadlinesFor(scope)) {
|
|
181
|
+
if (at > now) {
|
|
182
|
+
if (latest === null || at > latest)
|
|
183
|
+
latest = at;
|
|
148
184
|
}
|
|
149
|
-
|
|
150
|
-
|
|
185
|
+
else {
|
|
186
|
+
try {
|
|
187
|
+
fs.rmSync(path.join(backoffDir(), `${scope}.${at}`), { force: true });
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
/* another process may have swept it already */
|
|
191
|
+
}
|
|
151
192
|
}
|
|
152
193
|
}
|
|
153
194
|
}
|
|
@@ -18,6 +18,10 @@ export declare const REFRESH_BURN_DIVISOR = 4;
|
|
|
18
18
|
export declare const HOURLY_CALL_CAP = 12;
|
|
19
19
|
/** How often the daemon wakes to *consider* a refresh pass (due accounts only). */
|
|
20
20
|
export declare const USAGE_REFRESH_TICK_MS: number;
|
|
21
|
+
/** Consecutive failed live reads before one broken account is quarantined. */
|
|
22
|
+
export declare const FAILURE_QUARANTINE_THRESHOLD = 3;
|
|
23
|
+
/** A chronic offender waits this long while healthy siblings keep their cadence. */
|
|
24
|
+
export declare const FAILURE_QUARANTINE_MS: number;
|
|
21
25
|
/**
|
|
22
26
|
* One account's refresh state + published headroom. `sessionUsedPercent` /
|
|
23
27
|
* `capturedAt` are the prior sample the NEXT tick projects the burn rate from;
|
|
@@ -36,6 +40,8 @@ export interface HeadroomEntry {
|
|
|
36
40
|
callTimestamps: number[];
|
|
37
41
|
/** Epoch ms this entry was written. */
|
|
38
42
|
computedAt: number;
|
|
43
|
+
/** Consecutive live-fetch misses; absent on entries written before this field. */
|
|
44
|
+
consecutiveFailures?: number;
|
|
39
45
|
}
|
|
40
46
|
export declare function setHeadroomCachePathForTest(cachePath: string | null): string | null;
|
|
41
47
|
/** Read the whole headroom cache (best-effort; missing/corrupt → empty map). */
|
|
@@ -79,6 +85,8 @@ export interface LocalUsageAccount {
|
|
|
79
85
|
/** Live-fetch this account's usage; the daemon passes the real network fetch. */
|
|
80
86
|
fetch: () => Promise<UsageInfo>;
|
|
81
87
|
}
|
|
88
|
+
/** Cold accounts lead each pass; both cold and cached groups rotate every tick. */
|
|
89
|
+
export declare function orderUsageAccounts(accounts: LocalUsageAccount[], cache: Record<string, HeadroomEntry>, tick: number): LocalUsageAccount[];
|
|
82
90
|
/**
|
|
83
91
|
* Enumerate the usage accounts whose credentials live on THIS host — one
|
|
84
92
|
* per unique usage key, deduped to the most-recently-active version (the same
|
|
@@ -95,8 +103,14 @@ export interface UsageRefreshDeps {
|
|
|
95
103
|
listAccounts: () => Promise<LocalUsageAccount[]>;
|
|
96
104
|
/** Persist a fresh snapshot to the usage cache (writeClaudeUsageCache). */
|
|
97
105
|
writeUsageCache: (usageKey: string, snapshot: UsageSnapshot) => void;
|
|
98
|
-
/**
|
|
99
|
-
|
|
106
|
+
/**
|
|
107
|
+
* Epoch ms this provider — or, when `usageKey` is given, this specific
|
|
108
|
+
* account — is backed off until; null when free (usageRateLimitedUntil).
|
|
109
|
+
* Per-account scope (RUSH-3036): one throttled account must not park its
|
|
110
|
+
* siblings, which previously starved every account after the first 429 in
|
|
111
|
+
* this loop's fixed iteration order.
|
|
112
|
+
*/
|
|
113
|
+
backoffUntil: (agentId: AgentId, usageKey?: string) => number | null;
|
|
100
114
|
}
|
|
101
115
|
export interface UsageRefreshResult {
|
|
102
116
|
refreshed: number;
|
|
@@ -107,7 +121,7 @@ export interface UsageRefreshResult {
|
|
|
107
121
|
}
|
|
108
122
|
/**
|
|
109
123
|
* One refresher tick: for each local account that is due, under its hourly cap,
|
|
110
|
-
* and not
|
|
124
|
+
* and not backed off, live-fetch its usage, update the cache, and
|
|
111
125
|
* reschedule from the new burn projection. Never throws — a single account's
|
|
112
126
|
* failed fetch leaves its cache untouched and counts as `failed`.
|
|
113
127
|
*/
|