@phnx-labs/agents-cli 1.20.52 → 1.20.53

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 (107) hide show
  1. package/CHANGELOG.md +57 -2
  2. package/README.md +12 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/cloud.d.ts +3 -0
  5. package/dist/commands/cloud.js +2 -1
  6. package/dist/commands/exec.js +65 -1
  7. package/dist/commands/feed.d.ts +29 -0
  8. package/dist/commands/feed.js +237 -32
  9. package/dist/commands/memory.d.ts +9 -0
  10. package/dist/commands/memory.js +164 -0
  11. package/dist/commands/message.d.ts +11 -6
  12. package/dist/commands/message.js +140 -5
  13. package/dist/commands/routines.js +12 -0
  14. package/dist/commands/secrets-migrate.d.ts +2 -1
  15. package/dist/commands/secrets-migrate.js +88 -13
  16. package/dist/commands/secrets.js +4 -1
  17. package/dist/commands/sessions.js +10 -1
  18. package/dist/commands/worktree.js +4 -2
  19. package/dist/index.js +16 -21
  20. package/dist/lib/agents.js +249 -17
  21. package/dist/lib/answer-router.d.ts +75 -0
  22. package/dist/lib/answer-router.js +149 -0
  23. package/dist/lib/ask-classifier.d.ts +71 -0
  24. package/dist/lib/ask-classifier.js +197 -0
  25. package/dist/lib/cloud/antigravity.d.ts +0 -2
  26. package/dist/lib/cloud/antigravity.js +2 -17
  27. package/dist/lib/cloud/codex.js +3 -18
  28. package/dist/lib/cloud/rush.js +3 -15
  29. package/dist/lib/cloud/stream.js +2 -0
  30. package/dist/lib/cloud/types.d.ts +21 -0
  31. package/dist/lib/cloud/types.js +81 -0
  32. package/dist/lib/crabbox/cli.d.ts +1 -1
  33. package/dist/lib/crabbox/cli.js +12 -2
  34. package/dist/lib/crabbox/lease.d.ts +13 -0
  35. package/dist/lib/crabbox/lease.js +11 -2
  36. package/dist/lib/crabbox/progress.d.ts +62 -0
  37. package/dist/lib/crabbox/progress.js +129 -0
  38. package/dist/lib/events.js +4 -1
  39. package/dist/lib/exec.js +19 -1
  40. package/dist/lib/feed-outcome.d.ts +101 -0
  41. package/dist/lib/feed-outcome.js +244 -0
  42. package/dist/lib/feed-policy.d.ts +30 -0
  43. package/dist/lib/feed-policy.js +133 -0
  44. package/dist/lib/feed.d.ts +127 -3
  45. package/dist/lib/feed.js +416 -40
  46. package/dist/lib/git.d.ts +17 -1
  47. package/dist/lib/git.js +20 -1
  48. package/dist/lib/hooks.js +522 -12
  49. package/dist/lib/hosts/passthrough.d.ts +3 -3
  50. package/dist/lib/hosts/passthrough.js +3 -4
  51. package/dist/lib/mailbox-gc.d.ts +22 -0
  52. package/dist/lib/mailbox-gc.js +161 -0
  53. package/dist/lib/mailbox.d.ts +26 -2
  54. package/dist/lib/mailbox.js +80 -5
  55. package/dist/lib/mcp.js +82 -0
  56. package/dist/lib/memory.d.ts +55 -0
  57. package/dist/lib/memory.js +274 -0
  58. package/dist/lib/notify.d.ts +16 -0
  59. package/dist/lib/notify.js +61 -0
  60. package/dist/lib/operator.d.ts +26 -0
  61. package/dist/lib/operator.js +107 -0
  62. package/dist/lib/plugins.d.ts +35 -0
  63. package/dist/lib/plugins.js +217 -0
  64. package/dist/lib/remote-agents-json.d.ts +14 -0
  65. package/dist/lib/remote-agents-json.js +94 -0
  66. package/dist/lib/resources/mcp.js +44 -0
  67. package/dist/lib/resources/memory.d.ts +15 -0
  68. package/dist/lib/resources/memory.js +46 -0
  69. package/dist/lib/resources/types.d.ts +2 -2
  70. package/dist/lib/runner.d.ts +43 -0
  71. package/dist/lib/runner.js +323 -74
  72. package/dist/lib/sandbox.js +6 -0
  73. package/dist/lib/secrets/bundles.js +38 -13
  74. package/dist/lib/secrets/icloud-import.d.ts +12 -3
  75. package/dist/lib/secrets/icloud-import.js +37 -7
  76. package/dist/lib/secrets/index.d.ts +106 -2
  77. package/dist/lib/secrets/index.js +603 -28
  78. package/dist/lib/session/active.d.ts +18 -0
  79. package/dist/lib/session/active.js +47 -17
  80. package/dist/lib/session/db.d.ts +9 -1
  81. package/dist/lib/session/db.js +18 -3
  82. package/dist/lib/session/discover.d.ts +13 -0
  83. package/dist/lib/session/discover.js +31 -0
  84. package/dist/lib/session/parse.d.ts +8 -0
  85. package/dist/lib/session/parse.js +42 -21
  86. package/dist/lib/session/remote-active.js +8 -89
  87. package/dist/lib/session/state.d.ts +11 -0
  88. package/dist/lib/session/state.js +37 -0
  89. package/dist/lib/session/tail.d.ts +23 -4
  90. package/dist/lib/session/tail.js +34 -16
  91. package/dist/lib/session/throughput.d.ts +30 -0
  92. package/dist/lib/session/throughput.js +86 -0
  93. package/dist/lib/shim-heal.d.ts +12 -3
  94. package/dist/lib/shim-heal.js +12 -6
  95. package/dist/lib/staleness/detectors/subagents.js +57 -3
  96. package/dist/lib/staleness/writers/hooks.js +7 -3
  97. package/dist/lib/staleness/writers/subagents.js +37 -6
  98. package/dist/lib/startup/command-registry.d.ts +1 -0
  99. package/dist/lib/startup/command-registry.js +2 -0
  100. package/dist/lib/subagents.d.ts +52 -0
  101. package/dist/lib/subagents.js +315 -12
  102. package/dist/lib/teams/worktree.d.ts +8 -0
  103. package/dist/lib/teams/worktree.js +8 -0
  104. package/dist/lib/types.d.ts +10 -2
  105. package/dist/lib/versions.js +38 -48
  106. package/package.json +4 -3
  107. package/scripts/postinstall.js +61 -1
@@ -1,3 +1,4 @@
1
+ import type { CloudTaskStatus } from '../cloud/types.js';
1
2
  import { type PidSessionEntry } from './pid-registry.js';
2
3
  import { type SessionActivity, type AwaitingReason, type StructuredQuestion, type DetectedPr, type DetectedWorktree, type DetectedTicket } from './state.js';
3
4
  import { type SessionProvenance } from './provenance.js';
@@ -28,6 +29,12 @@ export interface ActiveSession {
28
29
  preview?: string;
29
30
  /** Inferred activity: working / waiting_input / idle (from the transcript tail). */
30
31
  activity?: SessionActivity;
32
+ /**
33
+ * Output-token throughput (tokens/sec) over a rolling 60s window, from the
34
+ * transcript tail. The number the Factory Floor shows next to a running agent;
35
+ * absent when no transcript is resolvable or the agent format reports no usage.
36
+ */
37
+ tokPerSec?: number;
31
38
  /** Why the agent is waiting, when activity is waiting_input. */
32
39
  awaitingReason?: AwaitingReason;
33
40
  /** The structured decision (question/plan/permission + options) the agent is waiting on. */
@@ -46,6 +53,8 @@ export interface ActiveSession {
46
53
  worktree?: DetectedWorktree;
47
54
  /** Tracker ticket the session is tied to. */
48
55
  ticket?: DetectedTicket;
56
+ /** Per-session rate/usage limit detected in the transcript (RUSH-1523). */
57
+ rateLimited?: boolean;
49
58
  /** Tracker refs the session CREATED (Linear create_issue / gh issue create). */
50
59
  createdTickets?: string[];
51
60
  /** Team name the session SPAWNED via `agents teams create/add`. */
@@ -112,10 +121,19 @@ export interface ActiveSession {
112
121
  tab?: number;
113
122
  };
114
123
  }
124
+ export declare function activeStatusFromCloudStatus(status: CloudTaskStatus): ActiveStatus;
115
125
  export interface ActiveQueryOptions {
116
126
  /** Skip the `ps` scan for ad-hoc headless agents. */
117
127
  skipHeadless?: boolean;
118
128
  }
129
+ /**
130
+ * A live process can only borrow an indexed session file if that transcript
131
+ * has been touched recently enough to plausibly belong to the process. This is
132
+ * deliberately wider than ACTIVE_MTIME_WINDOW_MS: an inactive-but-live CLI can
133
+ * be idle for longer than 2 minutes, but it must not attach to a weeks-old
134
+ * transcript just because a GUI app service with the same basename is alive.
135
+ */
136
+ export declare const ACTIVE_SESSION_STALE_MS: number;
119
137
  /**
120
138
  * Resolve an agent kind from a process's reported executable. `comm` may be an
121
139
  * absolute path (shim-launched agents), and Windows image names carry an
@@ -29,7 +29,8 @@ import { buildClaudeLabelMap } from './discover.js';
29
29
  import { buildRunNameMap } from './run-names.js';
30
30
  import { latestSessionFileForCwd } from './db.js';
31
31
  import { extractSessionTopic } from './prompt.js';
32
- import { readSessionTail } from './tail.js';
32
+ import { readSessionTailWithRaw } from './tail.js';
33
+ import { computeTokPerSec } from './throughput.js';
33
34
  import { inferSessionState } from './state.js';
34
35
  import { detectProvenance } from './provenance.js';
35
36
  import { mapBounded } from '../concurrency.js';
@@ -42,6 +43,18 @@ const execFileAsync = promisify(execFile);
42
43
  */
43
44
  export const LSOF_CONCURRENCY = 4;
44
45
  const LSOF_STAGGER_MS = 10;
46
+ export function activeStatusFromCloudStatus(status) {
47
+ switch (status) {
48
+ case 'running':
49
+ return 'running';
50
+ case 'idle':
51
+ return 'idle';
52
+ case 'input_required':
53
+ return 'input_required';
54
+ default:
55
+ return 'queued';
56
+ }
57
+ }
45
58
  const HOME = os.homedir();
46
59
  const LIVE_TERMINALS_FILE = path.join(getTerminalsDir(), 'live-terminals.json');
47
60
  /**
@@ -50,6 +63,14 @@ const LIVE_TERMINALS_FILE = path.join(getTerminalsDir(), 'live-terminals.json');
50
63
  * healthy session writes several times a minute.
51
64
  */
52
65
  const ACTIVE_MTIME_WINDOW_MS = 2 * 60_000;
66
+ /**
67
+ * A live process can only borrow an indexed session file if that transcript
68
+ * has been touched recently enough to plausibly belong to the process. This is
69
+ * deliberately wider than ACTIVE_MTIME_WINDOW_MS: an inactive-but-live CLI can
70
+ * be idle for longer than 2 minutes, but it must not attach to a weeks-old
71
+ * transcript just because a GUI app service with the same basename is alive.
72
+ */
73
+ export const ACTIVE_SESSION_STALE_MS = 24 * 60 * 60_000;
53
74
  /** Executables we recognize as agent CLIs when scanning the process table. */
54
75
  const AGENT_CLI_NAMES = {
55
76
  claude: 'claude',
@@ -194,7 +215,7 @@ export function findSessionFileForKind(kind, cwd, sessionId) {
194
215
  if (kind === 'claude')
195
216
  return findClaudeSessionFile(cwd, sessionId);
196
217
  if (kind === 'codex')
197
- return latestSessionFileForCwd('codex', cwd);
218
+ return latestSessionFileForCwd('codex', cwd, { maxAgeMs: ACTIVE_SESSION_STALE_MS });
198
219
  return undefined;
199
220
  }
200
221
  /** Recover the session UUID from a transcript filename (Claude `<uuid>.jsonl`, Codex `rollout-…-<uuid>.jsonl`). */
@@ -204,20 +225,28 @@ function sessionIdFromFile(file) {
204
225
  return undefined;
205
226
  return path.basename(file).match(UUID_RE)?.[0];
206
227
  }
207
- /** Infer live state from a session file's tail (Claude/Codex). Undefined when unreadable. */
208
- function computeLiveState(kind, sessionFile, cwd, pidAlive) {
228
+ /**
229
+ * Read a session file's tail ONCE and derive both the inferred state and the
230
+ * output-token throughput from it. State needs the normalized event model;
231
+ * throughput needs the raw lines the event model drops (Codex `token_count`), so
232
+ * both come off the same {@link readSessionTailWithRaw} read. Only Claude/Codex
233
+ * carry live state; other kinds yield an empty signal set.
234
+ */
235
+ function computeLiveSignals(kind, sessionFile, cwd, pidAlive) {
209
236
  if (!sessionFile)
210
- return undefined;
237
+ return {};
211
238
  const agent = kind === 'codex' ? 'codex' : 'claude';
212
- const events = readSessionTail(sessionFile, agent);
239
+ const { events, content } = readSessionTailWithRaw(sessionFile, agent);
213
240
  if (events.length === 0)
214
- return undefined;
241
+ return {};
215
242
  let mtimeMs;
216
243
  try {
217
244
  mtimeMs = fs.statSync(sessionFile).mtimeMs;
218
245
  }
219
246
  catch { /* vanished between calls */ }
220
- return inferSessionState(events, { cwd, pidAlive, mtimeMs, activeWindowMs: ACTIVE_MTIME_WINDOW_MS });
247
+ const state = inferSessionState(events, { cwd, pidAlive, mtimeMs, activeWindowMs: ACTIVE_MTIME_WINDOW_MS });
248
+ const tokPerSec = computeTokPerSec(content, agent);
249
+ return { state, tokPerSec: tokPerSec > 0 ? tokPerSec : undefined };
221
250
  }
222
251
  /** Map inferred activity onto the coarse ActiveStatus used by the renderer and counts. */
223
252
  function statusFromActivity(activity) {
@@ -246,6 +275,7 @@ function applyState(base, state, fallbackFile) {
246
275
  ticket: state.ticket,
247
276
  createdTickets: state.createdTickets,
248
277
  spawnedTeam: state.spawnedTeam,
278
+ rateLimited: state.rateLimited,
249
279
  };
250
280
  }
251
281
  /**
@@ -329,7 +359,7 @@ export async function listTeamsActive() {
329
359
  const sessionId = a.parentSessionId ?? a.remoteSessionId ?? undefined;
330
360
  const sessionFile = findSessionFileForKind(a.agentType, a.cwd ?? undefined, sessionId ?? undefined);
331
361
  const topic = sessionFile ? quickExtractTopic(sessionFile) : undefined;
332
- const state = computeLiveState(a.agentType, sessionFile, a.cwd ?? undefined, a.pid ? isPidAlive(a.pid) : true);
362
+ const { state, tokPerSec } = computeLiveSignals(a.agentType, sessionFile, a.cwd ?? undefined, a.pid ? isPidAlive(a.pid) : true);
333
363
  return applyState({
334
364
  context: 'teams',
335
365
  kind: a.agentType,
@@ -338,6 +368,7 @@ export async function listTeamsActive() {
338
368
  cwd: a.cwd ?? undefined,
339
369
  label: a.name ?? undefined,
340
370
  topic,
371
+ tokPerSec,
341
372
  sessionFile,
342
373
  startedAtMs: a.startedAt.getTime(),
343
374
  teamName: a.taskName,
@@ -378,7 +409,7 @@ export async function listTerminalsActive() {
378
409
  const name = resolvedId ? runNameMap.get(resolvedId) ?? undefined : undefined;
379
410
  // Extract topic from session file (first meaningful user message)
380
411
  const topic = sessionFile ? quickExtractTopic(sessionFile) : undefined;
381
- const state = computeLiveState(t.kind, sessionFile, t.cwd ?? undefined, isPidAlive(t.pid));
412
+ const { state, tokPerSec } = computeLiveSignals(t.kind, sessionFile, t.cwd ?? undefined, isPidAlive(t.pid));
382
413
  return applyState({
383
414
  context: 'terminal',
384
415
  kind: t.kind,
@@ -390,6 +421,7 @@ export async function listTerminalsActive() {
390
421
  label,
391
422
  name,
392
423
  topic,
424
+ tokPerSec,
393
425
  sessionFile,
394
426
  startedAtMs: t.startedAtMs,
395
427
  windowId: t.windowId,
@@ -410,11 +442,7 @@ export function listCloudActive() {
410
442
  kind: t.agent || 'cloud',
411
443
  label: t.prompt.length > 60 ? t.prompt.slice(0, 57) + '...' : t.prompt,
412
444
  startedAtMs: Date.parse(t.createdAt) || undefined,
413
- status: t.status === 'running'
414
- ? 'running'
415
- : t.status === 'input_required'
416
- ? 'input_required'
417
- : 'queued',
445
+ status: activeStatusFromCloudStatus(t.status),
418
446
  cloudProvider: t.provider,
419
447
  cloudTaskId: t.id,
420
448
  cloudStatus: t.status,
@@ -747,7 +775,7 @@ export async function listUnattributedActive(attributed) {
747
775
  const topic = sessionFile ? quickExtractTopic(sessionFile) : undefined;
748
776
  const host = detectHost(pid, procByPid);
749
777
  const context = host && UI_HOSTS.has(host) ? 'terminal' : 'headless';
750
- const state = computeLiveState(kind, sessionFile, cwd, true);
778
+ const { state, tokPerSec } = computeLiveSignals(kind, sessionFile, cwd, true);
751
779
  out.push(applyState({
752
780
  context,
753
781
  kind,
@@ -757,6 +785,7 @@ export async function listUnattributedActive(attributed) {
757
785
  cwd,
758
786
  sessionId: entry?.sessionId ?? sessionIdFromFile(sessionFile),
759
787
  topic,
788
+ tokPerSec,
760
789
  sessionFile,
761
790
  pidCount: 1 + (foldedByRoot.get(pid) ?? 0),
762
791
  }, state, sessionFile));
@@ -816,7 +845,7 @@ export async function listTmuxAgentSessions() {
816
845
  const cwd = meta?.cwd ?? (curPath || undefined);
817
846
  const sessionFile = findSessionFileForKind(agent, cwd, sessionId);
818
847
  const topic = sessionFile ? quickExtractTopic(sessionFile) : undefined;
819
- const state = computeLiveState(agent, sessionFile, cwd, pid ? isPidAlive(pid) : true);
848
+ const { state, tokPerSec } = computeLiveSignals(agent, sessionFile, cwd, pid ? isPidAlive(pid) : true);
820
849
  // Provenance is known exactly here (the pane IS a tmux pane) — set it so
821
850
  // enrichProvenance skips it and the locator/reply rails resolve off the pane.
822
851
  const provenance = {
@@ -833,6 +862,7 @@ export async function listTmuxAgentSessions() {
833
862
  sessionId,
834
863
  cwd,
835
864
  topic,
865
+ tokPerSec,
836
866
  sessionFile,
837
867
  provenance,
838
868
  }, state, sessionFile));
@@ -157,13 +157,21 @@ export declare function seedLabelsFromNames(nameMap: Map<string, string | null>)
157
157
  * Returns the number of rows updated.
158
158
  */
159
159
  export declare function syncTopics(topicMap: Map<string, string>): number;
160
+ export declare function isSessionActivityFresh(row: {
161
+ last_activity: string | null;
162
+ timestamp: string;
163
+ file_mtime_ms: number | null;
164
+ }, maxAgeMs: number, nowMs: number): boolean;
160
165
  /**
161
166
  * Newest indexed session file for an agent working in `cwd`. Lets the live
162
167
  * `--active` scanner locate a Codex transcript (whose files are date-partitioned,
163
168
  * not cwd-keyed like Claude's) by reusing the index. Returns undefined if the
164
169
  * session hasn't been scanned yet — the caller degrades to no live state.
165
170
  */
166
- export declare function latestSessionFileForCwd(agent: SessionAgentId, cwd: string): string | undefined;
171
+ export declare function latestSessionFileForCwd(agent: SessionAgentId, cwd: string, options?: {
172
+ maxAgeMs?: number;
173
+ nowMs?: number;
174
+ }): string | undefined;
167
175
  /** Query sessions from the database, applying filters and ordering by last-activity descending (default). */
168
176
  export declare function querySessions(options?: QueryOptions): SessionMeta[];
169
177
  /** Count sessions matching the given filter options. */
@@ -801,13 +801,18 @@ function resolveLastActivity(meta, scan) {
801
801
  return new Date(scan.fileMtimeMs).toISOString();
802
802
  return meta.timestamp;
803
803
  }
804
+ export function isSessionActivityFresh(row, maxAgeMs, nowMs) {
805
+ const parsedActivityMs = Date.parse(row.last_activity ?? row.timestamp);
806
+ const activityMs = Number.isFinite(parsedActivityMs) ? parsedActivityMs : row.file_mtime_ms ?? undefined;
807
+ return activityMs != null && nowMs - activityMs <= maxAgeMs;
808
+ }
804
809
  /**
805
810
  * Newest indexed session file for an agent working in `cwd`. Lets the live
806
811
  * `--active` scanner locate a Codex transcript (whose files are date-partitioned,
807
812
  * not cwd-keyed like Claude's) by reusing the index. Returns undefined if the
808
813
  * session hasn't been scanned yet — the caller degrades to no live state.
809
814
  */
810
- export function latestSessionFileForCwd(agent, cwd) {
815
+ export function latestSessionFileForCwd(agent, cwd, options) {
811
816
  if (!cwd)
812
817
  return undefined;
813
818
  let normalized = cwd;
@@ -817,9 +822,19 @@ export function latestSessionFileForCwd(agent, cwd) {
817
822
  catch { /* use as-is */ }
818
823
  const db = getDB();
819
824
  const row = db
820
- .prepare(`SELECT file_path FROM sessions WHERE agent = ? AND cwd = ? ORDER BY timestamp DESC LIMIT 1`)
825
+ .prepare(`SELECT file_path, last_activity, timestamp, file_mtime_ms
826
+ FROM sessions
827
+ WHERE agent = ? AND cwd = ?
828
+ ORDER BY COALESCE(last_activity, timestamp) DESC
829
+ LIMIT 1`)
821
830
  .get(agent, normalized);
822
- return row?.file_path;
831
+ if (!row)
832
+ return undefined;
833
+ if (options?.maxAgeMs != null) {
834
+ if (!isSessionActivityFresh(row, options.maxAgeMs, options.nowMs ?? Date.now()))
835
+ return undefined;
836
+ }
837
+ return row.file_path;
823
838
  }
824
839
  /** Build a parameterized WHERE clause from query options. */
825
840
  function buildSessionWhere(options) {
@@ -121,6 +121,19 @@ export declare function shouldDeferRecentAppend(prev: ScanStamp, current: ScanSt
121
121
  * realpath to avoid double-counting symlinked version homes.
122
122
  */
123
123
  export declare function getAgentSessionDirs(agent: string, subdir: string): string[];
124
+ /** A session-agent's on-disk watch roots (every version home + backup mirror). */
125
+ export interface SessionRoots {
126
+ agent: SessionAgentId;
127
+ /** Absolute directories that hold this agent's transcripts, existing right now. */
128
+ dirs: string[];
129
+ }
130
+ /**
131
+ * The directories `agents sessions` scans for each on-disk session agent,
132
+ * resolved to what exists on this machine. Emitted by `agents sessions --roots
133
+ * --json` so external watchers stay in lockstep with the CLI's discovery paths.
134
+ * Agents with no directories present are omitted.
135
+ */
136
+ export declare function getSessionRoots(): SessionRoots[];
124
137
  /**
125
138
  * Build a map of Claude sessionId -> user-given label from ~/.claude/sessions/*.json.
126
139
  * Each JSON has shape { pid, sessionId, cwd, startedAt, name?, ... }. The
@@ -303,6 +303,37 @@ export function getAgentSessionDirs(agent, subdir) {
303
303
  }
304
304
  return dirs;
305
305
  }
306
+ /**
307
+ * The (agent, subdir) pairs `discoverSessions` walks for JSONL transcripts —
308
+ * the single source of truth for which directories hold live session files.
309
+ * `getSessionRoots` expands each pair to its concrete directories so a consumer
310
+ * (the Factory extension's fs.watch, see issue #741) can configure its watcher
311
+ * from the CLI instead of hardcoding `~/.claude|.codex|.gemini`. Adding a new
312
+ * on-disk agent here makes every consumer watch it automatically.
313
+ */
314
+ const SESSION_ROOT_SPECS = [
315
+ { agent: 'claude', subdir: 'projects' },
316
+ { agent: 'codex', subdir: 'sessions' },
317
+ { agent: 'gemini', subdir: 'tmp' },
318
+ { agent: 'antigravity', subdir: 'conversations' },
319
+ { agent: 'droid', subdir: 'sessions' },
320
+ { agent: 'kimi', subdir: 'sessions' },
321
+ ];
322
+ /**
323
+ * The directories `agents sessions` scans for each on-disk session agent,
324
+ * resolved to what exists on this machine. Emitted by `agents sessions --roots
325
+ * --json` so external watchers stay in lockstep with the CLI's discovery paths.
326
+ * Agents with no directories present are omitted.
327
+ */
328
+ export function getSessionRoots() {
329
+ const out = [];
330
+ for (const { agent, subdir } of SESSION_ROOT_SPECS) {
331
+ const dirs = getAgentSessionDirs(agent, subdir);
332
+ if (dirs.length > 0)
333
+ out.push({ agent, dirs });
334
+ }
335
+ return out;
336
+ }
306
337
  // ---------------------------------------------------------------------------
307
338
  // Claude account info
308
339
  // ---------------------------------------------------------------------------
@@ -42,6 +42,14 @@ export declare function parseClaude(filePath: string): SessionEvent[];
42
42
  export declare function parseClaudeContent(content: string): SessionEvent[];
43
43
  /** Parse a Codex JSONL session file into normalized events. */
44
44
  export declare function parseCodex(filePath: string): SessionEvent[];
45
+ /**
46
+ * Extract target file path(s) from a Codex apply_patch envelope. The patch body
47
+ * opens with `*** Begin Patch` and carries one or more file ops of the form
48
+ * `*** Update File: <path>` / `*** Add File: <path>` / `*** Delete File: <path>`.
49
+ * Returns every path in order (a multi-file patch emits multiple paths so
50
+ * artifact discovery sees each file — RUSH-1410). Empty when unparseable.
51
+ */
52
+ export declare function applyPatchTargetPaths(input: string): string[];
45
53
  /**
46
54
  * Parse Codex JSONL *content* (already read into a string) into normalized
47
55
  * events. Split from `parseCodex` so the tail reader can parse just the last
@@ -430,14 +430,25 @@ export function parseCodex(filePath) {
430
430
  return parseCodexContent(safeReadSessionFile(filePath));
431
431
  }
432
432
  /**
433
- * Extract the target file path from a Codex apply_patch envelope. The patch body
433
+ * Extract target file path(s) from a Codex apply_patch envelope. The patch body
434
434
  * opens with `*** Begin Patch` and carries one or more file ops of the form
435
435
  * `*** Update File: <path>` / `*** Add File: <path>` / `*** Delete File: <path>`.
436
- * Returns the first file path found, or undefined for an unparseable body.
436
+ * Returns every path in order (a multi-file patch emits multiple paths so
437
+ * artifact discovery sees each file — RUSH-1410). Empty when unparseable.
437
438
  */
439
+ export function applyPatchTargetPaths(input) {
440
+ const paths = [];
441
+ const re = /^\*\*\* (?:Update|Add|Delete) File: (.+)$/gm;
442
+ for (const m of input.matchAll(re)) {
443
+ const p = m[1].trim();
444
+ if (p)
445
+ paths.push(p);
446
+ }
447
+ return paths;
448
+ }
449
+ /** @deprecated Prefer applyPatchTargetPaths — kept for single-file call sites. */
438
450
  function applyPatchTargetPath(input) {
439
- const m = input.match(/^\*\*\* (?:Update|Add|Delete) File: (.+)$/m);
440
- return m ? m[1].trim() : undefined;
451
+ return applyPatchTargetPaths(input)[0];
441
452
  }
442
453
  /**
443
454
  * Parse Codex JSONL *content* (already read into a string) into normalized
@@ -566,24 +577,34 @@ export function parseCodexContent(content) {
566
577
  const rawName = payload.name || 'unknown';
567
578
  const input = typeof payload.input === 'string' ? payload.input : '';
568
579
  const isApplyPatch = rawName === 'apply_patch';
569
- const patchPath = isApplyPatch ? applyPatchTargetPath(input) : undefined;
570
- // Normalize apply_patch to the shared Edit tool so it flows through
571
- // artifact discovery (WRITE_TOOLS) and the Edit summarizer.
580
+ // Multi-file patches: one tool_use per file so artifact discovery sees
581
+ // every path (RUSH-1410). Single-file / non-patch keep one event.
582
+ const patchPaths = isApplyPatch ? applyPatchTargetPaths(input) : [];
572
583
  const tool = isApplyPatch ? 'Edit' : rawName;
573
- const args = { input: input.length > 500 ? input.slice(0, 497) + '...' : input };
574
- if (patchPath)
575
- args.file_path = patchPath;
576
- const callId = payload.call_id || payload.id;
577
- if (callId)
578
- callMap.set(callId, { name: tool, args });
579
- events.push({
580
- type: 'tool_use',
581
- agent: 'codex',
582
- timestamp,
583
- tool,
584
- args,
585
- path: patchPath,
586
- });
584
+ const truncatedInput = input.length > 500 ? input.slice(0, 497) + '...' : input;
585
+ const emitOne = (patchPath) => {
586
+ const args = { input: truncatedInput };
587
+ if (patchPath)
588
+ args.file_path = patchPath;
589
+ const callId = payload.call_id || payload.id;
590
+ if (callId)
591
+ callMap.set(callId, { name: tool, args });
592
+ events.push({
593
+ type: 'tool_use',
594
+ agent: 'codex',
595
+ timestamp,
596
+ tool,
597
+ args,
598
+ path: patchPath,
599
+ });
600
+ };
601
+ if (isApplyPatch && patchPaths.length > 0) {
602
+ for (const p of patchPaths)
603
+ emitOne(p);
604
+ }
605
+ else {
606
+ emitOne(undefined);
607
+ }
587
608
  }
588
609
  else if (ptype === 'custom_tool_call_output') {
589
610
  const callId = payload.call_id || payload.id;
@@ -14,36 +14,13 @@
14
14
  * laptop must not blank the whole view. SSH runs are async + parallel (a fresh
15
15
  * `spawn`, not the sync `sshExec`) so N peers cost one round-trip, not N.
16
16
  */
17
- import { spawn } from 'child_process';
18
- import chalk from 'chalk';
19
- import { SSH_OPTS, controlOpts, assertValidSshTarget, shellQuote } from '../ssh-exec.js';
20
- import { sshTargetFor } from '../devices/connect.js';
21
- import { resolveExplicitTargets } from '../devices/resolve-target.js';
22
- import { loadDevices } from '../devices/registry.js';
23
- import { remoteShellFor, buildWindowsAgentsCommand } from '../hosts/remote-cmd.js';
24
- import { machineId, normalizeHost } from './sync/config.js';
25
- /** Per-host SSH budget. Slightly above SSH_OPTS' ConnectTimeout=10 so a
26
- * reachable-but-slow remote still answers before we give up. */
27
- const REMOTE_TIMEOUT_MS = 12_000;
17
+ import { gatherRemoteAgentsJson } from '../remote-agents-json.js';
28
18
  /**
29
19
  * Recursion guard, passed as an env var (not a CLI flag) so an OLDER remote
30
20
  * `agents` that predates this feature ignores it harmlessly instead of erroring
31
21
  * on an unknown option. A remote new enough to fan out reads it and stays local.
32
22
  */
33
23
  export const NO_FANOUT_ENV = 'AGENTS_SESSIONS_LOCAL';
34
- /** The command run on each peer: answer for itself, as JSON, without recursing.
35
- * A Windows peer gets a PowerShell invocation (ssh lands in cmd.exe/PowerShell
36
- * there, where `bash -lc` is not a command); every other OS keeps `bash -lc`. */
37
- function remoteActiveCommand(os) {
38
- if (remoteShellFor(os) === 'powershell') {
39
- return buildWindowsAgentsCommand({
40
- args: ['sessions', '--active', '--json'],
41
- env: { [NO_FANOUT_ENV]: '1' },
42
- });
43
- }
44
- const inner = `${NO_FANOUT_ENV}=1 agents sessions --active --json`;
45
- return `bash -lc ${shellQuote(inner)}`;
46
- }
47
24
  /**
48
25
  * Parse a peer's `--active --json` stdout into active sessions, tagging each
49
26
  * with `machine`. Defensive against version skew / partial output: non-JSON or
@@ -68,36 +45,6 @@ export function parseRemoteActive(stdout, machine) {
68
45
  }
69
46
  return out;
70
47
  }
71
- /** Run one remote `agents sessions --active --json --local` and capture stdout.
72
- * Resolves `{ code: null }` on spawn error or timeout (host treated as dead). */
73
- function sshCapture(target, remoteCmd, timeoutMs) {
74
- assertValidSshTarget(target);
75
- return new Promise((resolve) => {
76
- const args = [...SSH_OPTS, ...controlOpts(), target, remoteCmd];
77
- const child = spawn('ssh', args, { stdio: ['ignore', 'pipe', 'ignore'] });
78
- let stdout = '';
79
- let settled = false;
80
- const done = (code) => {
81
- if (settled)
82
- return;
83
- settled = true;
84
- clearTimeout(timer);
85
- resolve({ code, stdout });
86
- };
87
- const timer = setTimeout(() => { child.kill('SIGKILL'); done(null); }, timeoutMs);
88
- child.stdout.on('data', (d) => { stdout += d.toString(); });
89
- child.on('error', () => done(null));
90
- child.on('close', (code) => done(code));
91
- });
92
- }
93
- async function fetchByTarget(target, machine, display, os) {
94
- const { code, stdout } = await sshCapture(target, remoteActiveCommand(os), REMOTE_TIMEOUT_MS);
95
- if (code !== 0) {
96
- process.stderr.write(chalk.gray(` ${display}: unreachable or no agents CLI — skipped\n`));
97
- return [];
98
- }
99
- return parseRemoteActive(stdout, machine);
100
- }
101
48
  /**
102
49
  * Gather active sessions from other machines. With an explicit `hosts` list
103
50
  * (from `--host`), fan out to exactly those. Otherwise sweep the registered,
@@ -105,39 +52,11 @@ async function fetchByTarget(target, machine, display, os) {
105
52
  * address. Results from all peers run in parallel and are flattened.
106
53
  */
107
54
  export async function gatherRemoteActive(hosts) {
108
- const self = machineId();
109
- const targets = [];
110
- if (hosts && hosts.length > 0) {
111
- // Resolve each token through the device registry so an explicit --host/--device
112
- // dials the exact same address (and machine id) as the auto-discovery sweep.
113
- targets.push(...await resolveExplicitTargets(hosts));
114
- }
115
- else {
116
- let reg;
117
- try {
118
- reg = await loadDevices();
119
- }
120
- catch {
121
- return { sessions: [], deviceCount: 0 };
122
- }
123
- for (const d of Object.values(reg)) {
124
- if (d.tailscale?.online !== true)
125
- continue;
126
- if (normalizeHost(d.name) === self)
127
- continue;
128
- // Only machines that can actually run the CLI. iOS/tablet nodes register as
129
- // `unknown` platform and can never answer, so skip them rather than burn a
130
- // full ConnectTimeout on each.
131
- if (d.platform !== 'windows' && d.platform !== 'linux' && d.platform !== 'macos')
132
- continue;
133
- try {
134
- targets.push({ target: sshTargetFor(d), machine: normalizeHost(d.name), name: d.name, os: d.platform });
135
- }
136
- catch {
137
- // No address on the profile — nothing to dial; skip silently.
138
- }
139
- }
140
- }
141
- const results = await Promise.all(targets.map((t) => fetchByTarget(t.target, t.machine, t.name, t.os)));
142
- return { sessions: results.flat(), deviceCount: targets.length };
55
+ const result = await gatherRemoteAgentsJson({
56
+ args: ['sessions', '--active', '--json'],
57
+ noFanoutEnv: NO_FANOUT_ENV,
58
+ hosts,
59
+ parse: parseRemoteActive,
60
+ });
61
+ return { sessions: result.items, deviceCount: result.deviceCount };
143
62
  }
@@ -57,6 +57,12 @@ export interface DetectedTicket {
57
57
  id: string;
58
58
  url?: string;
59
59
  }
60
+ /**
61
+ * Detect per-session rate-limit / usage-limit signals in assistant or error
62
+ * text (RUSH-1523). Matches the same shapes Factory's prewarm detectBlockingPrompt
63
+ * uses, plus common Claude/Codex/Gemini limit strings.
64
+ */
65
+ export declare function detectRateLimited(text?: string): boolean;
60
66
  export interface SessionState {
61
67
  activity: SessionActivity;
62
68
  awaitingReason?: AwaitingReason;
@@ -64,6 +70,11 @@ export interface SessionState {
64
70
  lastEventKind?: SessionEvent['type'];
65
71
  /** Single-line description of the latest turn (message text or tool action). */
66
72
  preview?: string;
73
+ /**
74
+ * True when the transcript shows the session is rate/usage limited (RUSH-1523).
75
+ * Distinct from account-level usageStatus — this is per-session evidence.
76
+ */
77
+ rateLimited?: boolean;
67
78
  /**
68
79
  * The structured decision the agent is waiting on (question / plan / permission),
69
80
  * with its options when it offered any. Set only when activity is waiting_input.
@@ -15,6 +15,25 @@
15
15
  * shape + mtime — same function, driven off the normalized events.
16
16
  */
17
17
  import { summarizeToolUse } from './parse.js';
18
+ /**
19
+ * Detect per-session rate-limit / usage-limit signals in assistant or error
20
+ * text (RUSH-1523). Matches the same shapes Factory's prewarm detectBlockingPrompt
21
+ * uses, plus common Claude/Codex/Gemini limit strings.
22
+ */
23
+ export function detectRateLimited(text) {
24
+ if (!text)
25
+ return false;
26
+ const t = text.toLowerCase();
27
+ return (/\brate[- ]?limit(ed|s)?\b/.test(t) ||
28
+ /\btoo many requests\b/.test(t) ||
29
+ /\b429\b/.test(t) ||
30
+ /\busage[- ]?limit(ed)?\b/.test(t) ||
31
+ /\bhit your (usage |rate )?limit\b/.test(t) ||
32
+ /\byou('ve| have) (hit|reached|exceeded) (your |the )?(rate |usage )?limit\b/.test(t) ||
33
+ /\bout of (credits|quota)\b/.test(t) ||
34
+ /\bquota exceeded\b/.test(t) ||
35
+ /\btry again (in|later|after)\b/.test(t) && /\b(limit|rate|quota|throttl)\b/.test(t));
36
+ }
18
37
  /** A healthy live session writes several times a minute; 2 min ⇒ "recently active". */
19
38
  const ACTIVE_WINDOW_MS = 2 * 60_000;
20
39
  /**
@@ -378,6 +397,23 @@ export function inferSessionState(events, ctx = {}) {
378
397
  const state = inferActivity(events, ctx);
379
398
  const { pr, ticket, createdTickets, spawnedTeam } = detectDurableSignals(events);
380
399
  const worktree = detectWorktree(ctx.cwd, ctx.gitBranch);
400
+ // Rate-limit: scan the most recent assistant messages + tool errors (tail-first).
401
+ let rateLimited = false;
402
+ for (let i = events.length - 1; i >= 0 && i >= events.length - 12; i--) {
403
+ const e = events[i];
404
+ if (!e)
405
+ continue;
406
+ if (e.type === 'message' && e.role === 'assistant' && detectRateLimited(e.content)) {
407
+ rateLimited = true;
408
+ break;
409
+ }
410
+ if (e.type === 'tool_result' && detectRateLimited(e.output)) {
411
+ rateLimited = true;
412
+ break;
413
+ }
414
+ }
415
+ if (!rateLimited && detectRateLimited(state.preview))
416
+ rateLimited = true;
381
417
  return {
382
418
  ...state,
383
419
  pr: pr ?? state.pr,
@@ -385,5 +421,6 @@ export function inferSessionState(events, ctx = {}) {
385
421
  ticket: ticket ?? detectTicket(undefined, ctx.gitBranch) ?? state.ticket,
386
422
  createdTickets,
387
423
  spawnedTeam,
424
+ rateLimited: rateLimited || undefined,
388
425
  };
389
426
  }