@phnx-labs/agents-cli 1.20.27 → 1.20.29
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 +3 -0
- package/dist/commands/doctor.js +57 -4
- package/dist/commands/exec.d.ts +1 -1
- package/dist/commands/exec.js +198 -15
- package/dist/commands/hosts.d.ts +11 -0
- package/dist/commands/hosts.js +229 -0
- package/dist/commands/repo.d.ts +29 -0
- package/dist/commands/repo.js +174 -38
- package/dist/commands/secrets.d.ts +2 -7
- package/dist/commands/secrets.js +108 -29
- package/dist/commands/sessions.d.ts +2 -0
- package/dist/commands/sessions.js +8 -24
- package/dist/commands/ssh.d.ts +14 -0
- package/dist/commands/ssh.js +263 -0
- package/dist/commands/sync.d.ts +2 -0
- package/dist/commands/sync.js +22 -5
- package/dist/commands/view.js +27 -11
- package/dist/index.js +3 -1
- package/dist/lib/agents.d.ts +1 -0
- package/dist/lib/agents.js +44 -4
- package/dist/lib/browser/drivers/ssh.d.ts +47 -2
- package/dist/lib/browser/drivers/ssh.js +113 -24
- package/dist/lib/browser/profiles.js +28 -1
- package/dist/lib/browser/runtime-state.js +28 -8
- package/dist/lib/browser/types.d.ts +10 -1
- package/dist/lib/cli-resources.js +10 -1
- 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/doctor-diff.d.ts +12 -0
- package/dist/lib/doctor-diff.js +89 -2
- package/dist/lib/exec.d.ts +27 -0
- package/dist/lib/exec.js +62 -19
- package/dist/lib/hooks.d.ts +17 -0
- package/dist/lib/hooks.js +127 -3
- package/dist/lib/hosts/dispatch.d.ts +26 -0
- package/dist/lib/hosts/dispatch.js +71 -0
- package/dist/lib/hosts/progress.d.ts +21 -0
- package/dist/lib/hosts/progress.js +49 -0
- package/dist/lib/hosts/providers/local.d.ts +17 -0
- package/dist/lib/hosts/providers/local.js +81 -0
- package/dist/lib/hosts/ready.d.ts +37 -0
- package/dist/lib/hosts/ready.js +88 -0
- package/dist/lib/hosts/registry.d.ts +22 -0
- package/dist/lib/hosts/registry.js +65 -0
- package/dist/lib/hosts/ssh-config.d.ts +37 -0
- package/dist/lib/hosts/ssh-config.js +157 -0
- package/dist/lib/hosts/tasks.d.ts +32 -0
- package/dist/lib/hosts/tasks.js +58 -0
- package/dist/lib/hosts/types.d.ts +51 -0
- package/dist/lib/hosts/types.js +21 -0
- package/dist/lib/loop.d.ts +9 -0
- package/dist/lib/loop.js +13 -1
- package/dist/lib/mcp.js +12 -3
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/migrate.js +9 -5
- package/dist/lib/platform/exec.d.ts +10 -0
- package/dist/lib/platform/exec.js +17 -0
- package/dist/lib/platform/index.d.ts +1 -0
- package/dist/lib/platform/index.js +1 -0
- package/dist/lib/platform/links.d.ts +15 -0
- package/dist/lib/platform/links.js +42 -0
- package/dist/lib/platform/paths.d.ts +18 -0
- package/dist/lib/platform/paths.js +22 -0
- package/dist/lib/platform/posixpath.d.ts +28 -0
- package/dist/lib/platform/posixpath.js +153 -0
- package/dist/lib/plugins.d.ts +10 -0
- package/dist/lib/plugins.js +1 -1
- package/dist/lib/project-launch.js +6 -3
- package/dist/lib/sandbox.js +5 -2
- package/dist/lib/secrets/remote.d.ts +67 -0
- package/dist/lib/secrets/remote.js +133 -0
- package/dist/lib/self-update.js +7 -2
- package/dist/lib/session/db.d.ts +24 -0
- package/dist/lib/session/db.js +80 -5
- package/dist/lib/session/discover.d.ts +28 -0
- package/dist/lib/session/discover.js +303 -4
- package/dist/lib/session/parse.d.ts +7 -0
- package/dist/lib/session/parse.js +110 -0
- package/dist/lib/session/relative-time.d.ts +7 -0
- package/dist/lib/session/relative-time.js +28 -0
- package/dist/lib/session/remote.d.ts +31 -3
- package/dist/lib/session/remote.js +121 -14
- package/dist/lib/session/types.d.ts +1 -1
- package/dist/lib/session/types.js +1 -1
- package/dist/lib/ssh-exec.d.ts +45 -0
- package/dist/lib/ssh-exec.js +61 -0
- package/dist/lib/startup/command-registry.d.ts +2 -0
- package/dist/lib/startup/command-registry.js +5 -0
- package/dist/lib/state.d.ts +2 -0
- package/dist/lib/state.js +2 -0
- package/dist/lib/types.d.ts +21 -0
- package/dist/lib/versions.d.ts +6 -2
- package/dist/lib/versions.js +8 -4
- package/package.json +1 -1
- package/scripts/postinstall.js +62 -0
|
@@ -21,7 +21,7 @@ import { getConfigSymlinkVersion } from '../shims.js';
|
|
|
21
21
|
import { SESSION_AGENTS } from './types.js';
|
|
22
22
|
import { extractSessionTopic } from './prompt.js';
|
|
23
23
|
import { costOfUsage } from '../pricing/index.js';
|
|
24
|
-
import { getDB, getScanStampByPath, getScanStampsForPaths, recordScans, syncLabels, upsertSessionsBatch, querySessions, countSessions, ftsSearch, tryClaimScan, releaseScan, } from './db.js';
|
|
24
|
+
import { getDB, getScanStampByPath, getScanStampsForPaths, recordScans, syncLabels, syncTopics, upsertSessionsBatch, querySessions, countSessions, ftsSearch, tryClaimScan, releaseScan, } from './db.js';
|
|
25
25
|
const HOME = os.homedir();
|
|
26
26
|
// Versions can live under either repo: the user repo (current canonical
|
|
27
27
|
// location, ~/.agents/.history/versions/) or the system repo (legacy / npm-shipped,
|
|
@@ -34,6 +34,7 @@ const RUSH_SESSIONS_DIR = path.join(HOME, '.rush', 'sessions');
|
|
|
34
34
|
const HERMES_SESSIONS_DIR = path.join(HOME, '.hermes', 'sessions');
|
|
35
35
|
/** How long OpenClaw channel/cron snapshots stay valid before we re-shell-out. */
|
|
36
36
|
const OPENCLAW_TTL_MS = 60_000;
|
|
37
|
+
const ACTIVE_APPEND_RESCAN_DEBOUNCE_MS = 5_000;
|
|
37
38
|
let cachedOpenClawWorkspaces = null;
|
|
38
39
|
const cachedAgentVersions = new Map();
|
|
39
40
|
/**
|
|
@@ -63,6 +64,7 @@ export async function discoverSessions(options) {
|
|
|
63
64
|
case 'rush': return scanRushIncremental(onProgress);
|
|
64
65
|
case 'hermes': return scanHermesIncremental(onProgress);
|
|
65
66
|
case 'kimi': return scanKimiIncremental(onProgress);
|
|
67
|
+
case 'droid': return scanDroidIncremental(onProgress);
|
|
66
68
|
}
|
|
67
69
|
}));
|
|
68
70
|
}
|
|
@@ -164,10 +166,16 @@ export function searchContentIndex(sessions, query) {
|
|
|
164
166
|
/**
|
|
165
167
|
* For a list of files, stat each, compare to the DB ledger, and return only
|
|
166
168
|
* the ones that need rescanning. One bulk DB query for the whole list.
|
|
169
|
+
*
|
|
170
|
+
* Actively running agents append to their JSONL every few seconds. Without a
|
|
171
|
+
* small debounce, repeated `agents sessions` invocations stream-parse the same
|
|
172
|
+
* growing transcript over and over. The cached row is good enough for a few
|
|
173
|
+
* seconds; once writes settle or the debounce expires, the file is parsed once.
|
|
167
174
|
*/
|
|
168
175
|
function filterChangedFiles(filePaths) {
|
|
169
176
|
const ledger = getScanStampsForPaths(filePaths);
|
|
170
177
|
const out = [];
|
|
178
|
+
const now = Date.now();
|
|
171
179
|
for (const filePath of filePaths) {
|
|
172
180
|
const stat = safeStatSync(filePath);
|
|
173
181
|
if (!stat)
|
|
@@ -180,10 +188,22 @@ function filterChangedFiles(filePaths) {
|
|
|
180
188
|
if (prev && prev.fileMtimeMs === scan.fileMtimeMs && prev.fileSize === scan.fileSize) {
|
|
181
189
|
continue;
|
|
182
190
|
}
|
|
191
|
+
if (prev && shouldDeferRecentAppend(prev, scan, now)) {
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
183
194
|
out.push({ filePath, scan });
|
|
184
195
|
}
|
|
185
196
|
return out;
|
|
186
197
|
}
|
|
198
|
+
export function shouldDeferRecentAppend(prev, current, nowMs, debounceMs = ACTIVE_APPEND_RESCAN_DEBOUNCE_MS) {
|
|
199
|
+
if (prev.scannedAt === undefined)
|
|
200
|
+
return false;
|
|
201
|
+
if (current.fileSize <= prev.fileSize)
|
|
202
|
+
return false;
|
|
203
|
+
if (current.fileMtimeMs < prev.fileMtimeMs)
|
|
204
|
+
return false;
|
|
205
|
+
return nowMs - prev.scannedAt < debounceMs;
|
|
206
|
+
}
|
|
187
207
|
// ---------------------------------------------------------------------------
|
|
188
208
|
// Multi-version directory scanning
|
|
189
209
|
// ---------------------------------------------------------------------------
|
|
@@ -486,8 +506,14 @@ async function scanCodexIncremental(onProgress) {
|
|
|
486
506
|
}
|
|
487
507
|
}
|
|
488
508
|
const changed = filterChangedFiles(filePaths);
|
|
489
|
-
|
|
509
|
+
// Codex keeps human-readable titles (`thread_name`) in `session_index.jsonl`,
|
|
510
|
+
// which updates independently of the rollout files — apply them by id on every
|
|
511
|
+
// scan so a title that lands after a session was first indexed still surfaces.
|
|
512
|
+
const titles = readCodexThreadNames();
|
|
513
|
+
if (changed.length === 0) {
|
|
514
|
+
syncTopics(titles);
|
|
490
515
|
return;
|
|
516
|
+
}
|
|
491
517
|
onProgress?.({ agent: 'codex', parsed: 0, total: changed.length });
|
|
492
518
|
const entries = [];
|
|
493
519
|
const touched = [];
|
|
@@ -498,6 +524,10 @@ async function scanCodexIncremental(onProgress) {
|
|
|
498
524
|
const result = await readCodexMeta(filePath, account, currentVersion);
|
|
499
525
|
if (result && !seen.has(result.meta.id)) {
|
|
500
526
|
seen.add(result.meta.id);
|
|
527
|
+
// Prefer the Codex-generated title over the first-prompt fallback.
|
|
528
|
+
const title = titles.get(result.meta.id);
|
|
529
|
+
if (title)
|
|
530
|
+
result.meta.topic = title;
|
|
501
531
|
entries.push({ meta: result.meta, content: result.content, scan });
|
|
502
532
|
}
|
|
503
533
|
else {
|
|
@@ -512,6 +542,48 @@ async function scanCodexIncremental(onProgress) {
|
|
|
512
542
|
}
|
|
513
543
|
upsertSessionsBatch(entries);
|
|
514
544
|
recordScans(touched);
|
|
545
|
+
// Catch sessions whose rollout file was unchanged but gained a title since the
|
|
546
|
+
// last scan (the index changed, the transcript did not).
|
|
547
|
+
syncTopics(titles);
|
|
548
|
+
}
|
|
549
|
+
/** Parse the lines of a Codex `session_index.jsonl` into a session id -> title map. */
|
|
550
|
+
export function parseCodexThreadNameIndex(raw) {
|
|
551
|
+
const titles = new Map();
|
|
552
|
+
for (const line of raw.split('\n')) {
|
|
553
|
+
if (!line.trim())
|
|
554
|
+
continue;
|
|
555
|
+
try {
|
|
556
|
+
const entry = JSON.parse(line);
|
|
557
|
+
const id = typeof entry.id === 'string' ? entry.id : '';
|
|
558
|
+
const name = typeof entry.thread_name === 'string' ? entry.thread_name.trim() : '';
|
|
559
|
+
if (id && name)
|
|
560
|
+
titles.set(id, name);
|
|
561
|
+
}
|
|
562
|
+
catch {
|
|
563
|
+
// skip malformed line
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
return titles;
|
|
567
|
+
}
|
|
568
|
+
/**
|
|
569
|
+
* Read Codex session titles across every Codex home (live + versioned). The
|
|
570
|
+
* `session_index.jsonl` file sits beside each `sessions/` rollout tree.
|
|
571
|
+
*/
|
|
572
|
+
function readCodexThreadNames() {
|
|
573
|
+
const titles = new Map();
|
|
574
|
+
for (const sessionsDir of getAgentSessionDirs('codex', 'sessions')) {
|
|
575
|
+
const indexPath = path.join(path.dirname(sessionsDir), 'session_index.jsonl');
|
|
576
|
+
let raw;
|
|
577
|
+
try {
|
|
578
|
+
raw = fs.readFileSync(indexPath, 'utf-8');
|
|
579
|
+
}
|
|
580
|
+
catch {
|
|
581
|
+
continue; // no index in this home
|
|
582
|
+
}
|
|
583
|
+
for (const [id, name] of parseCodexThreadNameIndex(raw))
|
|
584
|
+
titles.set(id, name);
|
|
585
|
+
}
|
|
586
|
+
return titles;
|
|
515
587
|
}
|
|
516
588
|
/** Stream-parse a single Codex JSONL file to extract session metadata. */
|
|
517
589
|
async function readCodexMeta(filePath, account, currentVersion) {
|
|
@@ -1239,8 +1311,216 @@ function extractHermesMessageText(content) {
|
|
|
1239
1311
|
.join('\n')
|
|
1240
1312
|
.trim();
|
|
1241
1313
|
}
|
|
1314
|
+
/**
|
|
1315
|
+
* Incrementally re-scan changed Droid (Factory) session files and upsert into
|
|
1316
|
+
* the DB. Droid writes one `<uuid>.jsonl` transcript plus a sibling
|
|
1317
|
+
* `<uuid>.settings.json` (model + token usage) under
|
|
1318
|
+
* `~/.factory/sessions/<encoded-cwd>/`.
|
|
1319
|
+
*/
|
|
1320
|
+
async function scanDroidIncremental(onProgress) {
|
|
1321
|
+
const currentVersion = await getCurrentAgentVersion('droid');
|
|
1322
|
+
const filePaths = [];
|
|
1323
|
+
for (const sessionsDir of getAgentSessionDirs('droid', 'sessions')) {
|
|
1324
|
+
// High limit: we only stat files here, parsing is gated by ledger match.
|
|
1325
|
+
for (const fp of walkForFiles(sessionsDir, '.jsonl', 100_000)) {
|
|
1326
|
+
filePaths.push(fp);
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
const changed = filterChangedFiles(filePaths);
|
|
1330
|
+
if (changed.length === 0)
|
|
1331
|
+
return;
|
|
1332
|
+
onProgress?.({ agent: 'droid', parsed: 0, total: changed.length });
|
|
1333
|
+
const entries = [];
|
|
1334
|
+
const touched = [];
|
|
1335
|
+
const seen = new Set();
|
|
1336
|
+
let parsed = 0;
|
|
1337
|
+
for (const { filePath, scan } of changed) {
|
|
1338
|
+
try {
|
|
1339
|
+
const result = await readDroidMeta(filePath, currentVersion);
|
|
1340
|
+
if (result && !seen.has(result.meta.id)) {
|
|
1341
|
+
seen.add(result.meta.id);
|
|
1342
|
+
entries.push({ meta: result.meta, content: result.content, scan });
|
|
1343
|
+
}
|
|
1344
|
+
else {
|
|
1345
|
+
touched.push({ filePath, scan });
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
catch {
|
|
1349
|
+
touched.push({ filePath, scan });
|
|
1350
|
+
}
|
|
1351
|
+
parsed++;
|
|
1352
|
+
onProgress?.({ agent: 'droid', parsed, total: changed.length });
|
|
1353
|
+
}
|
|
1354
|
+
upsertSessionsBatch(entries);
|
|
1355
|
+
recordScans(touched);
|
|
1356
|
+
}
|
|
1357
|
+
/** Stream-parse a single Droid JSONL file (+ sibling settings) into session metadata. */
|
|
1358
|
+
async function readDroidMeta(filePath, currentVersion) {
|
|
1359
|
+
const scan = await scanDroidSession(filePath);
|
|
1360
|
+
// The filename is the canonical session id; fall back to the session_start id.
|
|
1361
|
+
const sessionId = path.basename(filePath).replace(/\.jsonl$/, '') || scan.sessionId || '';
|
|
1362
|
+
if (!sessionId)
|
|
1363
|
+
return null;
|
|
1364
|
+
// Token usage and cost live only in the sibling `<uuid>.settings.json`.
|
|
1365
|
+
const settings = readDroidSettings(filePath.replace(/\.jsonl$/, '.settings.json'));
|
|
1366
|
+
const model = settings.model || scan.model;
|
|
1367
|
+
const tokenCount = settings.tokenCount;
|
|
1368
|
+
const costUsd = model && settings.usage
|
|
1369
|
+
? costOfUsage({
|
|
1370
|
+
model,
|
|
1371
|
+
inputTokens: settings.usage.inputTokens,
|
|
1372
|
+
outputTokens: settings.usage.outputTokens,
|
|
1373
|
+
cacheReadTokens: settings.usage.cacheReadTokens,
|
|
1374
|
+
cacheCreationTokens: settings.usage.cacheCreationTokens,
|
|
1375
|
+
})
|
|
1376
|
+
: 0;
|
|
1377
|
+
const stat = safeStatSync(filePath);
|
|
1378
|
+
const cwd = normalizeCwd(scan.cwd || '');
|
|
1379
|
+
const meta = {
|
|
1380
|
+
id: sessionId,
|
|
1381
|
+
shortId: sessionId.slice(0, 8),
|
|
1382
|
+
agent: 'droid',
|
|
1383
|
+
timestamp: scan.timestamp || (stat ? stat.mtime.toISOString() : new Date().toISOString()),
|
|
1384
|
+
project: cwd ? path.basename(cwd) : undefined,
|
|
1385
|
+
cwd,
|
|
1386
|
+
filePath,
|
|
1387
|
+
version: resolveSessionVersion('droid', filePath, undefined, currentVersion),
|
|
1388
|
+
topic: scan.topic,
|
|
1389
|
+
messageCount: scan.messageCount,
|
|
1390
|
+
tokenCount,
|
|
1391
|
+
costUsd: costUsd > 0 ? costUsd : undefined,
|
|
1392
|
+
durationMs: scan.durationMs,
|
|
1393
|
+
};
|
|
1394
|
+
return { meta, content: scan.contentText || '' };
|
|
1395
|
+
}
|
|
1396
|
+
/** Read model + token usage from a Droid `<uuid>.settings.json` sidecar. */
|
|
1397
|
+
function readDroidSettings(settingsPath) {
|
|
1398
|
+
try {
|
|
1399
|
+
const data = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
|
|
1400
|
+
const model = typeof data.model === 'string' ? data.model : undefined;
|
|
1401
|
+
const u = data.tokenUsage;
|
|
1402
|
+
if (!u || typeof u !== 'object')
|
|
1403
|
+
return { model };
|
|
1404
|
+
const usage = {
|
|
1405
|
+
inputTokens: u.inputTokens,
|
|
1406
|
+
outputTokens: u.outputTokens,
|
|
1407
|
+
cacheReadTokens: u.cacheReadTokens,
|
|
1408
|
+
cacheCreationTokens: u.cacheCreationTokens,
|
|
1409
|
+
};
|
|
1410
|
+
const tokenCount = sumKnownNumbers([
|
|
1411
|
+
u.inputTokens,
|
|
1412
|
+
u.outputTokens,
|
|
1413
|
+
u.cacheCreationTokens,
|
|
1414
|
+
u.cacheReadTokens,
|
|
1415
|
+
]) ?? undefined;
|
|
1416
|
+
return { model, tokenCount, usage };
|
|
1417
|
+
}
|
|
1418
|
+
catch {
|
|
1419
|
+
return {};
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
/** Stream a Droid JSONL file and extract scan-level metadata (id, cwd, topic, model, duration). */
|
|
1423
|
+
async function scanDroidSession(filePath) {
|
|
1424
|
+
const stream = fs.createReadStream(filePath, { encoding: 'utf-8' });
|
|
1425
|
+
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
1426
|
+
let sessionId;
|
|
1427
|
+
let timestamp;
|
|
1428
|
+
let cwd;
|
|
1429
|
+
let title;
|
|
1430
|
+
let sessionTitle;
|
|
1431
|
+
let firstUserTopic;
|
|
1432
|
+
let model;
|
|
1433
|
+
let messageCount = 0;
|
|
1434
|
+
let firstTsMs;
|
|
1435
|
+
let lastTsMs;
|
|
1436
|
+
const userTexts = [];
|
|
1437
|
+
try {
|
|
1438
|
+
for await (const line of rl) {
|
|
1439
|
+
if (!line.trim())
|
|
1440
|
+
continue;
|
|
1441
|
+
let parsed;
|
|
1442
|
+
try {
|
|
1443
|
+
parsed = JSON.parse(line);
|
|
1444
|
+
}
|
|
1445
|
+
catch {
|
|
1446
|
+
continue;
|
|
1447
|
+
}
|
|
1448
|
+
if (parsed.type === 'session_start') {
|
|
1449
|
+
sessionId = typeof parsed.id === 'string' ? parsed.id : sessionId;
|
|
1450
|
+
cwd = typeof parsed.cwd === 'string' ? parsed.cwd : cwd;
|
|
1451
|
+
// Droid auto-generates `sessionTitle`; `title` is the raw first prompt.
|
|
1452
|
+
if (typeof parsed.sessionTitle === 'string' && parsed.sessionTitle.trim()) {
|
|
1453
|
+
sessionTitle = parsed.sessionTitle.trim();
|
|
1454
|
+
}
|
|
1455
|
+
if (typeof parsed.title === 'string' && parsed.title.trim()) {
|
|
1456
|
+
title = parsed.title.trim();
|
|
1457
|
+
}
|
|
1458
|
+
continue;
|
|
1459
|
+
}
|
|
1460
|
+
if (parsed.type !== 'message')
|
|
1461
|
+
continue;
|
|
1462
|
+
// Track duration across every timestamped message.
|
|
1463
|
+
if (typeof parsed.timestamp === 'string') {
|
|
1464
|
+
const ms = new Date(parsed.timestamp).getTime();
|
|
1465
|
+
if (!Number.isNaN(ms)) {
|
|
1466
|
+
if (firstTsMs === undefined || ms < firstTsMs)
|
|
1467
|
+
firstTsMs = ms;
|
|
1468
|
+
if (lastTsMs === undefined || ms > lastTsMs)
|
|
1469
|
+
lastTsMs = ms;
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
if (!timestamp && typeof parsed.timestamp === 'string')
|
|
1473
|
+
timestamp = parsed.timestamp;
|
|
1474
|
+
const msg = parsed.message || {};
|
|
1475
|
+
if (typeof msg.modelId === 'string')
|
|
1476
|
+
model = msg.modelId;
|
|
1477
|
+
const text = extractDroidMessageText(msg.content);
|
|
1478
|
+
if (!text)
|
|
1479
|
+
continue;
|
|
1480
|
+
messageCount++;
|
|
1481
|
+
if (msg.role === 'user') {
|
|
1482
|
+
userTexts.push(text);
|
|
1483
|
+
if (!firstUserTopic)
|
|
1484
|
+
firstUserTopic = extractSessionTopic(text);
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
finally {
|
|
1489
|
+
rl.close();
|
|
1490
|
+
stream.destroy();
|
|
1491
|
+
}
|
|
1492
|
+
const durationMs = firstTsMs !== undefined && lastTsMs !== undefined && lastTsMs > firstTsMs
|
|
1493
|
+
? lastTsMs - firstTsMs
|
|
1494
|
+
: undefined;
|
|
1495
|
+
return {
|
|
1496
|
+
sessionId,
|
|
1497
|
+
timestamp,
|
|
1498
|
+
cwd,
|
|
1499
|
+
// Prefer Droid's auto-title, then the raw first-prompt title, then the
|
|
1500
|
+
// derived first-user-message topic.
|
|
1501
|
+
topic: sessionTitle || title || firstUserTopic,
|
|
1502
|
+
model,
|
|
1503
|
+
messageCount,
|
|
1504
|
+
durationMs,
|
|
1505
|
+
contentText: userTexts.length > 0 ? userTexts.join('\n') : undefined,
|
|
1506
|
+
};
|
|
1507
|
+
}
|
|
1508
|
+
/** Extract plain text from a Droid message content field (Anthropic-shaped blocks). */
|
|
1509
|
+
function extractDroidMessageText(content) {
|
|
1510
|
+
if (typeof content === 'string')
|
|
1511
|
+
return content.trim();
|
|
1512
|
+
if (!Array.isArray(content))
|
|
1513
|
+
return '';
|
|
1514
|
+
return content
|
|
1515
|
+
.map((part) => (typeof part?.text === 'string' && part.type === 'text' ? part.text : ''))
|
|
1516
|
+
// Droid front-loads injected context (date, skills list) as <system-reminder>
|
|
1517
|
+
// text blocks on the first user turn — drop them so topic/content stay clean.
|
|
1518
|
+
.filter((text) => text.trim() && !text.trim().startsWith('<system-reminder>'))
|
|
1519
|
+
.join('\n')
|
|
1520
|
+
.trim();
|
|
1521
|
+
}
|
|
1242
1522
|
/** Stream a Claude JSONL file and extract scan-level metadata (timestamp, cwd, topic, tokens). */
|
|
1243
|
-
async function scanClaudeSession(filePath) {
|
|
1523
|
+
export async function scanClaudeSession(filePath) {
|
|
1244
1524
|
const stream = fs.createReadStream(filePath, { encoding: 'utf-8' });
|
|
1245
1525
|
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
1246
1526
|
let timestamp;
|
|
@@ -1248,6 +1528,10 @@ async function scanClaudeSession(filePath) {
|
|
|
1248
1528
|
let gitBranch;
|
|
1249
1529
|
let version;
|
|
1250
1530
|
let topic;
|
|
1531
|
+
// Explicit session titles: `/rename` writes a `custom-title` event; Claude
|
|
1532
|
+
// auto-generates an `ai-title`. Both can repeat across the file — last wins.
|
|
1533
|
+
let customTitle;
|
|
1534
|
+
let aiTitle;
|
|
1251
1535
|
let entrypoint;
|
|
1252
1536
|
let messageCount = 0;
|
|
1253
1537
|
let tokenCount = 0;
|
|
@@ -1291,6 +1575,18 @@ async function scanClaudeSession(filePath) {
|
|
|
1291
1575
|
gitBranch = parsed.gitBranch || undefined;
|
|
1292
1576
|
version = parsed.version || undefined;
|
|
1293
1577
|
}
|
|
1578
|
+
if (parsed.type === 'custom-title') {
|
|
1579
|
+
const t = typeof parsed.customTitle === 'string' ? parsed.customTitle.trim() : '';
|
|
1580
|
+
if (t)
|
|
1581
|
+
customTitle = t;
|
|
1582
|
+
continue;
|
|
1583
|
+
}
|
|
1584
|
+
if (parsed.type === 'ai-title') {
|
|
1585
|
+
const t = typeof parsed.aiTitle === 'string' ? parsed.aiTitle.trim() : '';
|
|
1586
|
+
if (t)
|
|
1587
|
+
aiTitle = t;
|
|
1588
|
+
continue;
|
|
1589
|
+
}
|
|
1294
1590
|
if (parsed.type === 'user') {
|
|
1295
1591
|
const text = extractClaudeUserText(parsed);
|
|
1296
1592
|
if (text) {
|
|
@@ -1344,12 +1640,15 @@ async function scanClaudeSession(filePath) {
|
|
|
1344
1640
|
const durationMs = firstTsMs !== undefined && lastTsMs !== undefined && lastTsMs > firstTsMs
|
|
1345
1641
|
? lastTsMs - firstTsMs
|
|
1346
1642
|
: undefined;
|
|
1643
|
+
// Prefer an explicit session title (user `/rename` > Claude auto-title) over
|
|
1644
|
+
// the first-prompt topic.
|
|
1645
|
+
const resolvedTopic = customTitle || aiTitle || topic;
|
|
1347
1646
|
return {
|
|
1348
1647
|
timestamp,
|
|
1349
1648
|
cwd,
|
|
1350
1649
|
gitBranch,
|
|
1351
1650
|
version,
|
|
1352
|
-
topic,
|
|
1651
|
+
topic: resolvedTopic,
|
|
1353
1652
|
entrypoint,
|
|
1354
1653
|
messageCount,
|
|
1355
1654
|
tokenCount: sawTokenCount ? tokenCount : undefined,
|
|
@@ -51,3 +51,10 @@ export declare function parseRush(filePath: string): SessionEvent[];
|
|
|
51
51
|
export declare function parseHermes(filePath: string): SessionEvent[];
|
|
52
52
|
/** Parse a Kimi session state.json file by reading its agents/main/wire.jsonl. */
|
|
53
53
|
export declare function parseKimi(filePath: string): SessionEvent[];
|
|
54
|
+
/**
|
|
55
|
+
* Parse a Droid (Factory) JSONL session file into normalized events. Droid
|
|
56
|
+
* wraps each turn in a `{type:'message', message:{role, content, modelId}}`
|
|
57
|
+
* envelope; the content blocks are Anthropic-shaped (text/thinking/tool_use/
|
|
58
|
+
* tool_result), so block handling mirrors the Claude parser.
|
|
59
|
+
*/
|
|
60
|
+
export declare function parseDroid(filePath: string): SessionEvent[];
|
|
@@ -109,6 +109,9 @@ export function parseSession(filePath, agent) {
|
|
|
109
109
|
case 'kimi':
|
|
110
110
|
events = parseKimi(filePath);
|
|
111
111
|
break;
|
|
112
|
+
case 'droid':
|
|
113
|
+
events = parseDroid(filePath);
|
|
114
|
+
break;
|
|
112
115
|
}
|
|
113
116
|
// Chokepoint: every string field that originated in an untrusted session
|
|
114
117
|
// file gets stripped of terminal escapes here, so renderers downstream can
|
|
@@ -133,6 +136,8 @@ export function detectAgent(filePath) {
|
|
|
133
136
|
return 'hermes';
|
|
134
137
|
if (filePath.includes('/.kimi-code/') || filePath.includes('\\.kimi-code\\'))
|
|
135
138
|
return 'kimi';
|
|
139
|
+
if (filePath.includes('/.factory/') || filePath.includes('\\.factory\\'))
|
|
140
|
+
return 'droid';
|
|
136
141
|
// Cloud convention: cloud-sessions/<id>/session.<format>.jsonl
|
|
137
142
|
const cloudMatch = filePath.match(/session\.(claude|codex|rush)\.jsonl(?:$|[?#])/);
|
|
138
143
|
if (cloudMatch)
|
|
@@ -1124,3 +1129,108 @@ export function parseKimi(filePath) {
|
|
|
1124
1129
|
}
|
|
1125
1130
|
return events;
|
|
1126
1131
|
}
|
|
1132
|
+
// ---------------------------------------------------------------------------
|
|
1133
|
+
// Droid (Factory) parser
|
|
1134
|
+
// ---------------------------------------------------------------------------
|
|
1135
|
+
/**
|
|
1136
|
+
* Parse a Droid (Factory) JSONL session file into normalized events. Droid
|
|
1137
|
+
* wraps each turn in a `{type:'message', message:{role, content, modelId}}`
|
|
1138
|
+
* envelope; the content blocks are Anthropic-shaped (text/thinking/tool_use/
|
|
1139
|
+
* tool_result), so block handling mirrors the Claude parser.
|
|
1140
|
+
*/
|
|
1141
|
+
export function parseDroid(filePath) {
|
|
1142
|
+
const content = safeReadSessionFile(filePath);
|
|
1143
|
+
const lines = content.split('\n').filter(l => l.trim());
|
|
1144
|
+
const events = [];
|
|
1145
|
+
// Map tool_use id -> {tool, args} for correlating with tool_result.
|
|
1146
|
+
const toolUseMap = new Map();
|
|
1147
|
+
for (const line of lines) {
|
|
1148
|
+
let raw;
|
|
1149
|
+
try {
|
|
1150
|
+
raw = JSON.parse(line);
|
|
1151
|
+
}
|
|
1152
|
+
catch {
|
|
1153
|
+
continue;
|
|
1154
|
+
}
|
|
1155
|
+
if (raw.type !== 'message')
|
|
1156
|
+
continue;
|
|
1157
|
+
const message = raw.message || {};
|
|
1158
|
+
const role = message.role === 'user' ? 'user' : 'assistant';
|
|
1159
|
+
const timestamp = raw.timestamp || new Date().toISOString();
|
|
1160
|
+
const blocks = message.content;
|
|
1161
|
+
// Plain-string content (rare) renders as a single message.
|
|
1162
|
+
if (typeof blocks === 'string') {
|
|
1163
|
+
const text = blocks.trim();
|
|
1164
|
+
if (text)
|
|
1165
|
+
events.push({ type: 'message', agent: 'droid', timestamp, role, content: text });
|
|
1166
|
+
continue;
|
|
1167
|
+
}
|
|
1168
|
+
if (!Array.isArray(blocks))
|
|
1169
|
+
continue;
|
|
1170
|
+
for (const block of blocks) {
|
|
1171
|
+
if (block.type === 'text') {
|
|
1172
|
+
const text = (block.text || '').trim();
|
|
1173
|
+
// Skip injected context blocks (date, skills list) on the first user turn.
|
|
1174
|
+
if (text && !(role === 'user' && text.startsWith('<system-reminder>'))) {
|
|
1175
|
+
events.push({ type: 'message', agent: 'droid', timestamp, role, content: text });
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
else if (block.type === 'thinking') {
|
|
1179
|
+
const thinkingText = (block.thinking || '').trim();
|
|
1180
|
+
if (thinkingText)
|
|
1181
|
+
events.push({ type: 'thinking', agent: 'droid', timestamp, content: thinkingText });
|
|
1182
|
+
}
|
|
1183
|
+
else if (block.type === 'tool_use') {
|
|
1184
|
+
const toolName = block.name || 'unknown';
|
|
1185
|
+
const toolInput = block.input || {};
|
|
1186
|
+
if (block.id)
|
|
1187
|
+
toolUseMap.set(block.id, { tool: toolName, args: toolInput });
|
|
1188
|
+
events.push({
|
|
1189
|
+
type: 'tool_use',
|
|
1190
|
+
agent: 'droid',
|
|
1191
|
+
timestamp,
|
|
1192
|
+
tool: toolName,
|
|
1193
|
+
args: toolInput,
|
|
1194
|
+
path: toolInput.file_path || toolInput.path || undefined,
|
|
1195
|
+
command: toolName === 'Bash' ? toolInput.command : undefined,
|
|
1196
|
+
});
|
|
1197
|
+
}
|
|
1198
|
+
else if (block.type === 'tool_result') {
|
|
1199
|
+
const toolId = block.tool_use_id;
|
|
1200
|
+
const toolInfo = toolId ? toolUseMap.get(toolId) : undefined;
|
|
1201
|
+
const isError = block.is_error === true;
|
|
1202
|
+
let output = '';
|
|
1203
|
+
if (typeof block.content === 'string') {
|
|
1204
|
+
output = block.content;
|
|
1205
|
+
}
|
|
1206
|
+
else if (Array.isArray(block.content)) {
|
|
1207
|
+
output = block.content
|
|
1208
|
+
.filter((c) => c.type === 'text')
|
|
1209
|
+
.map((c) => c.text || '')
|
|
1210
|
+
.join('\n');
|
|
1211
|
+
}
|
|
1212
|
+
if (isError) {
|
|
1213
|
+
events.push({ type: 'error', agent: 'droid', timestamp, tool: toolInfo?.tool, content: output || 'Tool execution failed' });
|
|
1214
|
+
}
|
|
1215
|
+
else {
|
|
1216
|
+
events.push({
|
|
1217
|
+
type: 'tool_result',
|
|
1218
|
+
agent: 'droid',
|
|
1219
|
+
timestamp,
|
|
1220
|
+
tool: toolInfo?.tool,
|
|
1221
|
+
success: true,
|
|
1222
|
+
output: output.length > 500 ? output.slice(0, 497) + '...' : output,
|
|
1223
|
+
});
|
|
1224
|
+
}
|
|
1225
|
+
if (toolId)
|
|
1226
|
+
toolUseMap.delete(toolId);
|
|
1227
|
+
}
|
|
1228
|
+
else if (block.type === 'image') {
|
|
1229
|
+
const source = block.source || {};
|
|
1230
|
+
const sizeBytes = source.type === 'base64' ? Math.ceil((source.data?.length || 0) * 0.75) : 0;
|
|
1231
|
+
events.push({ type: 'attachment', agent: 'droid', timestamp, mediaType: source.media_type || 'image/png', sizeBytes });
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
return events;
|
|
1236
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Human-readable "time since" formatting for session timestamps. Lives here (not
|
|
3
|
+
* inline in `sessions.ts`) so both the session list renderer and the remote
|
|
4
|
+
* offline-cache banner (`remote.ts`) share one formatter — `sessions.ts` imports
|
|
5
|
+
* `remote.ts`, so a back-import would cycle.
|
|
6
|
+
*/
|
|
7
|
+
export declare function formatRelativeTime(isoTimestamp: string): string;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Human-readable "time since" formatting for session timestamps. Lives here (not
|
|
3
|
+
* inline in `sessions.ts`) so both the session list renderer and the remote
|
|
4
|
+
* offline-cache banner (`remote.ts`) share one formatter — `sessions.ts` imports
|
|
5
|
+
* `remote.ts`, so a back-import would cycle.
|
|
6
|
+
*/
|
|
7
|
+
export function formatRelativeTime(isoTimestamp) {
|
|
8
|
+
const now = Date.now();
|
|
9
|
+
const then = new Date(isoTimestamp).getTime();
|
|
10
|
+
if (isNaN(then))
|
|
11
|
+
return isoTimestamp;
|
|
12
|
+
const diffMs = now - then;
|
|
13
|
+
const diffMin = Math.floor(diffMs / 60_000);
|
|
14
|
+
const diffHrs = Math.floor(diffMs / 3_600_000);
|
|
15
|
+
const diffDays = Math.floor(diffMs / 86_400_000);
|
|
16
|
+
if (diffMin < 1)
|
|
17
|
+
return 'just now';
|
|
18
|
+
if (diffMin < 60)
|
|
19
|
+
return `${diffMin} min ago`;
|
|
20
|
+
if (diffHrs < 24)
|
|
21
|
+
return `${diffHrs} hour${diffHrs === 1 ? '' : 's'} ago`;
|
|
22
|
+
if (diffDays < 7)
|
|
23
|
+
return `${diffDays} day${diffDays === 1 ? '' : 's'} ago`;
|
|
24
|
+
// Older: show date
|
|
25
|
+
const d = new Date(then);
|
|
26
|
+
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
27
|
+
return `${months[d.getMonth()]} ${d.getDate()}`;
|
|
28
|
+
}
|
|
@@ -24,10 +24,38 @@ export declare function buildForwardedArgs(argv: string[], hosts?: Set<string>):
|
|
|
24
24
|
* quoted again so it survives `bash -lc <...>`.
|
|
25
25
|
*/
|
|
26
26
|
export declare function buildRemoteCommand(forwardedArgs: string[]): string;
|
|
27
|
+
/** The four outcomes of one `ssh <host> agents sessions …` invocation. */
|
|
28
|
+
export type SshOutcome = 'ok' | 'unreachable' | 'query-failed' | 'spawn-error';
|
|
29
|
+
/**
|
|
30
|
+
* Classify an ssh `spawnSync` result. ssh(1) reserves exit 255 for its own
|
|
31
|
+
* connection-layer failures (host down, timeout, refused, auth, changed host
|
|
32
|
+
* key) — distinct from any other non-zero, which is the remote `agents sessions`
|
|
33
|
+
* exit code forwarded back (the query ran but failed). The two must be handled
|
|
34
|
+
* differently: 255 may fall back to cache, a forwarded failure must surface.
|
|
35
|
+
*/
|
|
36
|
+
export declare function classifySshFailure(res: {
|
|
37
|
+
error?: Error | null;
|
|
38
|
+
status: number | null;
|
|
39
|
+
}): SshOutcome;
|
|
40
|
+
/**
|
|
41
|
+
* Deterministic cache path for a (host, forwarded-args) pair. The forwarded args
|
|
42
|
+
* are hashed so distinct queries cache independently; the host stays readable in
|
|
43
|
+
* the filename (sanitised so `user@host` and aliases are filesystem-safe).
|
|
44
|
+
*/
|
|
45
|
+
export declare function remoteCachePath(host: string, forwardedArgs: string[]): string;
|
|
46
|
+
/** Banner shown above replayed cache rows when the peer is offline. */
|
|
47
|
+
export declare function formatStaleBanner(host: string, mtimeMs: number): string;
|
|
48
|
+
/** Message shown when a host is unreachable and there is no cache to fall back to. */
|
|
49
|
+
export declare function formatUnreachable(host: string): string;
|
|
27
50
|
/**
|
|
28
51
|
* Run the current `agents sessions` invocation on one or more remote machines over
|
|
29
|
-
* SSH,
|
|
30
|
-
*
|
|
31
|
-
* `
|
|
52
|
+
* SSH, writing each remote's output to the terminal. A successful fetch is cached;
|
|
53
|
+
* an unreachable host falls back to that cache (with a stale banner) when present.
|
|
54
|
+
* Sets `process.exitCode = 1` if any host could not be answered (live or cached).
|
|
55
|
+
* Reads the invocation from `process.argv` (override via `argv` for testing).
|
|
56
|
+
*
|
|
57
|
+
* Output is captured rather than `stdio: 'inherit'`-streamed so it can be cached.
|
|
58
|
+
* Session output is small and the remote returns quickly, so buffering is
|
|
59
|
+
* imperceptible; `maxBuffer` is generous for the rare large `--markdown <id>` dump.
|
|
32
60
|
*/
|
|
33
61
|
export declare function runRemoteSessions(hosts: string[], argv?: string[]): void;
|