@phnx-labs/agents-cli 1.20.28 → 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.
@@ -290,9 +290,9 @@ export function getDBPath() {
290
290
  export function getScanStampByPath(filePath) {
291
291
  const db = getDB();
292
292
  const row = db
293
- .prepare(`SELECT file_mtime_ms, file_size FROM scan_ledger WHERE file_path = ? LIMIT 1`)
293
+ .prepare(`SELECT file_mtime_ms, file_size, scanned_at FROM scan_ledger WHERE file_path = ? LIMIT 1`)
294
294
  .get(canonicalLedgerKey(filePath));
295
- return row ? { fileMtimeMs: row.file_mtime_ms, fileSize: row.file_size } : null;
295
+ return row ? { fileMtimeMs: row.file_mtime_ms, fileSize: row.file_size, scannedAt: row.scanned_at } : null;
296
296
  }
297
297
  /**
298
298
  * Bulk-load the stamp ledger for a set of file paths in a single SQL query.
@@ -324,13 +324,13 @@ export function getScanStampsForPaths(filePaths) {
324
324
  const placeholders = chunk.map(() => '?').join(',');
325
325
  const rows = db
326
326
  .prepare(`
327
- SELECT file_path, file_mtime_ms, file_size
327
+ SELECT file_path, file_mtime_ms, file_size, scanned_at
328
328
  FROM scan_ledger
329
329
  WHERE file_path IN (${placeholders})
330
330
  `)
331
331
  .all(...chunk);
332
332
  for (const row of rows) {
333
- const stamp = { fileMtimeMs: row.file_mtime_ms, fileSize: row.file_size };
333
+ const stamp = { fileMtimeMs: row.file_mtime_ms, fileSize: row.file_size, scannedAt: row.scanned_at };
334
334
  for (const original of canonicalToOriginals.get(row.file_path) || []) {
335
335
  result.set(original, stamp);
336
336
  }
@@ -7,6 +7,7 @@
7
7
  * subsequent queries are served entirely from the cache.
8
8
  */
9
9
  import type { SessionAgentId, SessionMeta } from './types.js';
10
+ import { type ScanStamp } from './db.js';
10
11
  /** Options controlling which sessions to discover and how to report progress. */
11
12
  export interface DiscoverOptions {
12
13
  agent?: SessionAgentId;
@@ -85,6 +86,7 @@ export declare function resolveSessionById(sessions: SessionMeta[], idQuery: str
85
86
  * preserving the existing SessionMeta[] contract so sessions.ts is unchanged.
86
87
  */
87
88
  export declare function searchContentIndex(sessions: SessionMeta[], query: string): Map<string, SessionMeta>;
89
+ export declare function shouldDeferRecentAppend(prev: ScanStamp, current: ScanStamp, nowMs: number, debounceMs?: number): boolean;
88
90
  /**
89
91
  * Collect all directories to scan for an agent's sessions. Deduplicates by
90
92
  * realpath to avoid double-counting symlinked version homes.
@@ -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
  // ---------------------------------------------------------------------------
@@ -1291,6 +1311,214 @@ function extractHermesMessageText(content) {
1291
1311
  .join('\n')
1292
1312
  .trim();
1293
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
+ }
1294
1522
  /** Stream a Claude JSONL file and extract scan-level metadata (timestamp, cwd, topic, tokens). */
1295
1523
  export async function scanClaudeSession(filePath) {
1296
1524
  const stream = fs.createReadStream(filePath, { encoding: 'utf-8' });
@@ -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
+ }
@@ -7,7 +7,7 @@
7
7
  * speaks these types.
8
8
  */
9
9
  /** Agents that store session data on disk and can be discovered by `agents sessions`. */
10
- export type SessionAgentId = 'claude' | 'codex' | 'gemini' | 'opencode' | 'openclaw' | 'rush' | 'hermes' | 'grok' | 'kimi';
10
+ export type SessionAgentId = 'claude' | 'codex' | 'gemini' | 'opencode' | 'openclaw' | 'rush' | 'hermes' | 'grok' | 'kimi' | 'droid';
11
11
  /** All agents with session discovery support, in display order. */
12
12
  export declare const SESSION_AGENTS: SessionAgentId[];
13
13
  /** A single normalized event within a session (message, tool call, thinking, etc.). */
@@ -7,4 +7,4 @@
7
7
  * speaks these types.
8
8
  */
9
9
  /** All agents with session discovery support, in display order. */
10
- export const SESSION_AGENTS = ['claude', 'codex', 'gemini', 'opencode', 'openclaw', 'rush', 'hermes', 'grok', 'kimi'];
10
+ export const SESSION_AGENTS = ['claude', 'codex', 'gemini', 'opencode', 'openclaw', 'rush', 'hermes', 'grok', 'kimi', 'droid'];
@@ -68,6 +68,7 @@ export declare const loadTmux: ModuleLoader;
68
68
  export declare const loadBrowser: ModuleLoader;
69
69
  export declare const loadComputer: ModuleLoader;
70
70
  export declare const loadHosts: ModuleLoader;
71
+ export declare const loadSsh: ModuleLoader;
71
72
  export declare const loadPull: ModuleLoader;
72
73
  export declare const loadPush: ModuleLoader;
73
74
  export declare const loadRepo: ModuleLoader;
@@ -46,6 +46,7 @@ export const loadTmux = async () => (await import('../../commands/tmux.js')).reg
46
46
  export const loadBrowser = async () => (await import('../../commands/browser.js')).registerBrowserCommand;
47
47
  export const loadComputer = async () => (await import('../../commands/computer.js')).registerComputerCommand;
48
48
  export const loadHosts = async () => (await import('../../commands/hosts.js')).registerHostsCommand;
49
+ export const loadSsh = async () => (await import('../../commands/ssh.js')).registerSshCommands;
49
50
  export const loadPull = async () => (await import('../../commands/pull.js')).registerPullCommand;
50
51
  export const loadPush = async () => (await import('../../commands/push.js')).registerPushCommand;
51
52
  export const loadRepo = async () => (await import('../../commands/repo.js')).registerRepoCommands;
@@ -128,6 +129,8 @@ export const COMMAND_LOADERS = {
128
129
  browser: [loadBrowser],
129
130
  computer: [loadComputer],
130
131
  hosts: [loadHosts],
132
+ ssh: [loadSsh],
133
+ devices: [loadSsh],
131
134
  pull: [loadPull],
132
135
  push: [loadPush],
133
136
  repo: [loadRepo],
@@ -152,6 +152,8 @@ export declare function getTeamsDir(): string;
152
152
  export declare function getTeamsAgentsDir(): string;
153
153
  /** Path to the team registry — list of named teams with timestamps. Durable runtime, per-machine. */
154
154
  export declare function getTeamsRegistryPath(): string;
155
+ /** Path to the device registry — SSH device profiles with platform/auth metadata. Durable runtime, per-machine (host list + addresses are NOT pulled by `agents repo push`). */
156
+ export declare function getDevicesRegistryPath(): string;
155
157
  /** Path to cloud dispatch cache (~/.agents/.cache/cloud/). */
156
158
  export declare function getCloudDir(): string;
157
159
  /** Path to terminal session metadata (~/.agents/.cache/terminals/). */
package/dist/lib/state.js CHANGED
@@ -334,6 +334,8 @@ export function getTeamsDir() { return TEAMS_DIR; }
334
334
  export function getTeamsAgentsDir() { return TEAMS_AGENTS_DIR; }
335
335
  /** Path to the team registry — list of named teams with timestamps. Durable runtime, per-machine. */
336
336
  export function getTeamsRegistryPath() { return path.join(HISTORY_DIR, 'teams', 'registry.json'); }
337
+ /** Path to the device registry — SSH device profiles with platform/auth metadata. Durable runtime, per-machine (host list + addresses are NOT pulled by `agents repo push`). */
338
+ export function getDevicesRegistryPath() { return path.join(HISTORY_DIR, 'devices', 'registry.json'); }
337
339
  /** Path to cloud dispatch cache (~/.agents/.cache/cloud/). */
338
340
  export function getCloudDir() { return CLOUD_DIR; }
339
341
  /** Path to terminal session metadata (~/.agents/.cache/terminals/). */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.20.28",
3
+ "version": "1.20.29",
4
4
  "description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",