@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.
- package/CHANGELOG.md +57 -2
- package/README.md +12 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/cloud.d.ts +3 -0
- package/dist/commands/cloud.js +2 -1
- package/dist/commands/exec.js +65 -1
- package/dist/commands/feed.d.ts +29 -0
- package/dist/commands/feed.js +237 -32
- package/dist/commands/memory.d.ts +9 -0
- package/dist/commands/memory.js +164 -0
- package/dist/commands/message.d.ts +11 -6
- package/dist/commands/message.js +140 -5
- package/dist/commands/routines.js +12 -0
- package/dist/commands/secrets-migrate.d.ts +2 -1
- package/dist/commands/secrets-migrate.js +88 -13
- package/dist/commands/secrets.js +4 -1
- package/dist/commands/sessions.js +10 -1
- package/dist/commands/worktree.js +4 -2
- package/dist/index.js +16 -21
- package/dist/lib/agents.js +249 -17
- package/dist/lib/answer-router.d.ts +75 -0
- package/dist/lib/answer-router.js +149 -0
- package/dist/lib/ask-classifier.d.ts +71 -0
- package/dist/lib/ask-classifier.js +197 -0
- package/dist/lib/cloud/antigravity.d.ts +0 -2
- package/dist/lib/cloud/antigravity.js +2 -17
- package/dist/lib/cloud/codex.js +3 -18
- package/dist/lib/cloud/rush.js +3 -15
- package/dist/lib/cloud/stream.js +2 -0
- package/dist/lib/cloud/types.d.ts +21 -0
- package/dist/lib/cloud/types.js +81 -0
- package/dist/lib/crabbox/cli.d.ts +1 -1
- package/dist/lib/crabbox/cli.js +12 -2
- package/dist/lib/crabbox/lease.d.ts +13 -0
- package/dist/lib/crabbox/lease.js +11 -2
- package/dist/lib/crabbox/progress.d.ts +62 -0
- package/dist/lib/crabbox/progress.js +129 -0
- package/dist/lib/events.js +4 -1
- package/dist/lib/exec.js +19 -1
- package/dist/lib/feed-outcome.d.ts +101 -0
- package/dist/lib/feed-outcome.js +244 -0
- package/dist/lib/feed-policy.d.ts +30 -0
- package/dist/lib/feed-policy.js +133 -0
- package/dist/lib/feed.d.ts +127 -3
- package/dist/lib/feed.js +416 -40
- package/dist/lib/git.d.ts +17 -1
- package/dist/lib/git.js +20 -1
- package/dist/lib/hooks.js +522 -12
- package/dist/lib/hosts/passthrough.d.ts +3 -3
- package/dist/lib/hosts/passthrough.js +3 -4
- package/dist/lib/mailbox-gc.d.ts +22 -0
- package/dist/lib/mailbox-gc.js +161 -0
- package/dist/lib/mailbox.d.ts +26 -2
- package/dist/lib/mailbox.js +80 -5
- package/dist/lib/mcp.js +82 -0
- package/dist/lib/memory.d.ts +55 -0
- package/dist/lib/memory.js +274 -0
- package/dist/lib/notify.d.ts +16 -0
- package/dist/lib/notify.js +61 -0
- package/dist/lib/operator.d.ts +26 -0
- package/dist/lib/operator.js +107 -0
- package/dist/lib/plugins.d.ts +35 -0
- package/dist/lib/plugins.js +217 -0
- package/dist/lib/remote-agents-json.d.ts +14 -0
- package/dist/lib/remote-agents-json.js +94 -0
- package/dist/lib/resources/mcp.js +44 -0
- package/dist/lib/resources/memory.d.ts +15 -0
- package/dist/lib/resources/memory.js +46 -0
- package/dist/lib/resources/types.d.ts +2 -2
- package/dist/lib/runner.d.ts +43 -0
- package/dist/lib/runner.js +323 -74
- package/dist/lib/sandbox.js +6 -0
- package/dist/lib/secrets/bundles.js +38 -13
- package/dist/lib/secrets/icloud-import.d.ts +12 -3
- package/dist/lib/secrets/icloud-import.js +37 -7
- package/dist/lib/secrets/index.d.ts +106 -2
- package/dist/lib/secrets/index.js +603 -28
- package/dist/lib/session/active.d.ts +18 -0
- package/dist/lib/session/active.js +47 -17
- package/dist/lib/session/db.d.ts +9 -1
- package/dist/lib/session/db.js +18 -3
- package/dist/lib/session/discover.d.ts +13 -0
- package/dist/lib/session/discover.js +31 -0
- package/dist/lib/session/parse.d.ts +8 -0
- package/dist/lib/session/parse.js +42 -21
- package/dist/lib/session/remote-active.js +8 -89
- package/dist/lib/session/state.d.ts +11 -0
- package/dist/lib/session/state.js +37 -0
- package/dist/lib/session/tail.d.ts +23 -4
- package/dist/lib/session/tail.js +34 -16
- package/dist/lib/session/throughput.d.ts +30 -0
- package/dist/lib/session/throughput.js +86 -0
- package/dist/lib/shim-heal.d.ts +12 -3
- package/dist/lib/shim-heal.js +12 -6
- package/dist/lib/staleness/detectors/subagents.js +57 -3
- package/dist/lib/staleness/writers/hooks.js +7 -3
- package/dist/lib/staleness/writers/subagents.js +37 -6
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +2 -0
- package/dist/lib/subagents.d.ts +52 -0
- package/dist/lib/subagents.js +315 -12
- package/dist/lib/teams/worktree.d.ts +8 -0
- package/dist/lib/teams/worktree.js +8 -0
- package/dist/lib/types.d.ts +10 -2
- package/dist/lib/versions.js +38 -48
- package/package.json +4 -3
- 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 {
|
|
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
|
-
/**
|
|
208
|
-
|
|
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
|
|
237
|
+
return {};
|
|
211
238
|
const agent = kind === 'codex' ? 'codex' : 'claude';
|
|
212
|
-
const events =
|
|
239
|
+
const { events, content } = readSessionTailWithRaw(sessionFile, agent);
|
|
213
240
|
if (events.length === 0)
|
|
214
|
-
return
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
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 =
|
|
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 =
|
|
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));
|
package/dist/lib/session/db.d.ts
CHANGED
|
@@ -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
|
|
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. */
|
package/dist/lib/session/db.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
570
|
-
//
|
|
571
|
-
|
|
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
|
|
574
|
-
|
|
575
|
-
args
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
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 {
|
|
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
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
}
|