@phnx-labs/agents-cli 1.20.86 → 1.20.88
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 +172 -0
- package/README.md +3 -3
- package/dist/bin/agents +0 -0
- package/dist/commands/doctor.d.ts +0 -19
- package/dist/commands/doctor.js +219 -305
- package/dist/commands/exec.js +7 -19
- package/dist/commands/inspect.js +3 -5
- package/dist/commands/routines.js +2 -2
- package/dist/commands/sessions-browser.d.ts +18 -0
- package/dist/commands/sessions-browser.js +126 -24
- package/dist/commands/sessions-picker.d.ts +21 -8
- package/dist/commands/sessions-picker.js +83 -7
- package/dist/commands/sessions.d.ts +19 -0
- package/dist/commands/sessions.js +148 -18
- package/dist/commands/ssh.js +62 -4
- package/dist/commands/teams-picker.d.ts +2 -0
- package/dist/commands/teams-picker.js +2 -1
- package/dist/commands/teams.d.ts +4 -1
- package/dist/commands/teams.js +106 -70
- package/dist/commands/usage.d.ts +3 -2
- package/dist/commands/usage.js +2 -9
- package/dist/commands/view.js +14 -3
- package/dist/index.js +31 -1
- package/dist/lib/agents.d.ts +31 -1
- package/dist/lib/agents.js +55 -0
- package/dist/lib/claude-account-token.d.ts +12 -0
- package/dist/lib/claude-account-token.js +63 -0
- package/dist/lib/command-skills.d.ts +10 -0
- package/dist/lib/command-skills.js +14 -0
- package/dist/lib/commands.js +19 -1
- package/dist/lib/daemon.js +17 -2
- package/dist/lib/devices/doctor-findings.d.ts +167 -0
- package/dist/lib/devices/doctor-findings.js +893 -0
- package/dist/lib/devices/fleet-divergence.d.ts +22 -0
- package/dist/lib/devices/fleet-divergence.js +34 -10
- package/dist/lib/devices/fleet-inventory.d.ts +17 -6
- package/dist/lib/devices/fleet-inventory.js +56 -8
- package/dist/lib/devices/registry.d.ts +25 -0
- package/dist/lib/devices/registry.js +82 -1
- package/dist/lib/events.d.ts +8 -1
- package/dist/lib/events.js +13 -0
- package/dist/lib/exec.d.ts +14 -3
- package/dist/lib/exec.js +51 -9
- package/dist/lib/format.d.ts +7 -0
- package/dist/lib/format.js +11 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/project-resources.js +34 -20
- package/dist/lib/runner.d.ts +14 -1
- package/dist/lib/runner.js +37 -8
- package/dist/lib/sandbox.d.ts +2 -0
- package/dist/lib/sandbox.js +38 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/rc-hygiene.d.ts +0 -6
- package/dist/lib/secrets/rc-hygiene.js +0 -24
- package/dist/lib/session/active.d.ts +6 -6
- package/dist/lib/session/active.js +6 -6
- package/dist/lib/session/db.d.ts +21 -0
- package/dist/lib/session/db.js +45 -4
- package/dist/lib/session/discover.d.ts +5 -0
- package/dist/lib/session/discover.js +137 -1
- package/dist/lib/session/parse.d.ts +2 -0
- package/dist/lib/session/parse.js +76 -37
- package/dist/lib/session/remote-list.d.ts +7 -0
- package/dist/lib/session/remote-list.js +8 -4
- package/dist/lib/session/state.js +69 -2
- package/dist/lib/session/sync/agents.js +0 -0
- package/dist/lib/session/team-filter.d.ts +22 -3
- package/dist/lib/session/team-filter.js +106 -17
- package/dist/lib/session/types.d.ts +9 -1
- package/dist/lib/session/types.js +1 -1
- package/dist/lib/signin-badge.d.ts +17 -0
- package/dist/lib/signin-badge.js +19 -0
- package/dist/lib/staleness/detectors/commands.js +14 -5
- package/dist/lib/staleness/types.d.ts +2 -0
- package/dist/lib/staleness/writers/commands.js +13 -7
- package/dist/lib/state.d.ts +2 -0
- package/dist/lib/state.js +2 -0
- package/dist/lib/usage.d.ts +72 -1
- package/dist/lib/usage.js +22 -87
- package/dist/lib/versions.js +30 -13
- package/package.json +1 -1
|
@@ -23,7 +23,7 @@ import { getConfigSymlinkVersion } from '../shims.js';
|
|
|
23
23
|
import { SESSION_AGENTS } from './types.js';
|
|
24
24
|
import { deriveShortId } from './short-id.js';
|
|
25
25
|
import { extractSessionTopic } from './prompt.js';
|
|
26
|
-
import { parseAntigravity } from './parse.js';
|
|
26
|
+
import { parseAntigravity, parseCursor } from './parse.js';
|
|
27
27
|
import { extractPrUrl, detectWorktree, detectTicket, isPrCreateCommand, detectSpawnedTeam, isTicketCreateTool, extractCreatedTicket, extractRecentDirectoriesTouched, extractTodoProgressFromEvents } from './state.js';
|
|
28
28
|
import { costOfUsage } from '../pricing/index.js';
|
|
29
29
|
import { machineForSessionFile } from './origin-machine.js';
|
|
@@ -221,6 +221,7 @@ function dispatchAgentScan(agent, onProgress) {
|
|
|
221
221
|
case 'kimi': return scanKimiIncremental(onProgress);
|
|
222
222
|
case 'droid': return scanDroidIncremental(onProgress);
|
|
223
223
|
case 'grok': return scanGrokIncremental(onProgress);
|
|
224
|
+
case 'cursor': return scanCursorIncremental(onProgress);
|
|
224
225
|
default: return Promise.resolve();
|
|
225
226
|
}
|
|
226
227
|
}
|
|
@@ -660,6 +661,7 @@ const SESSION_ROOT_SPECS = [
|
|
|
660
661
|
{ agent: 'droid', subdir: 'sessions' },
|
|
661
662
|
{ agent: 'kimi', subdir: 'sessions' },
|
|
662
663
|
{ agent: 'grok', subdir: 'sessions' },
|
|
664
|
+
{ agent: 'cursor', subdir: 'projects' },
|
|
663
665
|
];
|
|
664
666
|
function sessionRootSubdir(agent) {
|
|
665
667
|
return SESSION_ROOT_SPECS.find((spec) => spec.agent === agent)?.subdir ?? null;
|
|
@@ -752,6 +754,13 @@ async function readRoutineArchiveMeta(agent, filePath) {
|
|
|
752
754
|
const result = await readCodexMeta(filePath);
|
|
753
755
|
return result ? { ...result, meta: decorateRoutineSession(result.meta, info) } : null;
|
|
754
756
|
}
|
|
757
|
+
if (agent === 'cursor') {
|
|
758
|
+
// PR #1723 archives Cursor routine transcripts; preserve version resolution
|
|
759
|
+
// when this reader consumes those archives.
|
|
760
|
+
const currentVersion = await getCurrentAgentVersion('cursor');
|
|
761
|
+
const result = readCursorMeta(filePath, currentVersion);
|
|
762
|
+
return result ? { ...result, meta: decorateRoutineSession(result.meta, info) } : null;
|
|
763
|
+
}
|
|
755
764
|
return null;
|
|
756
765
|
}
|
|
757
766
|
async function scanRoutineArchivesIncremental(agent, onProgress) {
|
|
@@ -3601,6 +3610,133 @@ function sumKnownNumbers(values) {
|
|
|
3601
3610
|
// Time range parsing
|
|
3602
3611
|
// ---------------------------------------------------------------------------
|
|
3603
3612
|
// ---------------------------------------------------------------------------
|
|
3613
|
+
// Cursor
|
|
3614
|
+
// ---------------------------------------------------------------------------
|
|
3615
|
+
// Cursor writes the conversation to
|
|
3616
|
+
// projects/<encoded-cwd>/agent-transcripts/<uuid>/<uuid>.jsonl and metadata to
|
|
3617
|
+
// chats/<workspace-hash>/<uuid>/meta.json. Discovery deliberately starts from
|
|
3618
|
+
// transcripts, then joins metadata by UUID: chat directories with no transcript
|
|
3619
|
+
// are empty or abandoned sessions and must not become broken zero-event rows.
|
|
3620
|
+
// Routine archives may contain only the transcript; those remain browsable with
|
|
3621
|
+
// file timestamps and without guessed cwd/title metadata.
|
|
3622
|
+
/** Incrementally re-scan changed Cursor transcript files and upsert into the DB. */
|
|
3623
|
+
async function scanCursorIncremental(onProgress) {
|
|
3624
|
+
const currentVersion = await getCurrentAgentVersion('cursor');
|
|
3625
|
+
const prestat = [];
|
|
3626
|
+
for (const projectsDir of getAgentSessionDirs('cursor', 'projects')) {
|
|
3627
|
+
collectCursorTranscripts(projectsDir, prestat);
|
|
3628
|
+
}
|
|
3629
|
+
const changed = filterChangedEntries(prestat);
|
|
3630
|
+
if (changed.length === 0)
|
|
3631
|
+
return;
|
|
3632
|
+
onProgress?.({ agent: 'cursor', parsed: 0, total: changed.length });
|
|
3633
|
+
const entries = [];
|
|
3634
|
+
const touched = [];
|
|
3635
|
+
const seen = new Set();
|
|
3636
|
+
let parsed = 0;
|
|
3637
|
+
for (const { filePath, scan } of changed) {
|
|
3638
|
+
try {
|
|
3639
|
+
const result = readCursorMeta(filePath, currentVersion);
|
|
3640
|
+
if (result && !seen.has(result.meta.id)) {
|
|
3641
|
+
seen.add(result.meta.id);
|
|
3642
|
+
entries.push({ meta: result.meta, content: result.content, scan });
|
|
3643
|
+
}
|
|
3644
|
+
else {
|
|
3645
|
+
touched.push({ filePath, scan });
|
|
3646
|
+
}
|
|
3647
|
+
}
|
|
3648
|
+
catch {
|
|
3649
|
+
touched.push({ filePath, scan });
|
|
3650
|
+
}
|
|
3651
|
+
parsed++;
|
|
3652
|
+
onProgress?.({ agent: 'cursor', parsed, total: changed.length });
|
|
3653
|
+
}
|
|
3654
|
+
upsertSessionsBatch(entries);
|
|
3655
|
+
recordScans(touched);
|
|
3656
|
+
}
|
|
3657
|
+
function collectCursorTranscripts(projectsDir, out) {
|
|
3658
|
+
let projectNames;
|
|
3659
|
+
try {
|
|
3660
|
+
projectNames = fs.readdirSync(projectsDir);
|
|
3661
|
+
}
|
|
3662
|
+
catch {
|
|
3663
|
+
return;
|
|
3664
|
+
}
|
|
3665
|
+
for (const projectName of projectNames) {
|
|
3666
|
+
const transcriptsDir = path.join(projectsDir, projectName, 'agent-transcripts');
|
|
3667
|
+
for (const f of walkForFilesWithStat(transcriptsDir, '.jsonl', 100_000)) {
|
|
3668
|
+
const sessionId = path.basename(path.dirname(f.path));
|
|
3669
|
+
if (path.basename(f.path) !== `${sessionId}.jsonl`)
|
|
3670
|
+
continue;
|
|
3671
|
+
out.push({ filePath: f.path, fileMtimeMs: f.mtimeMs, fileSize: f.size });
|
|
3672
|
+
}
|
|
3673
|
+
}
|
|
3674
|
+
}
|
|
3675
|
+
function readCursorChatMeta(filePath, sessionId) {
|
|
3676
|
+
const projectDir = path.dirname(path.dirname(path.dirname(filePath)));
|
|
3677
|
+
const projectsDir = path.dirname(projectDir);
|
|
3678
|
+
const chatsDir = path.join(path.dirname(projectsDir), 'chats');
|
|
3679
|
+
let workspaceHashes;
|
|
3680
|
+
try {
|
|
3681
|
+
workspaceHashes = fs.readdirSync(chatsDir);
|
|
3682
|
+
}
|
|
3683
|
+
catch {
|
|
3684
|
+
return undefined;
|
|
3685
|
+
}
|
|
3686
|
+
for (const workspaceHash of workspaceHashes) {
|
|
3687
|
+
const metaPath = path.join(chatsDir, workspaceHash, sessionId, 'meta.json');
|
|
3688
|
+
try {
|
|
3689
|
+
return JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
|
|
3690
|
+
}
|
|
3691
|
+
catch {
|
|
3692
|
+
// This workspace hash does not own the session, or its metadata is unreadable.
|
|
3693
|
+
}
|
|
3694
|
+
}
|
|
3695
|
+
return undefined;
|
|
3696
|
+
}
|
|
3697
|
+
/** Parse one Cursor transcript and enrich it with the matching chat meta.json. */
|
|
3698
|
+
export function readCursorMeta(filePath, currentVersion) {
|
|
3699
|
+
const sessionId = path.basename(filePath).replace(/\.jsonl$/, '');
|
|
3700
|
+
if (!sessionId || path.basename(path.dirname(filePath)) !== sessionId)
|
|
3701
|
+
return null;
|
|
3702
|
+
const events = parseCursor(filePath);
|
|
3703
|
+
if (events.length === 0)
|
|
3704
|
+
return null;
|
|
3705
|
+
const chatMeta = readCursorChatMeta(filePath, sessionId);
|
|
3706
|
+
const stat = safeStatSync(filePath);
|
|
3707
|
+
const createdAtMs = typeof chatMeta?.createdAtMs === 'number' ? chatMeta.createdAtMs : undefined;
|
|
3708
|
+
const updatedAtMs = typeof chatMeta?.updatedAtMs === 'number' ? chatMeta.updatedAtMs : undefined;
|
|
3709
|
+
const timestamp = createdAtMs !== undefined
|
|
3710
|
+
? new Date(createdAtMs).toISOString()
|
|
3711
|
+
: stat ? stat.mtime.toISOString() : new Date().toISOString();
|
|
3712
|
+
const lastActivity = updatedAtMs !== undefined
|
|
3713
|
+
? new Date(updatedAtMs).toISOString()
|
|
3714
|
+
: stat ? stat.mtime.toISOString() : timestamp;
|
|
3715
|
+
const cwd = normalizeCwd(typeof chatMeta?.cwd === 'string' ? chatMeta.cwd : '');
|
|
3716
|
+
const userTexts = events
|
|
3717
|
+
.filter((event) => event.type === 'message' && event.role === 'user' && event.content)
|
|
3718
|
+
.map((event) => event.content);
|
|
3719
|
+
const firstUserText = userTexts[0];
|
|
3720
|
+
const title = typeof chatMeta?.title === 'string' && chatMeta.title.trim()
|
|
3721
|
+
? chatMeta.title.trim()
|
|
3722
|
+
: undefined;
|
|
3723
|
+
const meta = {
|
|
3724
|
+
id: sessionId,
|
|
3725
|
+
shortId: deriveShortId(sessionId),
|
|
3726
|
+
agent: 'cursor',
|
|
3727
|
+
timestamp,
|
|
3728
|
+
lastActivity,
|
|
3729
|
+
project: cwd ? path.basename(cwd) : undefined,
|
|
3730
|
+
cwd,
|
|
3731
|
+
filePath,
|
|
3732
|
+
version: resolveSessionVersion('cursor', filePath, undefined, currentVersion),
|
|
3733
|
+
topic: firstUserText ? extractSessionTopic(firstUserText) : undefined,
|
|
3734
|
+
label: title,
|
|
3735
|
+
messageCount: events.filter((event) => event.type === 'message').length,
|
|
3736
|
+
};
|
|
3737
|
+
return { meta, content: userTexts.join('\n') };
|
|
3738
|
+
}
|
|
3739
|
+
// ---------------------------------------------------------------------------
|
|
3604
3740
|
// Kimi
|
|
3605
3741
|
// ---------------------------------------------------------------------------
|
|
3606
3742
|
// Kimi stores sessions under ~/.kimi-code/sessions/<workdir_hash>/session_<uuid>/.
|
|
@@ -109,6 +109,8 @@ export declare function parseRush(filePath: string): SessionEvent[];
|
|
|
109
109
|
export declare function parseHermes(filePath: string): SessionEvent[];
|
|
110
110
|
/** Parse a Kimi session state.json file by reading its agents/main/wire.jsonl. */
|
|
111
111
|
export declare function parseKimi(filePath: string): SessionEvent[];
|
|
112
|
+
/** Parse Cursor's Anthropic-shaped message JSONL transcript. */
|
|
113
|
+
export declare function parseCursor(filePath: string): SessionEvent[];
|
|
112
114
|
/**
|
|
113
115
|
* Parse a Droid (Factory) JSONL session file into normalized events. Droid
|
|
114
116
|
* wraps each turn in a `{type:'message', message:{role, content, modelId}}`
|
|
@@ -154,6 +154,9 @@ export function parseSession(filePath, agent) {
|
|
|
154
154
|
case 'droid':
|
|
155
155
|
events = parseDroid(filePath);
|
|
156
156
|
break;
|
|
157
|
+
case 'cursor':
|
|
158
|
+
events = parseCursor(filePath);
|
|
159
|
+
break;
|
|
157
160
|
}
|
|
158
161
|
// Chokepoint: every string field that originated in an untrusted session
|
|
159
162
|
// file gets stripped of terminal escapes here, so renderers downstream can
|
|
@@ -194,6 +197,8 @@ export function detectAgent(filePath) {
|
|
|
194
197
|
return 'kimi';
|
|
195
198
|
if (filePath.includes('/.factory/') || filePath.includes('\\.factory\\'))
|
|
196
199
|
return 'droid';
|
|
200
|
+
if (filePath.includes('/.cursor/') || filePath.includes('\\.cursor\\'))
|
|
201
|
+
return 'cursor';
|
|
197
202
|
// Cloud convention: cloud-sessions/<id>/session.<format>.jsonl
|
|
198
203
|
const cloudMatch = filePath.match(/session\.(claude|codex|rush)\.jsonl(?:$|[?#])/);
|
|
199
204
|
if (cloudMatch)
|
|
@@ -1617,20 +1622,45 @@ export function parseKimi(filePath) {
|
|
|
1617
1622
|
return events;
|
|
1618
1623
|
}
|
|
1619
1624
|
// ---------------------------------------------------------------------------
|
|
1620
|
-
// Droid (Factory)
|
|
1625
|
+
// Cursor and Droid (Factory) parsers
|
|
1621
1626
|
// ---------------------------------------------------------------------------
|
|
1627
|
+
/** Parse Cursor's Anthropic-shaped message JSONL transcript. */
|
|
1628
|
+
export function parseCursor(filePath) {
|
|
1629
|
+
return parseAnthropicMessageJsonl(filePath, 'cursor');
|
|
1630
|
+
}
|
|
1622
1631
|
/**
|
|
1623
|
-
*
|
|
1624
|
-
*
|
|
1625
|
-
*
|
|
1626
|
-
*
|
|
1632
|
+
* Cursor stamps the first user turn as
|
|
1633
|
+
* `<timestamp>Sunday, Aug 2, 2026, 3:51 AM (UTC-7)</timestamp>` followed by the
|
|
1634
|
+
* real question in `<user_query>`. `Date.parse` silently DISCARDS the `(UTC-7)`
|
|
1635
|
+
* parenthetical and reads the rest as local time, so the offset has to be applied
|
|
1636
|
+
* by hand — otherwise the recovered instant is wrong on every machine whose zone
|
|
1637
|
+
* differs from the one that wrote the transcript.
|
|
1627
1638
|
*/
|
|
1628
|
-
|
|
1639
|
+
function parseCursorUserText(text) {
|
|
1640
|
+
const query = text.match(/<user_query>\s*([\s\S]*?)\s*<\/user_query>/);
|
|
1641
|
+
const stamp = text.match(/<timestamp>\s*([\s\S]*?)\s*<\/timestamp>/)?.[1]?.trim();
|
|
1642
|
+
return { text: (query?.[1] ?? text).trim(), timestamp: parseCursorTimestamp(stamp) };
|
|
1643
|
+
}
|
|
1644
|
+
function parseCursorTimestamp(stamp) {
|
|
1645
|
+
if (!stamp)
|
|
1646
|
+
return undefined;
|
|
1647
|
+
const offset = stamp.match(/\(UTC([+-])(\d{1,2})(?::(\d{2}))?\)/);
|
|
1648
|
+
// Without a declared offset there is no instant to recover -- guessing the
|
|
1649
|
+
// local zone would be worse than falling back to the file mtime.
|
|
1650
|
+
if (!offset)
|
|
1651
|
+
return undefined;
|
|
1652
|
+
const wall = Date.parse(`${stamp.replace(/\s*\(UTC[^)]*\)\s*/, " ").trim()} UTC`);
|
|
1653
|
+
if (Number.isNaN(wall))
|
|
1654
|
+
return undefined;
|
|
1655
|
+
const offsetMs = (Number(offset[2]) * 60 + Number(offset[3] ?? 0)) * 60_000;
|
|
1656
|
+
return new Date(offset[1] === "-" ? wall + offsetMs : wall - offsetMs).toISOString();
|
|
1657
|
+
}
|
|
1658
|
+
function parseAnthropicMessageJsonl(filePath, agent) {
|
|
1629
1659
|
const content = safeReadSessionFile(filePath);
|
|
1630
1660
|
const lines = content.split('\n').filter(l => l.trim());
|
|
1631
1661
|
const events = [];
|
|
1632
|
-
// Map tool_use id -> {tool, args} for correlating with tool_result.
|
|
1633
1662
|
const toolUseMap = new Map();
|
|
1663
|
+
const fallbackTimestamp = fs.statSync(filePath).mtime.toISOString();
|
|
1634
1664
|
for (const line of lines) {
|
|
1635
1665
|
let raw;
|
|
1636
1666
|
try {
|
|
@@ -1639,33 +1669,42 @@ export function parseDroid(filePath) {
|
|
|
1639
1669
|
catch {
|
|
1640
1670
|
continue;
|
|
1641
1671
|
}
|
|
1642
|
-
if (raw.type !== 'message')
|
|
1672
|
+
if (raw.type === 'turn_ended' || (agent === 'droid' && raw.type !== 'message'))
|
|
1643
1673
|
continue;
|
|
1644
|
-
const message = raw.message
|
|
1645
|
-
const
|
|
1646
|
-
|
|
1674
|
+
const message = raw.message;
|
|
1675
|
+
const wireRole = agent === 'cursor' ? raw.role : message?.role;
|
|
1676
|
+
if (!message || (agent === 'cursor' && !['user', 'assistant'].includes(wireRole)))
|
|
1677
|
+
continue;
|
|
1678
|
+
const role = wireRole === 'user' ? 'user' : 'assistant';
|
|
1679
|
+
let timestamp = raw.timestamp || fallbackTimestamp;
|
|
1647
1680
|
const blocks = message.content;
|
|
1648
|
-
// Plain-string content (rare) renders as a single message.
|
|
1649
1681
|
if (typeof blocks === 'string') {
|
|
1650
|
-
const
|
|
1682
|
+
const parsed = agent === 'cursor' && role === 'user'
|
|
1683
|
+
? parseCursorUserText(blocks)
|
|
1684
|
+
: { text: blocks.trim(), timestamp: undefined };
|
|
1685
|
+
const text = parsed.text;
|
|
1686
|
+
timestamp = parsed.timestamp || timestamp;
|
|
1651
1687
|
if (text)
|
|
1652
|
-
events.push({ type: 'message', agent
|
|
1688
|
+
events.push({ type: 'message', agent, timestamp, role, content: text });
|
|
1653
1689
|
continue;
|
|
1654
1690
|
}
|
|
1655
1691
|
if (!Array.isArray(blocks))
|
|
1656
1692
|
continue;
|
|
1657
1693
|
for (const block of blocks) {
|
|
1658
1694
|
if (block.type === 'text') {
|
|
1659
|
-
const
|
|
1660
|
-
|
|
1695
|
+
const parsed = agent === 'cursor' && role === 'user'
|
|
1696
|
+
? parseCursorUserText(block.text || '')
|
|
1697
|
+
: { text: (block.text || '').trim(), timestamp: undefined };
|
|
1698
|
+
const text = parsed.text;
|
|
1699
|
+
timestamp = parsed.timestamp || timestamp;
|
|
1661
1700
|
if (text && !(role === 'user' && text.startsWith('<system-reminder>'))) {
|
|
1662
|
-
events.push({ type: 'message', agent
|
|
1701
|
+
events.push({ type: 'message', agent, timestamp, role, content: text });
|
|
1663
1702
|
}
|
|
1664
1703
|
}
|
|
1665
1704
|
else if (block.type === 'thinking') {
|
|
1666
1705
|
const thinkingText = (block.thinking || '').trim();
|
|
1667
1706
|
if (thinkingText)
|
|
1668
|
-
events.push({ type: 'thinking', agent
|
|
1707
|
+
events.push({ type: 'thinking', agent, timestamp, content: thinkingText });
|
|
1669
1708
|
}
|
|
1670
1709
|
else if (block.type === 'tool_use') {
|
|
1671
1710
|
const toolName = block.name || 'unknown';
|
|
@@ -1674,38 +1713,29 @@ export function parseDroid(filePath) {
|
|
|
1674
1713
|
toolUseMap.set(block.id, { tool: toolName, args: toolInput });
|
|
1675
1714
|
events.push({
|
|
1676
1715
|
type: 'tool_use',
|
|
1677
|
-
agent
|
|
1716
|
+
agent,
|
|
1678
1717
|
timestamp,
|
|
1679
1718
|
tool: toolName,
|
|
1680
1719
|
args: toolInput,
|
|
1681
1720
|
path: toolInput.file_path || toolInput.path || undefined,
|
|
1682
|
-
command: (toolName === 'Bash' || toolName === 'Execute') ? toolInput.command : undefined,
|
|
1721
|
+
command: (toolName === 'Bash' || toolName === 'Execute' || toolName === 'Shell') ? toolInput.command : undefined,
|
|
1683
1722
|
});
|
|
1684
1723
|
}
|
|
1685
1724
|
else if (block.type === 'tool_result') {
|
|
1686
1725
|
const toolId = block.tool_use_id;
|
|
1687
1726
|
const toolInfo = toolId ? toolUseMap.get(toolId) : undefined;
|
|
1688
1727
|
const isError = block.is_error === true;
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
output = block.content
|
|
1695
|
-
.filter((c) => c.type === 'text')
|
|
1696
|
-
.map((c) => c.text || '')
|
|
1697
|
-
.join('\n');
|
|
1698
|
-
}
|
|
1728
|
+
const output = typeof block.content === 'string'
|
|
1729
|
+
? block.content
|
|
1730
|
+
: Array.isArray(block.content)
|
|
1731
|
+
? block.content.filter((c) => c.type === 'text').map((c) => c.text || '').join('\n')
|
|
1732
|
+
: '';
|
|
1699
1733
|
if (isError) {
|
|
1700
|
-
events.push({ type: 'error', agent
|
|
1734
|
+
events.push({ type: 'error', agent, timestamp, tool: toolInfo?.tool, content: output || 'Tool execution failed' });
|
|
1701
1735
|
}
|
|
1702
1736
|
else {
|
|
1703
1737
|
events.push({
|
|
1704
|
-
type: 'tool_result',
|
|
1705
|
-
agent: 'droid',
|
|
1706
|
-
timestamp,
|
|
1707
|
-
tool: toolInfo?.tool,
|
|
1708
|
-
success: true,
|
|
1738
|
+
type: 'tool_result', agent, timestamp, tool: toolInfo?.tool, success: true,
|
|
1709
1739
|
output: output.length > 500 ? output.slice(0, 497) + '...' : output,
|
|
1710
1740
|
});
|
|
1711
1741
|
}
|
|
@@ -1715,9 +1745,18 @@ export function parseDroid(filePath) {
|
|
|
1715
1745
|
else if (block.type === 'image') {
|
|
1716
1746
|
const source = block.source || {};
|
|
1717
1747
|
const sizeBytes = source.type === 'base64' ? Math.ceil((source.data?.length || 0) * 0.75) : 0;
|
|
1718
|
-
events.push(normalizedAttachmentEvent(
|
|
1748
|
+
events.push(normalizedAttachmentEvent(agent, timestamp, block, source, 'image/png', sizeBytes));
|
|
1719
1749
|
}
|
|
1720
1750
|
}
|
|
1721
1751
|
}
|
|
1722
1752
|
return events;
|
|
1723
1753
|
}
|
|
1754
|
+
/**
|
|
1755
|
+
* Parse a Droid (Factory) JSONL session file into normalized events. Droid
|
|
1756
|
+
* wraps each turn in a `{type:'message', message:{role, content, modelId}}`
|
|
1757
|
+
* envelope; the content blocks are Anthropic-shaped (text/thinking/tool_use/
|
|
1758
|
+
* tool_result), so block handling mirrors the Claude parser.
|
|
1759
|
+
*/
|
|
1760
|
+
export function parseDroid(filePath) {
|
|
1761
|
+
return parseAnthropicMessageJsonl(filePath, 'droid');
|
|
1762
|
+
}
|
|
@@ -20,6 +20,13 @@ export interface RemoteListResult {
|
|
|
20
20
|
sessions: SessionMeta[];
|
|
21
21
|
/** How many peer machines we attempted to reach (drives the empty-fleet tip). */
|
|
22
22
|
deviceCount: number;
|
|
23
|
+
/**
|
|
24
|
+
* Peers that failed to answer, by display name. The stderr note above is
|
|
25
|
+
* enough for a printed listing, but the interactive browser repaints over it —
|
|
26
|
+
* so callers rendering a full-screen UI need the outcome as data to tell
|
|
27
|
+
* "that box is asleep" apart from "that box has no matching sessions".
|
|
28
|
+
*/
|
|
29
|
+
unreachable: string[];
|
|
23
30
|
}
|
|
24
31
|
/**
|
|
25
32
|
* Gather listing sessions from other machines. With an explicit `hosts` list
|
|
@@ -97,9 +97,9 @@ async function fetchByTarget(target, machine, display, forwardedArgs, os) {
|
|
|
97
97
|
const { code, stdout } = await sshCapture(target, remoteListCommand(forwardedArgs, os), REMOTE_TIMEOUT_MS);
|
|
98
98
|
if (code !== 0) {
|
|
99
99
|
process.stderr.write(chalk.gray(` ${display}: unreachable or no agents CLI — skipped\n`));
|
|
100
|
-
return [];
|
|
100
|
+
return { sessions: [], unreachable: display };
|
|
101
101
|
}
|
|
102
|
-
return parseRemoteList(stdout, machine);
|
|
102
|
+
return { sessions: parseRemoteList(stdout, machine) };
|
|
103
103
|
}
|
|
104
104
|
/**
|
|
105
105
|
* Gather listing sessions from other machines. With an explicit `hosts` list
|
|
@@ -122,7 +122,7 @@ export async function gatherRemoteList(forwardedArgs, hosts) {
|
|
|
122
122
|
reg = await loadDevices();
|
|
123
123
|
}
|
|
124
124
|
catch {
|
|
125
|
-
return { sessions: [], deviceCount: 0 };
|
|
125
|
+
return { sessions: [], deviceCount: 0, unreachable: [] };
|
|
126
126
|
}
|
|
127
127
|
for (const d of Object.values(reg)) {
|
|
128
128
|
if (d.tailscale?.online !== true)
|
|
@@ -147,7 +147,11 @@ export async function gatherRemoteList(forwardedArgs, hosts) {
|
|
|
147
147
|
}
|
|
148
148
|
}
|
|
149
149
|
const results = await Promise.all(targets.map((t) => fetchByTarget(t.target, t.machine, t.name, forwardedArgs, t.os)));
|
|
150
|
-
return {
|
|
150
|
+
return {
|
|
151
|
+
sessions: results.flatMap((r) => r.sessions),
|
|
152
|
+
deviceCount: targets.length,
|
|
153
|
+
unreachable: results.map((r) => r.unreachable).filter((n) => !!n),
|
|
154
|
+
};
|
|
151
155
|
}
|
|
152
156
|
/** Resolve a peer's SSH target (and OS) from the device registry by its
|
|
153
157
|
* normalized machine id — the same id the fan-out tags rows with. Returns
|
|
@@ -201,12 +201,66 @@ const GH_PR_CREATE_RE = /\bgh\s+pr\s+(?:create|new)\b/;
|
|
|
201
201
|
const GH_ISSUE_CREATE_RE = /\bgh\s+issue\s+create\b/;
|
|
202
202
|
/** A created GitHub issue URL (…/issues/123) in tool-result output. */
|
|
203
203
|
const GH_ISSUE_URL_RE = /https:\/\/github\.com\/[^\s"'()<>]+\/issues\/(\d+)/;
|
|
204
|
+
/**
|
|
205
|
+
* Flags of `teams create` / `teams add` that take a value, so the value is not
|
|
206
|
+
* mistaken for the positional team name. Mirrors their value-taking flags in
|
|
207
|
+
* `commands/teams.ts` — most are `.option('… <x>')` registrations, but
|
|
208
|
+
* `--device`/`--host` come from `addHostOption`, so auditing this list against
|
|
209
|
+
* `.option(` alone would wrongly drop them. A flag missing here degrades to "no
|
|
210
|
+
* team detected", never to a wrong one.
|
|
211
|
+
*/
|
|
212
|
+
const TEAM_VALUE_FLAGS = [
|
|
213
|
+
'-d', '--description', '--use-worktree', '--devices', '--hosts', '--repo',
|
|
214
|
+
'-n', '--name', '-m', '--mode', '-e', '--effort', '--model', '--env',
|
|
215
|
+
'--cwd', '--worktree', '--after', '--task-type', '--cloud', '--branch',
|
|
216
|
+
'--device', '--host',
|
|
217
|
+
];
|
|
218
|
+
/**
|
|
219
|
+
* One flag value: a quoted string or a bare token. `-d "sessions lineage"` is the
|
|
220
|
+
* common shape — `--description` is usually a phrase — and a value pattern of
|
|
221
|
+
* `\S+` alone stops at the first space, leaving the rest of the phrase to be read
|
|
222
|
+
* as the positional team name (`… -d "sessions lineage" my-team` detected
|
|
223
|
+
* `lineage`). Quotes are matched as a unit so the whole value is consumed.
|
|
224
|
+
*
|
|
225
|
+
* A value containing an ESCAPED quote (`-d "say \"hi\" now"`) stops the quoted
|
|
226
|
+
* branch early and the match then fails outright — which is the intended failure
|
|
227
|
+
* direction: no team detected rather than a wrong one.
|
|
228
|
+
*/
|
|
229
|
+
const FLAG_VALUE = String.raw `(?:"[^"\n]*"|'[^'\n]*'|\S+)`;
|
|
204
230
|
/**
|
|
205
231
|
* `agents teams create <name>` / `agents teams add <team> …` (also the `ag` alias).
|
|
206
232
|
* The team NAME is the first bareword after the sub-verb, skipping any flags. This
|
|
207
233
|
* is the structural signal that a session SPAWNED a team (vs. was spawned by one).
|
|
234
|
+
*
|
|
235
|
+
* The separators are spaces/tabs, never `\s`: a command string routinely embeds
|
|
236
|
+
* documentation and quoted output, and `\s` let the flag-skip run across newlines
|
|
237
|
+
* to capture a word from a completely different line (a real scan produced
|
|
238
|
+
* `team:installed` from a heredoc). For the same reason the flag-skip is bounded
|
|
239
|
+
* rather than unlimited — a real invocation carries a handful of flags before the
|
|
240
|
+
* name, not dozens.
|
|
208
241
|
*/
|
|
209
|
-
const TEAMS_SPAWN_RE =
|
|
242
|
+
const TEAMS_SPAWN_RE = new RegExp(
|
|
243
|
+
// Start of an actually-executed command: string start, a newline, or a shell
|
|
244
|
+
// separator. Without this, a backticked mention inside prose or tool output
|
|
245
|
+
// ("… and `agents teams add --device auto`") reads as a spawn.
|
|
246
|
+
String.raw `(?:^|[\n;&|(]|&&|\|\|)[ \t]*` +
|
|
247
|
+
String.raw `ag(?:ents)?[ \t]+teams?[ \t]+(?:create|add)[ \t]+` +
|
|
248
|
+
// Flags before the positional name. A value-taking flag must swallow its
|
|
249
|
+
// value, or `--device auto` leaves `auto` looking like the team name — and
|
|
250
|
+
// the generic branch must exclude those flags, or it swallows the flag alone
|
|
251
|
+
// and hands the value back as the name.
|
|
252
|
+
String.raw `(?:(?:${TEAM_VALUE_FLAGS.join('|')})[= \t]${FLAG_VALUE}[ \t]+` +
|
|
253
|
+
String.raw `|(?!(?:${TEAM_VALUE_FLAGS.join('|')})[= \t])--?[a-z][\w-]*(?:=\S+)?[ \t]+){0,6}` +
|
|
254
|
+
// A team name may start with a digit — `createTeam` validates nothing, and
|
|
255
|
+
// `2fa-migration` is a legal name — so the class stays [A-Za-z0-9]. The
|
|
256
|
+
// all-digits case is rejected in the guard below instead.
|
|
257
|
+
String.raw `([A-Za-z0-9][\w-]*)`);
|
|
258
|
+
/**
|
|
259
|
+
* Sub-verbs that can follow `teams create|add` in prose ("teams add a teammate")
|
|
260
|
+
* but are never a team name. Guards the common case where the match came from a
|
|
261
|
+
* sentence rather than an executed command.
|
|
262
|
+
*/
|
|
263
|
+
const NON_TEAM_WORDS = new Set(['a', 'an', 'the', 'to', 'for', 'with', 'and', 'this', 'your', 'my', 'it']);
|
|
210
264
|
/** Collapse to a single trimmed line for a one-row preview cell. */
|
|
211
265
|
function oneLine(s) {
|
|
212
266
|
return s.replace(/\s+/g, ' ').trim();
|
|
@@ -259,7 +313,20 @@ export function detectSpawnedTeam(command) {
|
|
|
259
313
|
if (!command)
|
|
260
314
|
return undefined;
|
|
261
315
|
const m = command.match(TEAMS_SPAWN_RE);
|
|
262
|
-
|
|
316
|
+
if (!m)
|
|
317
|
+
return undefined;
|
|
318
|
+
const name = m[1];
|
|
319
|
+
// A single character is a doc placeholder (`agents teams create t --host <box>`)
|
|
320
|
+
// far more often than a real team, and an English article is prose. Both used to
|
|
321
|
+
// land in the index as a team name, and now that the name is rendered on the row
|
|
322
|
+
// a wrong one is worse than none.
|
|
323
|
+
// A single character is a doc placeholder (`agents teams create t --host <name>`)
|
|
324
|
+
// far more often than a real team; an all-digits token is a flag value or a list
|
|
325
|
+
// index that leaked through, never a name someone typed. Both had reached the
|
|
326
|
+
// index, and now that the name is rendered a wrong one is worse than none.
|
|
327
|
+
if (name.length < 2 || /^\d+$/.test(name) || NON_TEAM_WORDS.has(name.toLowerCase()))
|
|
328
|
+
return undefined;
|
|
329
|
+
return name;
|
|
263
330
|
}
|
|
264
331
|
/**
|
|
265
332
|
* True when a tool_use call CREATES a tracker ticket — a Linear MCP `create_issue`
|
|
Binary file
|
|
@@ -12,14 +12,33 @@ import type { SessionMeta, TeamOrigin } from './types.js';
|
|
|
12
12
|
*
|
|
13
13
|
* Primary signal is `session.isTeamOrigin`, captured at scan time from the
|
|
14
14
|
* JSONL `entrypoint` field ('sdk-cli' for team spawns, 'cli' for real CLI).
|
|
15
|
-
* When a team meta.json exists we additionally enrich with handle/mode
|
|
16
|
-
*
|
|
17
|
-
* was cleaned up still get recognized
|
|
15
|
+
* When a team meta.json exists we additionally enrich with handle/mode/team and
|
|
16
|
+
* the orchestrator that spawned it — but its absence no longer demotes a
|
|
17
|
+
* session: older team runs whose meta dir was cleaned up still get recognized
|
|
18
|
+
* via the entrypoint flag.
|
|
18
19
|
*
|
|
19
20
|
* Returns the TeamOrigin metadata when the session is team-origin, or null
|
|
20
21
|
* when it is a normal interactive session.
|
|
21
22
|
*/
|
|
22
23
|
export declare function classifyTeamSession(session: SessionMeta): TeamOrigin | null;
|
|
24
|
+
/**
|
|
25
|
+
* A team name / handle as it is safe to render: a real string, terminal escapes
|
|
26
|
+
* stripped, or undefined.
|
|
27
|
+
*
|
|
28
|
+
* These values reach the row and the preview pane, and for a peer's row they are
|
|
29
|
+
* whatever JSON that machine sent — `parseRemoteList` copies the object through
|
|
30
|
+
* without inspecting its fields, so neither the type nor the content is ours to
|
|
31
|
+
* assume. A non-string `spawnedTeam` used to throw out of `teamBadge`, which runs
|
|
32
|
+
* on every row, taking down the whole listing rather than one entry.
|
|
33
|
+
*/
|
|
34
|
+
export declare function safeTeamText(value: unknown): string | undefined;
|
|
35
|
+
/** Drop the cached teammate index — for tests that rewrite AGENTS_TEAMS_DIR. */
|
|
36
|
+
export declare function _resetTeamOriginIndex(): void;
|
|
37
|
+
/**
|
|
38
|
+
* Attach `teamOrigin` to every team-spawned row in `sessions`, from the shared
|
|
39
|
+
* teammate index rather than a stat per row.
|
|
40
|
+
*/
|
|
41
|
+
export declare function enrichTeamOrigins(sessions: SessionMeta[]): SessionMeta[];
|
|
23
42
|
/** Result of splitting sessions into visible and hidden (team-origin) groups. */
|
|
24
43
|
export interface FilterResult {
|
|
25
44
|
visible: SessionMeta[];
|