@phnx-labs/agents-cli 1.20.28 → 1.20.30
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/dist/commands/computer-actions.js +6 -2
- package/dist/commands/computer.d.ts +12 -0
- package/dist/commands/computer.js +88 -13
- package/dist/commands/exec.js +22 -10
- package/dist/commands/inspect.js +1 -1
- package/dist/commands/models.js +8 -2
- package/dist/commands/secrets.js +93 -6
- package/dist/commands/sessions.js +157 -44
- package/dist/commands/ssh.d.ts +14 -0
- package/dist/commands/ssh.js +263 -0
- package/dist/commands/sync.js +70 -14
- package/dist/index.js +2 -1
- package/dist/lib/agents.d.ts +0 -4
- package/dist/lib/agents.js +54 -5
- package/dist/lib/browser/drivers/ssh.js +4 -35
- package/dist/lib/computer-rpc.d.ts +6 -1
- package/dist/lib/computer-rpc.js +86 -3
- package/dist/lib/devices/connect.d.ts +34 -0
- package/dist/lib/devices/connect.js +101 -0
- package/dist/lib/devices/registry.d.ts +78 -0
- package/dist/lib/devices/registry.js +168 -0
- package/dist/lib/devices/ssh-config.d.ts +21 -0
- package/dist/lib/devices/ssh-config.js +33 -0
- package/dist/lib/devices/tailscale.d.ts +31 -0
- package/dist/lib/devices/tailscale.js +126 -0
- package/dist/lib/exec.js +14 -0
- package/dist/lib/models.js +138 -5
- package/dist/lib/runner.js +7 -7
- package/dist/lib/secrets/remote.d.ts +67 -0
- package/dist/lib/secrets/remote.js +133 -0
- package/dist/lib/session/active.d.ts +13 -0
- package/dist/lib/session/active.js +79 -18
- package/dist/lib/session/cloud.js +2 -0
- package/dist/lib/session/db.d.ts +12 -0
- package/dist/lib/session/db.js +66 -9
- package/dist/lib/session/discover.d.ts +7 -0
- package/dist/lib/session/discover.js +309 -0
- package/dist/lib/session/parse.d.ts +22 -0
- package/dist/lib/session/parse.js +132 -2
- package/dist/lib/session/remote.d.ts +1 -1
- package/dist/lib/session/remote.js +8 -3
- package/dist/lib/session/state.d.ts +82 -0
- package/dist/lib/session/state.js +221 -0
- package/dist/lib/session/tail.d.ts +18 -0
- package/dist/lib/session/tail.js +57 -0
- package/dist/lib/session/types.d.ts +10 -1
- package/dist/lib/session/types.js +1 -1
- package/dist/lib/session/width.d.ts +29 -0
- package/dist/lib/session/width.js +91 -0
- package/dist/lib/shims.d.ts +17 -1
- package/dist/lib/shims.js +130 -6
- package/dist/lib/ssh-tunnel.d.ts +127 -0
- package/dist/lib/ssh-tunnel.js +346 -0
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +3 -0
- package/dist/lib/state.d.ts +4 -0
- package/dist/lib/state.js +19 -1
- package/dist/lib/teams/agents.d.ts +11 -1
- package/dist/lib/teams/agents.js +16 -2
- package/dist/lib/types.d.ts +1 -0
- package/dist/lib/versions.d.ts +19 -0
- package/dist/lib/versions.js +84 -24
- package/package.json +1 -1
|
@@ -20,6 +20,7 @@ import { walkForFiles } from '../fs-walk.js';
|
|
|
20
20
|
import { getConfigSymlinkVersion } from '../shims.js';
|
|
21
21
|
import { SESSION_AGENTS } from './types.js';
|
|
22
22
|
import { extractSessionTopic } from './prompt.js';
|
|
23
|
+
import { extractPrUrl, detectWorktree, detectTicket, isPrCreateCommand } from './state.js';
|
|
23
24
|
import { costOfUsage } from '../pricing/index.js';
|
|
24
25
|
import { getDB, getScanStampByPath, getScanStampsForPaths, recordScans, syncLabels, syncTopics, upsertSessionsBatch, querySessions, countSessions, ftsSearch, tryClaimScan, releaseScan, } from './db.js';
|
|
25
26
|
const HOME = os.homedir();
|
|
@@ -34,6 +35,7 @@ const RUSH_SESSIONS_DIR = path.join(HOME, '.rush', 'sessions');
|
|
|
34
35
|
const HERMES_SESSIONS_DIR = path.join(HOME, '.hermes', 'sessions');
|
|
35
36
|
/** How long OpenClaw channel/cron snapshots stay valid before we re-shell-out. */
|
|
36
37
|
const OPENCLAW_TTL_MS = 60_000;
|
|
38
|
+
const ACTIVE_APPEND_RESCAN_DEBOUNCE_MS = 5_000;
|
|
37
39
|
let cachedOpenClawWorkspaces = null;
|
|
38
40
|
const cachedAgentVersions = new Map();
|
|
39
41
|
/**
|
|
@@ -63,6 +65,7 @@ export async function discoverSessions(options) {
|
|
|
63
65
|
case 'rush': return scanRushIncremental(onProgress);
|
|
64
66
|
case 'hermes': return scanHermesIncremental(onProgress);
|
|
65
67
|
case 'kimi': return scanKimiIncremental(onProgress);
|
|
68
|
+
case 'droid': return scanDroidIncremental(onProgress);
|
|
66
69
|
}
|
|
67
70
|
}));
|
|
68
71
|
}
|
|
@@ -164,10 +167,16 @@ export function searchContentIndex(sessions, query) {
|
|
|
164
167
|
/**
|
|
165
168
|
* For a list of files, stat each, compare to the DB ledger, and return only
|
|
166
169
|
* the ones that need rescanning. One bulk DB query for the whole list.
|
|
170
|
+
*
|
|
171
|
+
* Actively running agents append to their JSONL every few seconds. Without a
|
|
172
|
+
* small debounce, repeated `agents sessions` invocations stream-parse the same
|
|
173
|
+
* growing transcript over and over. The cached row is good enough for a few
|
|
174
|
+
* seconds; once writes settle or the debounce expires, the file is parsed once.
|
|
167
175
|
*/
|
|
168
176
|
function filterChangedFiles(filePaths) {
|
|
169
177
|
const ledger = getScanStampsForPaths(filePaths);
|
|
170
178
|
const out = [];
|
|
179
|
+
const now = Date.now();
|
|
171
180
|
for (const filePath of filePaths) {
|
|
172
181
|
const stat = safeStatSync(filePath);
|
|
173
182
|
if (!stat)
|
|
@@ -180,10 +189,22 @@ function filterChangedFiles(filePaths) {
|
|
|
180
189
|
if (prev && prev.fileMtimeMs === scan.fileMtimeMs && prev.fileSize === scan.fileSize) {
|
|
181
190
|
continue;
|
|
182
191
|
}
|
|
192
|
+
if (prev && shouldDeferRecentAppend(prev, scan, now)) {
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
183
195
|
out.push({ filePath, scan });
|
|
184
196
|
}
|
|
185
197
|
return out;
|
|
186
198
|
}
|
|
199
|
+
export function shouldDeferRecentAppend(prev, current, nowMs, debounceMs = ACTIVE_APPEND_RESCAN_DEBOUNCE_MS) {
|
|
200
|
+
if (prev.scannedAt === undefined)
|
|
201
|
+
return false;
|
|
202
|
+
if (current.fileSize <= prev.fileSize)
|
|
203
|
+
return false;
|
|
204
|
+
if (current.fileMtimeMs < prev.fileMtimeMs)
|
|
205
|
+
return false;
|
|
206
|
+
return nowMs - prev.scannedAt < debounceMs;
|
|
207
|
+
}
|
|
187
208
|
// ---------------------------------------------------------------------------
|
|
188
209
|
// Multi-version directory scanning
|
|
189
210
|
// ---------------------------------------------------------------------------
|
|
@@ -407,6 +428,10 @@ async function readClaudeMeta(filePath, sessionId, account, label) {
|
|
|
407
428
|
costUsd: scan.costUsd,
|
|
408
429
|
durationMs: scan.durationMs,
|
|
409
430
|
isTeamOrigin,
|
|
431
|
+
prUrl: scan.prUrl,
|
|
432
|
+
prNumber: scan.prNumber,
|
|
433
|
+
worktreeSlug: scan.worktreeSlug,
|
|
434
|
+
ticketId: scan.ticketId,
|
|
410
435
|
};
|
|
411
436
|
}
|
|
412
437
|
else {
|
|
@@ -425,6 +450,10 @@ async function readClaudeMeta(filePath, sessionId, account, label) {
|
|
|
425
450
|
durationMs: scan.durationMs,
|
|
426
451
|
topic: scan.topic,
|
|
427
452
|
isTeamOrigin,
|
|
453
|
+
prUrl: scan.prUrl,
|
|
454
|
+
prNumber: scan.prNumber,
|
|
455
|
+
worktreeSlug: scan.worktreeSlug,
|
|
456
|
+
ticketId: scan.ticketId,
|
|
428
457
|
};
|
|
429
458
|
}
|
|
430
459
|
return { meta, content: scan.contentText || '' };
|
|
@@ -590,6 +619,10 @@ async function readCodexMeta(filePath, account, currentVersion) {
|
|
|
590
619
|
costUsd: scan.costUsd,
|
|
591
620
|
durationMs: scan.durationMs,
|
|
592
621
|
account,
|
|
622
|
+
prUrl: scan.prUrl,
|
|
623
|
+
prNumber: scan.prNumber,
|
|
624
|
+
worktreeSlug: scan.worktreeSlug,
|
|
625
|
+
ticketId: scan.ticketId,
|
|
593
626
|
};
|
|
594
627
|
return { meta, content: scan.contentText || '' };
|
|
595
628
|
}
|
|
@@ -1291,6 +1324,214 @@ function extractHermesMessageText(content) {
|
|
|
1291
1324
|
.join('\n')
|
|
1292
1325
|
.trim();
|
|
1293
1326
|
}
|
|
1327
|
+
/**
|
|
1328
|
+
* Incrementally re-scan changed Droid (Factory) session files and upsert into
|
|
1329
|
+
* the DB. Droid writes one `<uuid>.jsonl` transcript plus a sibling
|
|
1330
|
+
* `<uuid>.settings.json` (model + token usage) under
|
|
1331
|
+
* `~/.factory/sessions/<encoded-cwd>/`.
|
|
1332
|
+
*/
|
|
1333
|
+
async function scanDroidIncremental(onProgress) {
|
|
1334
|
+
const currentVersion = await getCurrentAgentVersion('droid');
|
|
1335
|
+
const filePaths = [];
|
|
1336
|
+
for (const sessionsDir of getAgentSessionDirs('droid', 'sessions')) {
|
|
1337
|
+
// High limit: we only stat files here, parsing is gated by ledger match.
|
|
1338
|
+
for (const fp of walkForFiles(sessionsDir, '.jsonl', 100_000)) {
|
|
1339
|
+
filePaths.push(fp);
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
const changed = filterChangedFiles(filePaths);
|
|
1343
|
+
if (changed.length === 0)
|
|
1344
|
+
return;
|
|
1345
|
+
onProgress?.({ agent: 'droid', parsed: 0, total: changed.length });
|
|
1346
|
+
const entries = [];
|
|
1347
|
+
const touched = [];
|
|
1348
|
+
const seen = new Set();
|
|
1349
|
+
let parsed = 0;
|
|
1350
|
+
for (const { filePath, scan } of changed) {
|
|
1351
|
+
try {
|
|
1352
|
+
const result = await readDroidMeta(filePath, currentVersion);
|
|
1353
|
+
if (result && !seen.has(result.meta.id)) {
|
|
1354
|
+
seen.add(result.meta.id);
|
|
1355
|
+
entries.push({ meta: result.meta, content: result.content, scan });
|
|
1356
|
+
}
|
|
1357
|
+
else {
|
|
1358
|
+
touched.push({ filePath, scan });
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
catch {
|
|
1362
|
+
touched.push({ filePath, scan });
|
|
1363
|
+
}
|
|
1364
|
+
parsed++;
|
|
1365
|
+
onProgress?.({ agent: 'droid', parsed, total: changed.length });
|
|
1366
|
+
}
|
|
1367
|
+
upsertSessionsBatch(entries);
|
|
1368
|
+
recordScans(touched);
|
|
1369
|
+
}
|
|
1370
|
+
/** Stream-parse a single Droid JSONL file (+ sibling settings) into session metadata. */
|
|
1371
|
+
async function readDroidMeta(filePath, currentVersion) {
|
|
1372
|
+
const scan = await scanDroidSession(filePath);
|
|
1373
|
+
// The filename is the canonical session id; fall back to the session_start id.
|
|
1374
|
+
const sessionId = path.basename(filePath).replace(/\.jsonl$/, '') || scan.sessionId || '';
|
|
1375
|
+
if (!sessionId)
|
|
1376
|
+
return null;
|
|
1377
|
+
// Token usage and cost live only in the sibling `<uuid>.settings.json`.
|
|
1378
|
+
const settings = readDroidSettings(filePath.replace(/\.jsonl$/, '.settings.json'));
|
|
1379
|
+
const model = settings.model || scan.model;
|
|
1380
|
+
const tokenCount = settings.tokenCount;
|
|
1381
|
+
const costUsd = model && settings.usage
|
|
1382
|
+
? costOfUsage({
|
|
1383
|
+
model,
|
|
1384
|
+
inputTokens: settings.usage.inputTokens,
|
|
1385
|
+
outputTokens: settings.usage.outputTokens,
|
|
1386
|
+
cacheReadTokens: settings.usage.cacheReadTokens,
|
|
1387
|
+
cacheCreationTokens: settings.usage.cacheCreationTokens,
|
|
1388
|
+
})
|
|
1389
|
+
: 0;
|
|
1390
|
+
const stat = safeStatSync(filePath);
|
|
1391
|
+
const cwd = normalizeCwd(scan.cwd || '');
|
|
1392
|
+
const meta = {
|
|
1393
|
+
id: sessionId,
|
|
1394
|
+
shortId: sessionId.slice(0, 8),
|
|
1395
|
+
agent: 'droid',
|
|
1396
|
+
timestamp: scan.timestamp || (stat ? stat.mtime.toISOString() : new Date().toISOString()),
|
|
1397
|
+
project: cwd ? path.basename(cwd) : undefined,
|
|
1398
|
+
cwd,
|
|
1399
|
+
filePath,
|
|
1400
|
+
version: resolveSessionVersion('droid', filePath, undefined, currentVersion),
|
|
1401
|
+
topic: scan.topic,
|
|
1402
|
+
messageCount: scan.messageCount,
|
|
1403
|
+
tokenCount,
|
|
1404
|
+
costUsd: costUsd > 0 ? costUsd : undefined,
|
|
1405
|
+
durationMs: scan.durationMs,
|
|
1406
|
+
};
|
|
1407
|
+
return { meta, content: scan.contentText || '' };
|
|
1408
|
+
}
|
|
1409
|
+
/** Read model + token usage from a Droid `<uuid>.settings.json` sidecar. */
|
|
1410
|
+
function readDroidSettings(settingsPath) {
|
|
1411
|
+
try {
|
|
1412
|
+
const data = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
|
|
1413
|
+
const model = typeof data.model === 'string' ? data.model : undefined;
|
|
1414
|
+
const u = data.tokenUsage;
|
|
1415
|
+
if (!u || typeof u !== 'object')
|
|
1416
|
+
return { model };
|
|
1417
|
+
const usage = {
|
|
1418
|
+
inputTokens: u.inputTokens,
|
|
1419
|
+
outputTokens: u.outputTokens,
|
|
1420
|
+
cacheReadTokens: u.cacheReadTokens,
|
|
1421
|
+
cacheCreationTokens: u.cacheCreationTokens,
|
|
1422
|
+
};
|
|
1423
|
+
const tokenCount = sumKnownNumbers([
|
|
1424
|
+
u.inputTokens,
|
|
1425
|
+
u.outputTokens,
|
|
1426
|
+
u.cacheCreationTokens,
|
|
1427
|
+
u.cacheReadTokens,
|
|
1428
|
+
]) ?? undefined;
|
|
1429
|
+
return { model, tokenCount, usage };
|
|
1430
|
+
}
|
|
1431
|
+
catch {
|
|
1432
|
+
return {};
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
/** Stream a Droid JSONL file and extract scan-level metadata (id, cwd, topic, model, duration). */
|
|
1436
|
+
async function scanDroidSession(filePath) {
|
|
1437
|
+
const stream = fs.createReadStream(filePath, { encoding: 'utf-8' });
|
|
1438
|
+
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
1439
|
+
let sessionId;
|
|
1440
|
+
let timestamp;
|
|
1441
|
+
let cwd;
|
|
1442
|
+
let title;
|
|
1443
|
+
let sessionTitle;
|
|
1444
|
+
let firstUserTopic;
|
|
1445
|
+
let model;
|
|
1446
|
+
let messageCount = 0;
|
|
1447
|
+
let firstTsMs;
|
|
1448
|
+
let lastTsMs;
|
|
1449
|
+
const userTexts = [];
|
|
1450
|
+
try {
|
|
1451
|
+
for await (const line of rl) {
|
|
1452
|
+
if (!line.trim())
|
|
1453
|
+
continue;
|
|
1454
|
+
let parsed;
|
|
1455
|
+
try {
|
|
1456
|
+
parsed = JSON.parse(line);
|
|
1457
|
+
}
|
|
1458
|
+
catch {
|
|
1459
|
+
continue;
|
|
1460
|
+
}
|
|
1461
|
+
if (parsed.type === 'session_start') {
|
|
1462
|
+
sessionId = typeof parsed.id === 'string' ? parsed.id : sessionId;
|
|
1463
|
+
cwd = typeof parsed.cwd === 'string' ? parsed.cwd : cwd;
|
|
1464
|
+
// Droid auto-generates `sessionTitle`; `title` is the raw first prompt.
|
|
1465
|
+
if (typeof parsed.sessionTitle === 'string' && parsed.sessionTitle.trim()) {
|
|
1466
|
+
sessionTitle = parsed.sessionTitle.trim();
|
|
1467
|
+
}
|
|
1468
|
+
if (typeof parsed.title === 'string' && parsed.title.trim()) {
|
|
1469
|
+
title = parsed.title.trim();
|
|
1470
|
+
}
|
|
1471
|
+
continue;
|
|
1472
|
+
}
|
|
1473
|
+
if (parsed.type !== 'message')
|
|
1474
|
+
continue;
|
|
1475
|
+
// Track duration across every timestamped message.
|
|
1476
|
+
if (typeof parsed.timestamp === 'string') {
|
|
1477
|
+
const ms = new Date(parsed.timestamp).getTime();
|
|
1478
|
+
if (!Number.isNaN(ms)) {
|
|
1479
|
+
if (firstTsMs === undefined || ms < firstTsMs)
|
|
1480
|
+
firstTsMs = ms;
|
|
1481
|
+
if (lastTsMs === undefined || ms > lastTsMs)
|
|
1482
|
+
lastTsMs = ms;
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
if (!timestamp && typeof parsed.timestamp === 'string')
|
|
1486
|
+
timestamp = parsed.timestamp;
|
|
1487
|
+
const msg = parsed.message || {};
|
|
1488
|
+
if (typeof msg.modelId === 'string')
|
|
1489
|
+
model = msg.modelId;
|
|
1490
|
+
const text = extractDroidMessageText(msg.content);
|
|
1491
|
+
if (!text)
|
|
1492
|
+
continue;
|
|
1493
|
+
messageCount++;
|
|
1494
|
+
if (msg.role === 'user') {
|
|
1495
|
+
userTexts.push(text);
|
|
1496
|
+
if (!firstUserTopic)
|
|
1497
|
+
firstUserTopic = extractSessionTopic(text);
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
finally {
|
|
1502
|
+
rl.close();
|
|
1503
|
+
stream.destroy();
|
|
1504
|
+
}
|
|
1505
|
+
const durationMs = firstTsMs !== undefined && lastTsMs !== undefined && lastTsMs > firstTsMs
|
|
1506
|
+
? lastTsMs - firstTsMs
|
|
1507
|
+
: undefined;
|
|
1508
|
+
return {
|
|
1509
|
+
sessionId,
|
|
1510
|
+
timestamp,
|
|
1511
|
+
cwd,
|
|
1512
|
+
// Prefer Droid's auto-title, then the raw first-prompt title, then the
|
|
1513
|
+
// derived first-user-message topic.
|
|
1514
|
+
topic: sessionTitle || title || firstUserTopic,
|
|
1515
|
+
model,
|
|
1516
|
+
messageCount,
|
|
1517
|
+
durationMs,
|
|
1518
|
+
contentText: userTexts.length > 0 ? userTexts.join('\n') : undefined,
|
|
1519
|
+
};
|
|
1520
|
+
}
|
|
1521
|
+
/** Extract plain text from a Droid message content field (Anthropic-shaped blocks). */
|
|
1522
|
+
function extractDroidMessageText(content) {
|
|
1523
|
+
if (typeof content === 'string')
|
|
1524
|
+
return content.trim();
|
|
1525
|
+
if (!Array.isArray(content))
|
|
1526
|
+
return '';
|
|
1527
|
+
return content
|
|
1528
|
+
.map((part) => (typeof part?.text === 'string' && part.type === 'text' ? part.text : ''))
|
|
1529
|
+
// Droid front-loads injected context (date, skills list) as <system-reminder>
|
|
1530
|
+
// text blocks on the first user turn — drop them so topic/content stay clean.
|
|
1531
|
+
.filter((text) => text.trim() && !text.trim().startsWith('<system-reminder>'))
|
|
1532
|
+
.join('\n')
|
|
1533
|
+
.trim();
|
|
1534
|
+
}
|
|
1294
1535
|
/** Stream a Claude JSONL file and extract scan-level metadata (timestamp, cwd, topic, tokens). */
|
|
1295
1536
|
export async function scanClaudeSession(filePath) {
|
|
1296
1537
|
const stream = fs.createReadStream(filePath, { encoding: 'utf-8' });
|
|
@@ -1315,6 +1556,12 @@ export async function scanClaudeSession(filePath) {
|
|
|
1315
1556
|
let lastTsMs;
|
|
1316
1557
|
const seenAssistantIds = new Set();
|
|
1317
1558
|
const userTexts = [];
|
|
1559
|
+
// Durable PR signal: set only when an actual `gh pr create` Bash *command*
|
|
1560
|
+
// runs (structural — the command field, not any prose mentioning it), then
|
|
1561
|
+
// capture the pull URL from a later tool_result's output.
|
|
1562
|
+
let sawPrCreate = false;
|
|
1563
|
+
let prUrl;
|
|
1564
|
+
let prNumber;
|
|
1318
1565
|
try {
|
|
1319
1566
|
for await (const line of rl) {
|
|
1320
1567
|
if (!line.trim())
|
|
@@ -1331,6 +1578,31 @@ export async function scanClaudeSession(filePath) {
|
|
|
1331
1578
|
if (!entrypoint && typeof parsed.entrypoint === 'string') {
|
|
1332
1579
|
entrypoint = parsed.entrypoint;
|
|
1333
1580
|
}
|
|
1581
|
+
// PR signal, structurally: a Bash tool_use whose command is `gh pr create`
|
|
1582
|
+
// marks intent; the pull URL is then read from a tool_result's output.
|
|
1583
|
+
if (!prUrl) {
|
|
1584
|
+
if (!sawPrCreate && parsed.type === 'assistant' && Array.isArray(parsed.message?.content)) {
|
|
1585
|
+
for (const b of parsed.message.content) {
|
|
1586
|
+
if (b?.type === 'tool_use' && typeof b?.input?.command === 'string' && isPrCreateCommand(b.input.command)) {
|
|
1587
|
+
sawPrCreate = true;
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
if (sawPrCreate && parsed.type === 'user' && Array.isArray(parsed.message?.content)) {
|
|
1592
|
+
for (const b of parsed.message.content) {
|
|
1593
|
+
if (b?.type !== 'tool_result')
|
|
1594
|
+
continue;
|
|
1595
|
+
const text = typeof b.content === 'string'
|
|
1596
|
+
? b.content
|
|
1597
|
+
: Array.isArray(b.content) ? b.content.map((c) => c?.text || '').join('\n') : '';
|
|
1598
|
+
const pr = extractPrUrl(text);
|
|
1599
|
+
if (pr) {
|
|
1600
|
+
prUrl = pr.url;
|
|
1601
|
+
prNumber = pr.number;
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1334
1606
|
// Track duration across every timestamped event, not just the first.
|
|
1335
1607
|
if (typeof parsed.timestamp === 'string') {
|
|
1336
1608
|
const ms = new Date(parsed.timestamp).getTime();
|
|
@@ -1415,6 +1687,8 @@ export async function scanClaudeSession(filePath) {
|
|
|
1415
1687
|
// Prefer an explicit session title (user `/rename` > Claude auto-title) over
|
|
1416
1688
|
// the first-prompt topic.
|
|
1417
1689
|
const resolvedTopic = customTitle || aiTitle || topic;
|
|
1690
|
+
const worktree = detectWorktree(cwd, gitBranch);
|
|
1691
|
+
const ticket = detectTicket(userTexts.join('\n') || undefined, gitBranch);
|
|
1418
1692
|
return {
|
|
1419
1693
|
timestamp,
|
|
1420
1694
|
cwd,
|
|
@@ -1427,6 +1701,10 @@ export async function scanClaudeSession(filePath) {
|
|
|
1427
1701
|
costUsd: sawCost ? costUsd : undefined,
|
|
1428
1702
|
durationMs,
|
|
1429
1703
|
contentText: userTexts.length > 0 ? userTexts.join('\n') : undefined,
|
|
1704
|
+
prUrl,
|
|
1705
|
+
prNumber,
|
|
1706
|
+
worktreeSlug: worktree?.slug,
|
|
1707
|
+
ticketId: ticket?.id,
|
|
1430
1708
|
};
|
|
1431
1709
|
}
|
|
1432
1710
|
/** Stream a Codex JSONL file and extract scan-level metadata (session ID, cwd, topic, tokens). */
|
|
@@ -1446,6 +1724,9 @@ async function scanCodexSession(filePath) {
|
|
|
1446
1724
|
let firstTsMs;
|
|
1447
1725
|
let lastTsMs;
|
|
1448
1726
|
const userTexts = [];
|
|
1727
|
+
let sawPrCreate = false;
|
|
1728
|
+
let prUrl;
|
|
1729
|
+
let prNumber;
|
|
1449
1730
|
try {
|
|
1450
1731
|
for await (const line of rl) {
|
|
1451
1732
|
if (!line.trim())
|
|
@@ -1457,6 +1738,28 @@ async function scanCodexSession(filePath) {
|
|
|
1457
1738
|
catch {
|
|
1458
1739
|
continue;
|
|
1459
1740
|
}
|
|
1741
|
+
// PR signal, structurally: a Codex `function_call` whose command is
|
|
1742
|
+
// `gh pr create`, then the pull URL from a `function_call_output`.
|
|
1743
|
+
if (!prUrl && parsed.type === 'response_item') {
|
|
1744
|
+
const p = parsed.payload || {};
|
|
1745
|
+
if (!sawPrCreate && p.type === 'function_call') {
|
|
1746
|
+
let cmd = '';
|
|
1747
|
+
try {
|
|
1748
|
+
const args = typeof p.arguments === 'string' ? JSON.parse(p.arguments) : (p.arguments || {});
|
|
1749
|
+
cmd = String(args.command || args.cmd || '');
|
|
1750
|
+
}
|
|
1751
|
+
catch { /* non-JSON args */ }
|
|
1752
|
+
if (isPrCreateCommand(cmd))
|
|
1753
|
+
sawPrCreate = true;
|
|
1754
|
+
}
|
|
1755
|
+
if (sawPrCreate && p.type === 'function_call_output') {
|
|
1756
|
+
const pr = extractPrUrl(String(p.output || ''));
|
|
1757
|
+
if (pr) {
|
|
1758
|
+
prUrl = pr.url;
|
|
1759
|
+
prNumber = pr.number;
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
1762
|
+
}
|
|
1460
1763
|
// Track duration across every timestamped event.
|
|
1461
1764
|
if (typeof parsed.timestamp === 'string') {
|
|
1462
1765
|
const ms = new Date(parsed.timestamp).getTime();
|
|
@@ -1527,6 +1830,8 @@ async function scanCodexSession(filePath) {
|
|
|
1527
1830
|
const durationMs = firstTsMs !== undefined && lastTsMs !== undefined && lastTsMs > firstTsMs
|
|
1528
1831
|
? lastTsMs - firstTsMs
|
|
1529
1832
|
: undefined;
|
|
1833
|
+
const worktree = detectWorktree(cwd, gitBranch);
|
|
1834
|
+
const ticket = detectTicket(userTexts.join('\n') || undefined, gitBranch);
|
|
1530
1835
|
return {
|
|
1531
1836
|
sessionId,
|
|
1532
1837
|
timestamp,
|
|
@@ -1539,6 +1844,10 @@ async function scanCodexSession(filePath) {
|
|
|
1539
1844
|
costUsd,
|
|
1540
1845
|
durationMs,
|
|
1541
1846
|
contentText: userTexts.length > 0 ? userTexts.join('\n') : undefined,
|
|
1847
|
+
prUrl,
|
|
1848
|
+
prNumber,
|
|
1849
|
+
worktreeSlug: worktree?.slug,
|
|
1850
|
+
ticketId: ticket?.id,
|
|
1542
1851
|
};
|
|
1543
1852
|
}
|
|
1544
1853
|
/** Resolve the working directory for an OpenClaw agent from its workspace config. */
|
|
@@ -13,6 +13,8 @@ import type { SessionAgentId, SessionEvent } from './types.js';
|
|
|
13
13
|
*/
|
|
14
14
|
export declare const SESSION_FILE_MAX_BYTES = 200000000;
|
|
15
15
|
export declare function sanitizeForTerminal(s: string): string;
|
|
16
|
+
/** In-place sanitize every user-visible string field on a list of events. */
|
|
17
|
+
export declare function sanitizeEvents(events: SessionEvent[]): void;
|
|
16
18
|
/**
|
|
17
19
|
* Read a session file, refusing files above maxBytes. Bounded read protects
|
|
18
20
|
* against multi-GB session blobs that would OOM the CLI or exceed V8's
|
|
@@ -31,8 +33,21 @@ export declare function detectAgent(filePath: string): SessionAgentId | null;
|
|
|
31
33
|
export declare function summarizeToolUse(tool: string, args?: Record<string, any>): string;
|
|
32
34
|
/** Parse a Claude JSONL session file into normalized events. */
|
|
33
35
|
export declare function parseClaude(filePath: string): SessionEvent[];
|
|
36
|
+
/**
|
|
37
|
+
* Parse Claude JSONL *content* (already read into a string) into normalized
|
|
38
|
+
* events. Split from `parseClaude` so the tail reader can parse just the last
|
|
39
|
+
* chunk of a file without re-reading the whole thing. Malformed leading lines
|
|
40
|
+
* (a tail that starts mid-line) are skipped by the per-line try/catch below.
|
|
41
|
+
*/
|
|
42
|
+
export declare function parseClaudeContent(content: string): SessionEvent[];
|
|
34
43
|
/** Parse a Codex JSONL session file into normalized events. */
|
|
35
44
|
export declare function parseCodex(filePath: string): SessionEvent[];
|
|
45
|
+
/**
|
|
46
|
+
* Parse Codex JSONL *content* (already read into a string) into normalized
|
|
47
|
+
* events. Split from `parseCodex` so the tail reader can parse just the last
|
|
48
|
+
* chunk without re-reading the whole file.
|
|
49
|
+
*/
|
|
50
|
+
export declare function parseCodexContent(content: string): SessionEvent[];
|
|
36
51
|
/** Parse a Gemini JSON session file into normalized events. */
|
|
37
52
|
export declare function parseGemini(filePath: string): SessionEvent[];
|
|
38
53
|
/**
|
|
@@ -51,3 +66,10 @@ export declare function parseRush(filePath: string): SessionEvent[];
|
|
|
51
66
|
export declare function parseHermes(filePath: string): SessionEvent[];
|
|
52
67
|
/** Parse a Kimi session state.json file by reading its agents/main/wire.jsonl. */
|
|
53
68
|
export declare function parseKimi(filePath: string): SessionEvent[];
|
|
69
|
+
/**
|
|
70
|
+
* Parse a Droid (Factory) JSONL session file into normalized events. Droid
|
|
71
|
+
* wraps each turn in a `{type:'message', message:{role, content, modelId}}`
|
|
72
|
+
* envelope; the content blocks are Anthropic-shaped (text/thinking/tool_use/
|
|
73
|
+
* tool_result), so block handling mirrors the Claude parser.
|
|
74
|
+
*/
|
|
75
|
+
export declare function parseDroid(filePath: string): SessionEvent[];
|
|
@@ -41,6 +41,11 @@ function sanitizeArgsDeep(value) {
|
|
|
41
41
|
}
|
|
42
42
|
return value;
|
|
43
43
|
}
|
|
44
|
+
/** In-place sanitize every user-visible string field on a list of events. */
|
|
45
|
+
export function sanitizeEvents(events) {
|
|
46
|
+
for (const e of events)
|
|
47
|
+
sanitizeEvent(e);
|
|
48
|
+
}
|
|
44
49
|
/** In-place sanitize all user-visible string fields on an event. */
|
|
45
50
|
function sanitizeEvent(e) {
|
|
46
51
|
if (e.content)
|
|
@@ -109,6 +114,9 @@ export function parseSession(filePath, agent) {
|
|
|
109
114
|
case 'kimi':
|
|
110
115
|
events = parseKimi(filePath);
|
|
111
116
|
break;
|
|
117
|
+
case 'droid':
|
|
118
|
+
events = parseDroid(filePath);
|
|
119
|
+
break;
|
|
112
120
|
}
|
|
113
121
|
// Chokepoint: every string field that originated in an untrusted session
|
|
114
122
|
// file gets stripped of terminal escapes here, so renderers downstream can
|
|
@@ -133,6 +141,8 @@ export function detectAgent(filePath) {
|
|
|
133
141
|
return 'hermes';
|
|
134
142
|
if (filePath.includes('/.kimi-code/') || filePath.includes('\\.kimi-code\\'))
|
|
135
143
|
return 'kimi';
|
|
144
|
+
if (filePath.includes('/.factory/') || filePath.includes('\\.factory\\'))
|
|
145
|
+
return 'droid';
|
|
136
146
|
// Cloud convention: cloud-sessions/<id>/session.<format>.jsonl
|
|
137
147
|
const cloudMatch = filePath.match(/session\.(claude|codex|rush)\.jsonl(?:$|[?#])/);
|
|
138
148
|
if (cloudMatch)
|
|
@@ -209,7 +219,15 @@ function shortenPath(p) {
|
|
|
209
219
|
// ---------------------------------------------------------------------------
|
|
210
220
|
/** Parse a Claude JSONL session file into normalized events. */
|
|
211
221
|
export function parseClaude(filePath) {
|
|
212
|
-
|
|
222
|
+
return parseClaudeContent(safeReadSessionFile(filePath));
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Parse Claude JSONL *content* (already read into a string) into normalized
|
|
226
|
+
* events. Split from `parseClaude` so the tail reader can parse just the last
|
|
227
|
+
* chunk of a file without re-reading the whole thing. Malformed leading lines
|
|
228
|
+
* (a tail that starts mid-line) are skipped by the per-line try/catch below.
|
|
229
|
+
*/
|
|
230
|
+
export function parseClaudeContent(content) {
|
|
213
231
|
const lines = content.split('\n').filter(l => l.trim());
|
|
214
232
|
const events = [];
|
|
215
233
|
// Map tool_use id -> {tool, args} for correlating with tool_result
|
|
@@ -398,7 +416,14 @@ export function parseClaude(filePath) {
|
|
|
398
416
|
// ---------------------------------------------------------------------------
|
|
399
417
|
/** Parse a Codex JSONL session file into normalized events. */
|
|
400
418
|
export function parseCodex(filePath) {
|
|
401
|
-
|
|
419
|
+
return parseCodexContent(safeReadSessionFile(filePath));
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* Parse Codex JSONL *content* (already read into a string) into normalized
|
|
423
|
+
* events. Split from `parseCodex` so the tail reader can parse just the last
|
|
424
|
+
* chunk without re-reading the whole file.
|
|
425
|
+
*/
|
|
426
|
+
export function parseCodexContent(content) {
|
|
402
427
|
const lines = content.split('\n').filter(l => l.trim());
|
|
403
428
|
const events = [];
|
|
404
429
|
// Track function_call id -> name for correlating with function_call_output
|
|
@@ -1124,3 +1149,108 @@ export function parseKimi(filePath) {
|
|
|
1124
1149
|
}
|
|
1125
1150
|
return events;
|
|
1126
1151
|
}
|
|
1152
|
+
// ---------------------------------------------------------------------------
|
|
1153
|
+
// Droid (Factory) parser
|
|
1154
|
+
// ---------------------------------------------------------------------------
|
|
1155
|
+
/**
|
|
1156
|
+
* Parse a Droid (Factory) JSONL session file into normalized events. Droid
|
|
1157
|
+
* wraps each turn in a `{type:'message', message:{role, content, modelId}}`
|
|
1158
|
+
* envelope; the content blocks are Anthropic-shaped (text/thinking/tool_use/
|
|
1159
|
+
* tool_result), so block handling mirrors the Claude parser.
|
|
1160
|
+
*/
|
|
1161
|
+
export function parseDroid(filePath) {
|
|
1162
|
+
const content = safeReadSessionFile(filePath);
|
|
1163
|
+
const lines = content.split('\n').filter(l => l.trim());
|
|
1164
|
+
const events = [];
|
|
1165
|
+
// Map tool_use id -> {tool, args} for correlating with tool_result.
|
|
1166
|
+
const toolUseMap = new Map();
|
|
1167
|
+
for (const line of lines) {
|
|
1168
|
+
let raw;
|
|
1169
|
+
try {
|
|
1170
|
+
raw = JSON.parse(line);
|
|
1171
|
+
}
|
|
1172
|
+
catch {
|
|
1173
|
+
continue;
|
|
1174
|
+
}
|
|
1175
|
+
if (raw.type !== 'message')
|
|
1176
|
+
continue;
|
|
1177
|
+
const message = raw.message || {};
|
|
1178
|
+
const role = message.role === 'user' ? 'user' : 'assistant';
|
|
1179
|
+
const timestamp = raw.timestamp || new Date().toISOString();
|
|
1180
|
+
const blocks = message.content;
|
|
1181
|
+
// Plain-string content (rare) renders as a single message.
|
|
1182
|
+
if (typeof blocks === 'string') {
|
|
1183
|
+
const text = blocks.trim();
|
|
1184
|
+
if (text)
|
|
1185
|
+
events.push({ type: 'message', agent: 'droid', timestamp, role, content: text });
|
|
1186
|
+
continue;
|
|
1187
|
+
}
|
|
1188
|
+
if (!Array.isArray(blocks))
|
|
1189
|
+
continue;
|
|
1190
|
+
for (const block of blocks) {
|
|
1191
|
+
if (block.type === 'text') {
|
|
1192
|
+
const text = (block.text || '').trim();
|
|
1193
|
+
// Skip injected context blocks (date, skills list) on the first user turn.
|
|
1194
|
+
if (text && !(role === 'user' && text.startsWith('<system-reminder>'))) {
|
|
1195
|
+
events.push({ type: 'message', agent: 'droid', timestamp, role, content: text });
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
else if (block.type === 'thinking') {
|
|
1199
|
+
const thinkingText = (block.thinking || '').trim();
|
|
1200
|
+
if (thinkingText)
|
|
1201
|
+
events.push({ type: 'thinking', agent: 'droid', timestamp, content: thinkingText });
|
|
1202
|
+
}
|
|
1203
|
+
else if (block.type === 'tool_use') {
|
|
1204
|
+
const toolName = block.name || 'unknown';
|
|
1205
|
+
const toolInput = block.input || {};
|
|
1206
|
+
if (block.id)
|
|
1207
|
+
toolUseMap.set(block.id, { tool: toolName, args: toolInput });
|
|
1208
|
+
events.push({
|
|
1209
|
+
type: 'tool_use',
|
|
1210
|
+
agent: 'droid',
|
|
1211
|
+
timestamp,
|
|
1212
|
+
tool: toolName,
|
|
1213
|
+
args: toolInput,
|
|
1214
|
+
path: toolInput.file_path || toolInput.path || undefined,
|
|
1215
|
+
command: toolName === 'Bash' ? toolInput.command : undefined,
|
|
1216
|
+
});
|
|
1217
|
+
}
|
|
1218
|
+
else if (block.type === 'tool_result') {
|
|
1219
|
+
const toolId = block.tool_use_id;
|
|
1220
|
+
const toolInfo = toolId ? toolUseMap.get(toolId) : undefined;
|
|
1221
|
+
const isError = block.is_error === true;
|
|
1222
|
+
let output = '';
|
|
1223
|
+
if (typeof block.content === 'string') {
|
|
1224
|
+
output = block.content;
|
|
1225
|
+
}
|
|
1226
|
+
else if (Array.isArray(block.content)) {
|
|
1227
|
+
output = block.content
|
|
1228
|
+
.filter((c) => c.type === 'text')
|
|
1229
|
+
.map((c) => c.text || '')
|
|
1230
|
+
.join('\n');
|
|
1231
|
+
}
|
|
1232
|
+
if (isError) {
|
|
1233
|
+
events.push({ type: 'error', agent: 'droid', timestamp, tool: toolInfo?.tool, content: output || 'Tool execution failed' });
|
|
1234
|
+
}
|
|
1235
|
+
else {
|
|
1236
|
+
events.push({
|
|
1237
|
+
type: 'tool_result',
|
|
1238
|
+
agent: 'droid',
|
|
1239
|
+
timestamp,
|
|
1240
|
+
tool: toolInfo?.tool,
|
|
1241
|
+
success: true,
|
|
1242
|
+
output: output.length > 500 ? output.slice(0, 497) + '...' : output,
|
|
1243
|
+
});
|
|
1244
|
+
}
|
|
1245
|
+
if (toolId)
|
|
1246
|
+
toolUseMap.delete(toolId);
|
|
1247
|
+
}
|
|
1248
|
+
else if (block.type === 'image') {
|
|
1249
|
+
const source = block.source || {};
|
|
1250
|
+
const sizeBytes = source.type === 'base64' ? Math.ceil((source.data?.length || 0) * 0.75) : 0;
|
|
1251
|
+
events.push({ type: 'attachment', agent: 'droid', timestamp, mediaType: source.media_type || 'image/png', sizeBytes });
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
return events;
|
|
1256
|
+
}
|
|
@@ -23,7 +23,7 @@ export declare function buildForwardedArgs(argv: string[], hosts?: Set<string>):
|
|
|
23
23
|
* are quoted for the inner login shell, then the whole `agents …` invocation is
|
|
24
24
|
* quoted again so it survives `bash -lc <...>`.
|
|
25
25
|
*/
|
|
26
|
-
export declare function buildRemoteCommand(forwardedArgs: string[]): string;
|
|
26
|
+
export declare function buildRemoteCommand(forwardedArgs: string[], columns?: number): string;
|
|
27
27
|
/** The four outcomes of one `ssh <host> agents sessions …` invocation. */
|
|
28
28
|
export type SshOutcome = 'ok' | 'unreachable' | 'query-failed' | 'spawn-error';
|
|
29
29
|
/**
|
|
@@ -28,6 +28,7 @@ import { createHash } from 'crypto';
|
|
|
28
28
|
import chalk from 'chalk';
|
|
29
29
|
import { getCacheDir } from '../state.js';
|
|
30
30
|
import { formatRelativeTime } from './relative-time.js';
|
|
31
|
+
import { terminalWidth } from './width.js';
|
|
31
32
|
/**
|
|
32
33
|
* SSH target: a bare ssh-config host alias (e.g. `yosemite-s1`) or `user@host`.
|
|
33
34
|
* The strict allowlist blocks shell metacharacters and a leading `-`, so a target
|
|
@@ -87,9 +88,13 @@ export function buildForwardedArgs(argv, hosts = new Set()) {
|
|
|
87
88
|
* are quoted for the inner login shell, then the whole `agents …` invocation is
|
|
88
89
|
* quoted again so it survives `bash -lc <...>`.
|
|
89
90
|
*/
|
|
90
|
-
export function buildRemoteCommand(forwardedArgs) {
|
|
91
|
+
export function buildRemoteCommand(forwardedArgs, columns) {
|
|
91
92
|
const inner = ['agents', ...forwardedArgs].map(shellQuote).join(' ');
|
|
92
|
-
|
|
93
|
+
// Forward the caller's terminal width so the remote renders the table to the
|
|
94
|
+
// local screen (over SSH the remote's own COLUMNS is unset/wrong). `VAR=val
|
|
95
|
+
// cmd` scopes the env to that process — the remote's terminalWidth() reads it.
|
|
96
|
+
const withCols = columns && columns > 0 ? `COLUMNS=${columns} ${inner}` : inner;
|
|
97
|
+
return `bash -lc ${shellQuote(withCols)}`;
|
|
93
98
|
}
|
|
94
99
|
const SSH_OPTS = [
|
|
95
100
|
'-o', 'BatchMode=yes',
|
|
@@ -175,7 +180,7 @@ export function runRemoteSessions(hosts, argv = process.argv) {
|
|
|
175
180
|
for (const host of hosts)
|
|
176
181
|
assertValidSshTarget(host); // fail fast on any bad target
|
|
177
182
|
const forwarded = buildForwardedArgs(argv, new Set(hosts));
|
|
178
|
-
const remoteCmd = buildRemoteCommand(forwarded);
|
|
183
|
+
const remoteCmd = buildRemoteCommand(forwarded, terminalWidth());
|
|
179
184
|
const multi = hosts.length > 1;
|
|
180
185
|
let failures = 0;
|
|
181
186
|
for (const host of hosts) {
|