@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.
Files changed (177) hide show
  1. package/CHANGELOG.md +303 -0
  2. package/LICENSE +102 -182
  3. package/README.md +54 -17
  4. package/dist/cli/command-registry.d.ts +2 -1
  5. package/dist/cli/command-registry.js +6 -2
  6. package/dist/commands/accounts.js +56 -12
  7. package/dist/commands/auth.d.ts +2 -0
  8. package/dist/commands/auth.js +229 -0
  9. package/dist/commands/browser-sessions-picker.js +9 -7
  10. package/dist/commands/browser.js +266 -15
  11. package/dist/commands/config.js +26 -4
  12. package/dist/commands/cost.d.ts +1 -1
  13. package/dist/commands/detach.d.ts +13 -0
  14. package/dist/commands/detach.js +11 -8
  15. package/dist/commands/exec.js +55 -38
  16. package/dist/commands/feed-watch.d.ts +2 -0
  17. package/dist/commands/feed-watch.js +28 -0
  18. package/dist/commands/feed.js +27 -0
  19. package/dist/commands/feedback.js +3 -19
  20. package/dist/commands/insights.d.ts +1 -1
  21. package/dist/commands/insights.js +2 -2
  22. package/dist/commands/lease.js +3 -3
  23. package/dist/commands/routines.js +1 -1
  24. package/dist/commands/sessions-picker-factory.d.ts +2 -1
  25. package/dist/commands/sessions-picker-factory.js +1 -1
  26. package/dist/commands/sessions-stop.d.ts +24 -0
  27. package/dist/commands/sessions-stop.js +66 -0
  28. package/dist/commands/sessions-trace.d.ts +82 -0
  29. package/dist/commands/sessions-trace.js +387 -0
  30. package/dist/commands/sessions.js +4 -0
  31. package/dist/commands/ssh.d.ts +15 -0
  32. package/dist/commands/ssh.js +194 -16
  33. package/dist/commands/teams.js +34 -2
  34. package/dist/commands/view.d.ts +2 -0
  35. package/dist/commands/view.js +19 -32
  36. package/dist/commands/watchdog.js +9 -14
  37. package/dist/lib/account-capabilities.d.ts +4 -0
  38. package/dist/lib/account-capabilities.js +13 -9
  39. package/dist/lib/account-registry.d.ts +2 -0
  40. package/dist/lib/account-registry.js +30 -2
  41. package/dist/lib/accounting/rotate.d.ts +9 -0
  42. package/dist/lib/accounting/rotate.js +12 -2
  43. package/dist/lib/accounting/usage.d.ts +92 -6
  44. package/dist/lib/accounting/usage.js +329 -75
  45. package/dist/lib/agent-modes.js +29 -1
  46. package/dist/lib/agent-spec/agents.d.ts +4 -3
  47. package/dist/lib/agent-spec/agents.js +20 -7
  48. package/dist/lib/analytics/mix-commands.d.ts +1 -1
  49. package/dist/lib/analytics/mix-commands.js +2 -2
  50. package/dist/lib/auth-health.d.ts +13 -3
  51. package/dist/lib/auth-health.js +50 -8
  52. package/dist/lib/browser/devices.d.ts +19 -0
  53. package/dist/lib/browser/devices.js +23 -0
  54. package/dist/lib/browser/ipc.d.ts +1 -1
  55. package/dist/lib/browser/ipc.js +33 -7
  56. package/dist/lib/browser/profiles.d.ts +106 -1
  57. package/dist/lib/browser/profiles.js +300 -17
  58. package/dist/lib/browser/remote-control.d.ts +26 -2
  59. package/dist/lib/browser/remote-control.js +33 -3
  60. package/dist/lib/browser/runtime-state.d.ts +9 -0
  61. package/dist/lib/browser/runtime-state.js +24 -8
  62. package/dist/lib/browser/service.d.ts +37 -0
  63. package/dist/lib/browser/service.js +78 -20
  64. package/dist/lib/browser/sessions-list.d.ts +1 -3
  65. package/dist/lib/browser/sessions-list.js +3 -16
  66. package/dist/lib/browser/types.d.ts +12 -1
  67. package/dist/lib/cli-resources.js +17 -15
  68. package/dist/lib/codex-policy.d.ts +11 -1
  69. package/dist/lib/codex-policy.js +20 -5
  70. package/dist/lib/config-keys.d.ts +4 -3
  71. package/dist/lib/config-keys.js +17 -7
  72. package/dist/lib/daemon/runner.js +15 -2
  73. package/dist/lib/daemon-ticks.js +2 -2
  74. package/dist/lib/device-config.d.ts +25 -8
  75. package/dist/lib/device-config.js +90 -17
  76. package/dist/lib/devices/config-migration.d.ts +7 -0
  77. package/dist/lib/devices/config-migration.js +67 -3
  78. package/dist/lib/devices/harness-inventory.js +20 -3
  79. package/dist/lib/devices/health.d.ts +27 -2
  80. package/dist/lib/devices/health.js +39 -9
  81. package/dist/lib/devices/interactive-host.d.ts +15 -0
  82. package/dist/lib/devices/interactive-host.js +53 -0
  83. package/dist/lib/devices/registry.d.ts +61 -4
  84. package/dist/lib/devices/registry.js +94 -38
  85. package/dist/lib/devices/stats-cache.d.ts +6 -0
  86. package/dist/lib/devices/stats-cache.js +10 -2
  87. package/dist/lib/exec.d.ts +25 -3
  88. package/dist/lib/exec.js +75 -22
  89. package/dist/lib/feed/answer.d.ts +29 -0
  90. package/dist/lib/feed/answer.js +145 -0
  91. package/dist/lib/feed/attention.d.ts +83 -0
  92. package/dist/lib/feed/attention.js +0 -0
  93. package/dist/lib/feed/feed.d.ts +109 -0
  94. package/dist/lib/feed/feed.js +154 -3
  95. package/dist/lib/feed/pr-status.d.ts +14 -0
  96. package/dist/lib/feed/pr-status.js +47 -0
  97. package/dist/lib/feed/watch.d.ts +59 -0
  98. package/dist/lib/feed/watch.js +180 -0
  99. package/dist/lib/fleet/capture.js +6 -0
  100. package/dist/lib/fleet/remote-login.js +8 -3
  101. package/dist/lib/fleet/types.d.ts +17 -0
  102. package/dist/lib/harness/adapter.d.ts +11 -0
  103. package/dist/lib/harness/adapters/claude.js +38 -24
  104. package/dist/lib/harness/adapters/codex.js +2 -4
  105. package/dist/lib/hooks/cache.js +8 -0
  106. package/dist/lib/hooks/match.d.ts +3 -0
  107. package/dist/lib/hooks/match.js +25 -0
  108. package/dist/lib/hosts/passthrough.js +16 -0
  109. package/dist/lib/hosts/registry.js +7 -0
  110. package/dist/lib/identity/client.d.ts +53 -0
  111. package/dist/lib/identity/client.js +106 -0
  112. package/dist/lib/identity/index.d.ts +115 -0
  113. package/dist/lib/identity/index.js +82 -0
  114. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  115. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  116. package/dist/lib/menubar/install-menubar.d.ts +13 -0
  117. package/dist/lib/menubar/install-menubar.js +26 -2
  118. package/dist/lib/open-url.d.ts +60 -2
  119. package/dist/lib/open-url.js +167 -12
  120. package/dist/lib/probe.d.ts +8 -0
  121. package/dist/lib/probe.js +105 -0
  122. package/dist/lib/refresh-coordinator.d.ts +1 -1
  123. package/dist/lib/refresh-coordinator.js +1 -1
  124. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  125. package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +2 -0
  126. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  127. package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
  128. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +13 -1
  129. package/dist/lib/session/active.d.ts +67 -0
  130. package/dist/lib/session/active.js +69 -1
  131. package/dist/lib/session/db.js +4 -2
  132. package/dist/lib/session/discover.d.ts +2 -0
  133. package/dist/lib/session/discover.js +12 -6
  134. package/dist/lib/session/prompt.d.ts +35 -0
  135. package/dist/lib/session/prompt.js +56 -0
  136. package/dist/lib/session/remote/watch.d.ts +1 -1
  137. package/dist/lib/session/remote/watch.js +6 -1
  138. package/dist/lib/session/render.js +7 -3
  139. package/dist/lib/session/session-cache.js +5 -1
  140. package/dist/lib/session/trajectory-compare.d.ts +47 -0
  141. package/dist/lib/session/trajectory-compare.js +116 -0
  142. package/dist/lib/session/trajectory-html.d.ts +15 -0
  143. package/dist/lib/session/trajectory-html.js +750 -0
  144. package/dist/lib/session/trajectory-lineage.d.ts +92 -0
  145. package/dist/lib/session/trajectory-lineage.js +195 -0
  146. package/dist/lib/session/trajectory-text.d.ts +37 -0
  147. package/dist/lib/session/trajectory-text.js +277 -0
  148. package/dist/lib/session/trajectory.d.ts +102 -0
  149. package/dist/lib/session/trajectory.js +335 -0
  150. package/dist/lib/startup/command-registry.d.ts +5 -2
  151. package/dist/lib/startup/command-registry.js +8 -5
  152. package/dist/lib/state.d.ts +1 -1
  153. package/dist/lib/state.js +4 -2
  154. package/dist/lib/teams/agents.js +9 -0
  155. package/dist/lib/terminal/inject.d.ts +19 -0
  156. package/dist/lib/terminal/inject.js +19 -9
  157. package/dist/lib/tmux/session.d.ts +46 -13
  158. package/dist/lib/tmux/session.js +173 -27
  159. package/dist/lib/types.d.ts +16 -0
  160. package/dist/lib/usage-backoff.d.ts +2 -1
  161. package/dist/lib/usage-backoff.js +60 -19
  162. package/dist/lib/usage-refresh.d.ts +17 -3
  163. package/dist/lib/usage-refresh.js +57 -11
  164. package/dist/lib/view-types.d.ts +2 -2
  165. package/dist/lib/watchdog/log.d.ts +1 -1
  166. package/dist/lib/watchdog/log.js +1 -1
  167. package/dist/lib/watchdog/runner.d.ts +36 -45
  168. package/dist/lib/watchdog/runner.js +150 -119
  169. package/dist/lib/watchdog/service.d.ts +0 -1
  170. package/dist/lib/watchdog/service.js +0 -1
  171. package/dist/lib/watchdog/watchdog-agent.d.ts +38 -0
  172. package/dist/lib/watchdog/watchdog-agent.js +59 -0
  173. package/dist/lib/watchdog/watchdog.d.ts +11 -2
  174. package/dist/lib/watchdog/watchdog.js +37 -104
  175. package/package.json +2 -2
  176. package/dist/commands/usage.d.ts +0 -27
  177. 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 = 5;
244
+ export declare const AGENT_HOOK_SCHEMA = 6;
218
245
  /**
219
- * The guarded `pane-died` hook. Detach the client ONLY when the agent pane dies
220
- * (so the blocking attach in runInTmux returns and the exit status can be read);
221
- * a user split's death runs the else-branch, closing just that split. The
222
- * else-branch goes through `run-shell -b -C` with an explicit `-t #{hook_pane}`
223
- * target: tmux format-expands the command at fire time and executes it inside
224
- * the server command queue in the background, so the event pane is always the
225
- * one killed without launching a second tmux client against the same socket and
226
- * without stalling the hook on a loaded runner. A bare `kill-pane` relied on
227
- * the hook context supplying a "current pane", while an external self-client
228
- * could race the server under load. Single source of truth: both the
229
- * spawn-wrap (exec.ts) and the daemon reconcile build the hook here, so the
230
- * two can never drift.
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. */
@@ -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
- // Set remain-on-exit BEFORE the child command can finish — a fast-exiting
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 args = ['set-option', '-g', 'remain-on-exit', 'on', ';', 'new-session', '-d', '-s', opts.name, '-P', '-F', '#{pane_id}'];
82
- if (opts.width)
83
- args.push('-x', String(opts.width));
84
- if (opts.height)
85
- args.push('-y', String(opts.height));
86
- if (opts.cwd)
87
- args.push('-c', opts.cwd);
88
- // Separator + child command. tmux passes the rest verbatim to exec, so no
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 = 5;
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. Detach the client ONLY when the agent pane dies
420
- * (so the blocking attach in runInTmux returns and the exit status can be read);
421
- * a user split's death runs the else-branch, closing just that split. The
422
- * else-branch goes through `run-shell -b -C` with an explicit `-t #{hook_pane}`
423
- * target: tmux format-expands the command at fire time and executes it inside
424
- * the server command queue in the background, so the event pane is always the
425
- * one killed without launching a second tmux client against the same socket and
426
- * without stalling the hook on a loaded runner. A bare `kill-pane` relied on
427
- * the hook context supplying a "current pane", while an external self-client
428
- * could race the server under load. Single source of truth: both the
429
- * spawn-wrap (exec.ts) and the daemon reconcile build the hook here, so the
430
- * two can never drift.
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
- return `if -F '#{==:#{hook_pane},${agentPane}}' 'detach-client -s =${sessionName}' 'run-shell -b -C "kill-pane -t #{hook_pane}"'`;
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) {
@@ -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
- * Deliberately per-provider, not per-account: the endpoint throttles the caller,
43
- * and the observed 429 hit all five accounts on the box at once. Backing off one
44
- * account while the others keep firing would not clear the penalty.
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
- /** Every recorded deadline for `agent`, newest-first. Never throws. */
88
- function deadlinesFor(agent) {
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 = `${agent}.`;
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 at = Number(name.slice(prefix.length));
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
- fs.writeFileSync(path.join(backoffDir(), `${agent}.${deadline}`), '');
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 at of deadlinesFor(agent)) {
141
- if (at > now) {
142
- if (latest === null || at > latest)
143
- latest = at;
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
- catch {
150
- /* another process may have swept it already */
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
- /** Epoch ms a provider is backed off until, or null when free (usageRateLimitedUntil). */
99
- backoffUntil: (agentId: AgentId) => number | null;
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 provider-backed-off, live-fetch its usage, update the cache, and
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
  */