subconscious-cli 4.0.18 → 4.1.0-windows.0

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.
@@ -0,0 +1,622 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { spawnWindowsSync } from './windows/process.js';
3
+ import fs from 'node:fs/promises';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+
7
+ import { c } from './colors.js';
8
+ import { resolveAgent, runAgent } from './agents.js';
9
+
10
+ const MAX_CANDIDATE_FILES = 160;
11
+ const MAX_READ_BYTES = 2 * 1024 * 1024;
12
+ const MAX_METADATA_BYTES = 256 * 1024;
13
+ const MAX_INDEX_BYTES = 4 * 1024 * 1024;
14
+ const MAX_HANDOFF_MESSAGES = 24;
15
+ const MAX_HANDOFF_CHARS = 24_000;
16
+ const MAX_MESSAGE_CHARS = 4_000;
17
+ const SESSION_KEY_PATTERN = /^(claude|codex|opencode|pi|sc):[-A-Za-z0-9._]+$/;
18
+
19
+ export const SESSION_HARNESSES = Object.freeze({
20
+ claude: { name: 'Claude Code', command: 'claude', portable: true },
21
+ codex: { name: 'Codex CLI', command: 'codex', portable: true },
22
+ opencode: { name: 'OpenCode', command: 'opencode', portable: true },
23
+ pi: { name: 'Pi', command: 'pi', portable: true },
24
+ sc: { name: 'Subconscious Code', command: 'sc', portable: false },
25
+ });
26
+
27
+ function cleanText(value) {
28
+ return String(value || '')
29
+ .replace(/\u0000/g, '')
30
+ .replace(/\r\n/g, '\n')
31
+ .trim();
32
+ }
33
+
34
+ function textFromContent(content) {
35
+ if (typeof content === 'string') return cleanText(content);
36
+ if (!Array.isArray(content)) return '';
37
+ return cleanText(
38
+ content
39
+ .filter((part) => {
40
+ if (typeof part === 'string') return true;
41
+ return ['text', 'input_text', 'output_text'].includes(part?.type);
42
+ })
43
+ .map((part) => (typeof part === 'string' ? part : part.text || ''))
44
+ .filter(Boolean)
45
+ .join('\n'),
46
+ );
47
+ }
48
+
49
+ function firstLine(value, fallback = 'Untitled session') {
50
+ const line = cleanText(value).split('\n').find(Boolean) || fallback;
51
+ return line.length > 100 ? `${line.slice(0, 97)}...` : line;
52
+ }
53
+
54
+ function parseLines(text) {
55
+ const records = [];
56
+ for (const line of text.split('\n')) {
57
+ if (!line.trim()) continue;
58
+ try {
59
+ records.push(JSON.parse(line));
60
+ } catch {
61
+ // Session writers can leave a partial final line after an interrupted run.
62
+ }
63
+ }
64
+ return records;
65
+ }
66
+
67
+ async function readBounded(file, limit = MAX_READ_BYTES) {
68
+ const handle = await fs.open(file, 'r');
69
+ try {
70
+ const stat = await handle.stat();
71
+ if (stat.size <= limit) return handle.readFile({ encoding: 'utf8' });
72
+
73
+ const half = Math.floor(limit / 2);
74
+ const head = Buffer.alloc(half);
75
+ const tail = Buffer.alloc(half);
76
+ await handle.read(head, 0, half, 0);
77
+ await handle.read(tail, 0, half, Math.max(0, stat.size - half));
78
+ const headText = head.toString('utf8').replace(/[^\n]*$/, '');
79
+ const tailText = tail.toString('utf8').replace(/^[^\n]*\n?/, '');
80
+ return `${headText}\n${tailText}`;
81
+ } finally {
82
+ await handle.close();
83
+ }
84
+ }
85
+
86
+ async function readHead(file, limit = 64 * 1024) {
87
+ const handle = await fs.open(file, 'r');
88
+ try {
89
+ const buffer = Buffer.alloc(limit);
90
+ const { bytesRead } = await handle.read(buffer, 0, limit, 0);
91
+ const text = buffer.subarray(0, bytesRead).toString('utf8');
92
+ return bytesRead === limit ? text.replace(/[^\n]*$/, '') : text;
93
+ } finally {
94
+ await handle.close();
95
+ }
96
+ }
97
+
98
+ async function readTail(file, limit = MAX_INDEX_BYTES) {
99
+ const handle = await fs.open(file, 'r');
100
+ try {
101
+ const stat = await handle.stat();
102
+ const size = Math.min(stat.size, limit);
103
+ const buffer = Buffer.alloc(size);
104
+ await handle.read(buffer, 0, size, Math.max(0, stat.size - size));
105
+ const text = buffer.toString('utf8');
106
+ return stat.size > size ? text.replace(/^[^\n]*\n?/, '') : text;
107
+ } finally {
108
+ await handle.close();
109
+ }
110
+ }
111
+
112
+ async function walkJsonl(root, maxDepth = 8) {
113
+ const files = [];
114
+ const pending = [{ directory: root, depth: 0 }];
115
+ while (pending.length) {
116
+ const { directory, depth } = pending.pop();
117
+ let entries;
118
+ try {
119
+ entries = await fs.readdir(directory, { withFileTypes: true });
120
+ } catch {
121
+ continue;
122
+ }
123
+ for (const entry of entries) {
124
+ const resolved = path.join(directory, entry.name);
125
+ if (entry.isDirectory() && depth < maxDepth) {
126
+ pending.push({ directory: resolved, depth: depth + 1 });
127
+ } else if (entry.isFile() && entry.name.endsWith('.jsonl')) {
128
+ files.push(resolved);
129
+ }
130
+ }
131
+ }
132
+ return files;
133
+ }
134
+
135
+ async function newestFiles(root, limit) {
136
+ const files = await walkJsonl(root);
137
+ const entries = await Promise.all(
138
+ files.map(async (file) => {
139
+ try {
140
+ const stat = await fs.stat(file);
141
+ return { file, updated: stat.mtimeMs };
142
+ } catch {
143
+ return null;
144
+ }
145
+ }),
146
+ );
147
+ return entries
148
+ .filter(Boolean)
149
+ .sort((a, b) => b.updated - a.updated)
150
+ .slice(0, Math.min(MAX_CANDIDATE_FILES, limit));
151
+ }
152
+
153
+ function sessionRecord(harness, id, values) {
154
+ const descriptor = SESSION_HARNESSES[harness];
155
+ if (!descriptor || !id) return null;
156
+ const updatedMs =
157
+ typeof values.updated === 'number'
158
+ ? values.updated
159
+ : Date.parse(values.updated || '') || 0;
160
+ return {
161
+ key: `${harness}:${id}`,
162
+ id,
163
+ harness,
164
+ harnessName: descriptor.name,
165
+ title: firstLine(values.title),
166
+ cwd: values.cwd || '',
167
+ updatedAt: new Date(updatedMs || Date.now()).toISOString(),
168
+ updatedMs,
169
+ model: values.model || '',
170
+ sourcePath: values.sourcePath || '',
171
+ portable: harness === 'opencode' || Boolean(values.sourcePath),
172
+ };
173
+ }
174
+
175
+ function claudeMessages(records) {
176
+ return records.flatMap((record) => {
177
+ const role = record.message?.role || record.type;
178
+ if (!['user', 'assistant'].includes(role)) return [];
179
+ const text = textFromContent(record.message?.content ?? record.content);
180
+ return text ? [{ role, text }] : [];
181
+ });
182
+ }
183
+
184
+ function codexMessages(records) {
185
+ const responseItems = records.flatMap((record) => {
186
+ if (record.type !== 'response_item') return [];
187
+ const role = record.payload?.role;
188
+ if (!['user', 'assistant'].includes(role)) return [];
189
+ const text = textFromContent(record.payload?.content);
190
+ return text ? [{ role, text }] : [];
191
+ });
192
+ if (responseItems.length) return responseItems;
193
+
194
+ return records.flatMap((record) => {
195
+ if (record.type !== 'event_msg') return [];
196
+ const eventType = record.payload?.type;
197
+ const role = eventType === 'user_message' ? 'user' : eventType === 'agent_message' ? 'assistant' : '';
198
+ const text = cleanText(record.payload?.message || record.payload?.last_agent_message);
199
+ return role && text ? [{ role, text }] : [];
200
+ });
201
+ }
202
+
203
+ function piMessages(records) {
204
+ return records.flatMap((record) => {
205
+ if (record.type !== 'message') return [];
206
+ const role = record.message?.role;
207
+ if (!['user', 'assistant'].includes(role)) return [];
208
+ const text = textFromContent(record.message?.content);
209
+ return text ? [{ role, text }] : [];
210
+ });
211
+ }
212
+
213
+ function scMessages(records) {
214
+ return records.flatMap((record) => {
215
+ if (!['user', 'assistant'].includes(record.type)) return [];
216
+ const text = cleanText(record.text ?? record.content);
217
+ return text ? [{ role: record.type, text }] : [];
218
+ });
219
+ }
220
+
221
+ async function parseFileSession(entry, harness) {
222
+ const records = parseLines(await readBounded(entry.file, MAX_METADATA_BYTES));
223
+ if (!records.length) return null;
224
+ const filename = path.basename(entry.file, '.jsonl');
225
+
226
+ if (harness === 'claude') {
227
+ const messages = claudeMessages(records);
228
+ const metadata = records.find((record) => record.cwd) || {};
229
+ const identity = records.find((record) => record.sessionId) || {};
230
+ const id = metadata.sessionId || identity.sessionId || filename.split('.orphaned-')[0];
231
+ const titleRecord = records.find((record) => record.type === 'ai-title');
232
+ const lastPrompt = [...records].reverse().find((record) => record.type === 'last-prompt');
233
+ const assistant = records.find((record) => record.message?.role === 'assistant');
234
+ return sessionRecord(harness, id, {
235
+ title: titleRecord?.aiTitle || messages.find((message) => message.role === 'user')?.text || lastPrompt?.lastPrompt,
236
+ cwd: metadata.cwd,
237
+ model: assistant?.message?.model,
238
+ updated: entry.updated,
239
+ sourcePath: entry.file,
240
+ });
241
+ }
242
+
243
+ if (harness === 'codex') {
244
+ const meta = records.find((record) => record.type === 'session_meta')?.payload || {};
245
+ const messages = codexMessages(records);
246
+ const id = meta.id || filename.match(/([0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12})$/i)?.[1];
247
+ const context = records.find((record) => record.type === 'turn_context')?.payload || {};
248
+ return sessionRecord(harness, id, {
249
+ title: messages.find((message) => message.role === 'user')?.text,
250
+ cwd: meta.cwd,
251
+ model: context.model,
252
+ updated: entry.updated,
253
+ sourcePath: entry.file,
254
+ });
255
+ }
256
+
257
+ if (harness === 'pi') {
258
+ const meta = records.find((record) => record.type === 'session') || {};
259
+ const messages = piMessages(records);
260
+ const assistant = records.find((record) => record.message?.role === 'assistant');
261
+ return sessionRecord(harness, meta.id || filename.split('_').at(-1), {
262
+ title: messages.find((message) => message.role === 'user')?.text,
263
+ cwd: meta.cwd,
264
+ model: assistant?.message?.model,
265
+ updated: entry.updated,
266
+ sourcePath: entry.file,
267
+ });
268
+ }
269
+
270
+ if (harness === 'sc') {
271
+ const meta = records[0] || {};
272
+ const messages = scMessages(records);
273
+ const id = String(meta.id || filename).replace(/^session-/, '');
274
+ return sessionRecord(harness, id, {
275
+ title: messages.find((message) => message.role === 'user')?.text,
276
+ cwd: meta.cwd,
277
+ model: meta.model,
278
+ updated: entry.updated,
279
+ sourcePath: entry.file,
280
+ });
281
+ }
282
+
283
+ return null;
284
+ }
285
+
286
+ async function discoverFileSessions(root, harness, limit) {
287
+ const files = await newestFiles(root, limit);
288
+ const sessions = await Promise.all(
289
+ files.map((entry) => parseFileSession(entry, harness).catch(() => null)),
290
+ );
291
+ return sessions.filter(Boolean);
292
+ }
293
+
294
+ function sessionIdFromFile(file, harness) {
295
+ const name = path.basename(file, '.jsonl');
296
+ if (harness === 'claude') {
297
+ if (name.includes('.orphaned-') || file.split(path.sep).includes('subagents')) return '';
298
+ return name;
299
+ }
300
+ if (harness === 'codex') {
301
+ return name.match(/([0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12})$/i)?.[1] || '';
302
+ }
303
+ return '';
304
+ }
305
+
306
+ async function sourceFilesById(root, harness) {
307
+ const files = await walkJsonl(root);
308
+ return new Map(
309
+ files
310
+ .map((file) => [sessionIdFromFile(file, harness), file])
311
+ .filter(([id]) => id),
312
+ );
313
+ }
314
+
315
+ async function discoverClaudeIndex(root, indexFile, max) {
316
+ let records;
317
+ try {
318
+ records = parseLines(await readTail(indexFile));
319
+ } catch {
320
+ return null;
321
+ }
322
+ const sources = await sourceFilesById(root, 'claude');
323
+ const byID = new Map();
324
+ for (const record of records) {
325
+ if (!record.sessionId) continue;
326
+ const updated = Number(record.timestamp) || 0;
327
+ const current = byID.get(record.sessionId);
328
+ if (!current || updated >= current.updated) {
329
+ byID.set(record.sessionId, {
330
+ id: record.sessionId,
331
+ title: record.display,
332
+ cwd: record.project,
333
+ updated,
334
+ });
335
+ }
336
+ }
337
+ return [...byID.values()]
338
+ .sort((a, b) => b.updated - a.updated)
339
+ .slice(0, max)
340
+ .map((record) =>
341
+ sessionRecord('claude', record.id, {
342
+ ...record,
343
+ sourcePath: sources.get(record.id),
344
+ }),
345
+ );
346
+ }
347
+
348
+ async function discoverCodexIndex(root, indexFile, max) {
349
+ let records;
350
+ try {
351
+ records = parseLines(await readTail(indexFile));
352
+ } catch {
353
+ return null;
354
+ }
355
+ const sources = await sourceFilesById(root, 'codex');
356
+ const byID = new Map();
357
+ for (const record of records) {
358
+ if (!record.id) continue;
359
+ const updated = Date.parse(record.updated_at || '') || 0;
360
+ const current = byID.get(record.id);
361
+ if (!current || updated >= current.updated) {
362
+ byID.set(record.id, { id: record.id, title: record.thread_name, updated });
363
+ }
364
+ }
365
+ const selected = [...byID.values()].sort((a, b) => b.updated - a.updated).slice(0, max);
366
+ return selected.map((record) =>
367
+ sessionRecord('codex', record.id, {
368
+ ...record,
369
+ sourcePath: sources.get(record.id),
370
+ }),
371
+ );
372
+ }
373
+
374
+ function commandResult(execute, command, args, options = {}) {
375
+ const result = execute(command, args, {
376
+ encoding: 'utf8',
377
+ timeout: options.timeout ?? 5_000,
378
+ env: { ...process.env, NO_COLOR: '1' },
379
+ maxBuffer: 4 * 1024 * 1024,
380
+ });
381
+ if (result.error || result.status !== 0) return '';
382
+ return String(result.stdout || '').trim();
383
+ }
384
+
385
+ function parseOpenCodeModel(value) {
386
+ try {
387
+ return JSON.parse(value || '{}').id || '';
388
+ } catch {
389
+ return '';
390
+ }
391
+ }
392
+
393
+ function sqlString(value) {
394
+ return `'${String(value).replaceAll("'", "''")}'`;
395
+ }
396
+
397
+ function discoverOpenCodeSessions(execute, max) {
398
+ const query = [
399
+ 'select id, title, directory, time_created, time_updated, model',
400
+ 'from session where time_archived is null',
401
+ `order by time_updated desc limit ${Math.max(1, Math.min(500, max))}`,
402
+ ].join(' ');
403
+ const output = commandResult(execute, 'opencode', ['db', '--format', 'json', query]);
404
+ if (!output) return [];
405
+ try {
406
+ return JSON.parse(output).map((record) =>
407
+ sessionRecord('opencode', record.id, {
408
+ title: record.title,
409
+ cwd: record.directory,
410
+ model: parseOpenCodeModel(record.model),
411
+ updated: record.time_updated || record.time_created,
412
+ }),
413
+ );
414
+ } catch {
415
+ return [];
416
+ }
417
+ }
418
+
419
+ export async function discoverSessions(options = {}) {
420
+ const home = options.home || os.homedir();
421
+ const max = options.max || 500;
422
+ const execute = options.execute || (process.platform === 'win32' ? spawnWindowsSync : spawnSync);
423
+ const roots = options.roots || {
424
+ claude: path.join(home, '.claude', 'projects'),
425
+ codex: path.join(home, '.codex', 'sessions'),
426
+ pi: path.join(home, '.pi', 'agent', 'sessions'),
427
+ sc: path.join(home, '.sc', 'sessions'),
428
+ };
429
+ const indexes =
430
+ options.indexes ||
431
+ (options.roots
432
+ ? {
433
+ claude: path.join(roots.claude, '.history-index.jsonl'),
434
+ codex: path.join(roots.codex, '.session-index.jsonl'),
435
+ }
436
+ : {
437
+ claude: path.join(home, '.claude', 'history.jsonl'),
438
+ codex: path.join(home, '.codex', 'session_index.jsonl'),
439
+ });
440
+ const claude = await discoverClaudeIndex(roots.claude, indexes.claude, max);
441
+ const codex = await discoverCodexIndex(roots.codex, indexes.codex, max);
442
+ const groups = await Promise.all([
443
+ claude || discoverFileSessions(roots.claude, 'claude', max),
444
+ codex || discoverFileSessions(roots.codex, 'codex', max),
445
+ Promise.resolve(discoverOpenCodeSessions(execute, max)),
446
+ discoverFileSessions(roots.pi, 'pi', max),
447
+ discoverFileSessions(roots.sc, 'sc', max),
448
+ ]);
449
+ const seen = new Set();
450
+ return groups
451
+ .flat()
452
+ .filter((session) => {
453
+ if (!session || seen.has(session.key)) return false;
454
+ seen.add(session.key);
455
+ return true;
456
+ })
457
+ .sort((a, b) => b.updatedMs - a.updatedMs)
458
+ .slice(0, max);
459
+ }
460
+
461
+ async function openCodeMessages(session, execute) {
462
+ const query = [
463
+ "select json_extract(message.data, '$.role') as role,",
464
+ "json_extract(part.data, '$.text') as text",
465
+ 'from message join part on part.message_id = message.id',
466
+ `where message.session_id = ${sqlString(session.id)}`,
467
+ "and json_extract(part.data, '$.type') = 'text'",
468
+ 'order by message.time_created asc, part.time_created asc',
469
+ ].join(' ');
470
+ const output = commandResult(execute, 'opencode', ['db', '--format', 'json', query]);
471
+ if (!output) return [];
472
+ try {
473
+ return JSON.parse(output)
474
+ .filter((row) => ['user', 'assistant'].includes(row.role) && cleanText(row.text))
475
+ .map((row) => ({ role: row.role, text: cleanText(row.text) }));
476
+ } catch {
477
+ return [];
478
+ }
479
+ }
480
+
481
+ export async function readSessionMessages(session, options = {}) {
482
+ if (session.harness === 'opencode') {
483
+ return openCodeMessages(session, options.execute || (process.platform === 'win32' ? spawnWindowsSync : spawnSync));
484
+ }
485
+ if (!session.sourcePath) return [];
486
+ const records = parseLines(await readBounded(session.sourcePath));
487
+ if (session.harness === 'claude') return claudeMessages(records);
488
+ if (session.harness === 'codex') return codexMessages(records);
489
+ if (session.harness === 'pi') return piMessages(records);
490
+ if (session.harness === 'sc') return scMessages(records);
491
+ return [];
492
+ }
493
+
494
+ async function hydrateSession(session) {
495
+ if (session.harness !== 'codex' || !session.sourcePath || session.cwd) return session;
496
+ try {
497
+ const head = parseLines(await readHead(session.sourcePath));
498
+ const meta = head.find((item) => item.type === 'session_meta')?.payload || {};
499
+ const context = head.find((item) => item.type === 'turn_context')?.payload || {};
500
+ return { ...session, cwd: meta.cwd || '', model: context.model || '' };
501
+ } catch {
502
+ return session;
503
+ }
504
+ }
505
+
506
+ export function buildHandoffPrompt(session, messages) {
507
+ const selected = [];
508
+ let remaining = MAX_HANDOFF_CHARS;
509
+ for (const message of messages.slice(-MAX_HANDOFF_MESSAGES).reverse()) {
510
+ const label = message.role === 'assistant' ? 'Assistant' : 'User';
511
+ const text = cleanText(message.text).slice(0, MAX_MESSAGE_CHARS);
512
+ if (!text) continue;
513
+ const block = `${label}:\n${text}`;
514
+ if (block.length > remaining && selected.length) break;
515
+ selected.push(block.slice(0, remaining));
516
+ remaining -= block.length;
517
+ if (remaining <= 0) break;
518
+ }
519
+ selected.reverse();
520
+ if (!selected.length) {
521
+ throw new Error(`No portable user/assistant messages were found in ${session.harnessName}.`);
522
+ }
523
+
524
+ return [
525
+ `Continue the work from a session originally run in ${session.harnessName}.`,
526
+ 'This is a portable handoff, not native session state. Inspect the current working tree before changing files, preserve existing work, and use the transcript below only as context.',
527
+ session.title ? `Session: ${session.title}` : '',
528
+ session.cwd ? `Working directory: ${session.cwd}` : '',
529
+ '',
530
+ '<session_handoff>',
531
+ ...selected,
532
+ '</session_handoff>',
533
+ ]
534
+ .filter((line, index, lines) => line || (index > 0 && lines[index - 1]))
535
+ .join('\n\n');
536
+ }
537
+
538
+ export function nativeResumeArgs(session) {
539
+ if (session.harness === 'claude') return ['--resume', session.id];
540
+ if (session.harness === 'codex') return ['resume', session.id];
541
+ if (session.harness === 'opencode') return ['--session', session.id];
542
+ if (session.harness === 'pi') return ['--session', session.sourcePath || session.id];
543
+ if (session.harness === 'sc') return ['--resume', session.sourcePath];
544
+ throw new Error(`Native resume is not supported for ${session.harnessName}.`);
545
+ }
546
+
547
+ export function handoffLaunchArgs(targetHarness, prompt) {
548
+ if (targetHarness === 'opencode') return ['--prompt', prompt];
549
+ if (['claude', 'codex', 'pi'].includes(targetHarness)) return [prompt];
550
+ throw new Error(`${SESSION_HARNESSES[targetHarness]?.name || targetHarness} cannot start an interactive handoff yet.`);
551
+ }
552
+
553
+ function sessionByKey(sessions, key) {
554
+ if (!SESSION_KEY_PATTERN.test(key || '')) throw new Error('Invalid session key.');
555
+ return sessions.find((session) => session.key === key) || null;
556
+ }
557
+
558
+ function formatWhen(timestamp) {
559
+ const date = new Date(timestamp);
560
+ if (Number.isNaN(date.getTime())) return 'unknown';
561
+ return date.toLocaleString();
562
+ }
563
+
564
+ export function printSessions(sessions) {
565
+ console.log(`\n ${c.bold}Coding sessions${c.reset}\n`);
566
+ if (!sessions.length) {
567
+ console.log(` ${c.dim}No supported local coding sessions were found.${c.reset}\n`);
568
+ return;
569
+ }
570
+ for (const session of sessions) {
571
+ console.log(` ${c.cyan}${session.key}${c.reset} ${c.bold}${session.title}${c.reset}`);
572
+ console.log(
573
+ ` ${c.dim}${session.harnessName} · ${formatWhen(session.updatedAt)}${session.cwd ? ` · ${session.cwd}` : ''}${c.reset}`,
574
+ );
575
+ }
576
+ console.log(
577
+ `\n Resume with ${c.cyan}subc sessions resume <session-key>${c.reset} or add ${c.cyan}--harness codex${c.reset}.\n`,
578
+ );
579
+ }
580
+
581
+ export async function sessionsCommand(argv, options = {}) {
582
+ const sessions = options.sessions || (await discoverSessions(options));
583
+ const action = argv[0];
584
+ if (!action || action === 'list') {
585
+ printSessions(sessions);
586
+ return 0;
587
+ }
588
+ if (action !== 'resume') throw new Error(`Unknown sessions action: ${action}`);
589
+
590
+ const key = argv[1];
591
+ let session = sessionByKey(sessions, key);
592
+ if (!session) throw new Error(`Session '${key}' was not found. Run subc sessions to refresh the list.`);
593
+ session = await hydrateSession(session);
594
+ const harnessIndex = argv.indexOf('--harness');
595
+ const targetHarness = harnessIndex >= 0 ? argv[harnessIndex + 1] : session.harness;
596
+ const target = SESSION_HARNESSES[targetHarness];
597
+ if (!target) throw new Error(`Unknown destination harness: ${targetHarness}`);
598
+
599
+ if (session.cwd) {
600
+ try {
601
+ const stat = await fs.stat(session.cwd);
602
+ if (stat.isDirectory()) process.chdir(session.cwd);
603
+ } catch {
604
+ console.error(` ${c.yellow}The original directory is unavailable; resuming in ${process.cwd()}.${c.reset}\n`);
605
+ }
606
+ }
607
+
608
+ const agent = resolveAgent(target.command);
609
+ if (!agent) throw new Error(`${target.name} is not registered with this CLI.`);
610
+ if (targetHarness === session.harness) {
611
+ console.log(`\n ${c.dim}Resuming in ${c.reset}${c.bold}${session.harnessName}${c.reset}${c.dim}: ${session.title}${c.reset}\n`);
612
+ return runAgent(agent, nativeResumeArgs(session), { profile: options.profile });
613
+ }
614
+ if (!target.portable) throw new Error(`${target.name} cannot receive cross-harness sessions yet.`);
615
+
616
+ const messages = await readSessionMessages(session, options);
617
+ const prompt = buildHandoffPrompt(session, messages);
618
+ console.log(
619
+ `\n ${c.dim}Handing off ${c.reset}${c.bold}${session.harnessName}${c.reset}${c.dim} → ${c.reset}${c.bold}${target.name}${c.reset}${c.dim}: ${session.title}${c.reset}\n`,
620
+ );
621
+ return runAgent(agent, handoffLaunchArgs(targetHarness, prompt), { profile: options.profile });
622
+ }
package/bin/tui.js CHANGED
@@ -7,6 +7,7 @@ import { fileURLToPath } from 'node:url';
7
7
  import { agentList } from './agents.js';
8
8
  import { DEFAULT_PLATFORM_URL, getApiKey, getPlatformUrl } from './auth.js';
9
9
  import { resolveModelCatalog } from './models.js';
10
+ import { discoverSessions, SESSION_HARNESSES } from './sessions.js';
10
11
  import {
11
12
  DEFAULT_PROFILE,
12
13
  listProfiles,
@@ -128,6 +129,7 @@ export async function createTuiState(profileName = DEFAULT_PROFILE, options = {}
128
129
  fallbackModels: PACKAGED_MODELS,
129
130
  });
130
131
  const selectedModel = requestedModel;
132
+ const sessions = await discoverSessions({ max: options.maxSessions || 500 });
131
133
 
132
134
  return {
133
135
  version: await packageVersion(),
@@ -148,6 +150,21 @@ export async function createTuiState(profileName = DEFAULT_PROFILE, options = {}
148
150
  platformOverridden: Boolean(process.env.SUBCONSCIOUS_URL?.trim()),
149
151
  modelError: catalog.error?.message || '',
150
152
  modelSource: catalog.source,
153
+ sessions: sessions.map((session) => ({
154
+ key: session.key,
155
+ harness: session.harness,
156
+ harnessName: session.harnessName,
157
+ title: session.title,
158
+ cwd: session.cwd,
159
+ updatedAt: session.updatedAt,
160
+ model: session.model,
161
+ portable: session.portable,
162
+ })),
163
+ sessionHarnesses: Object.entries(SESSION_HARNESSES).map(([id, harness]) => ({
164
+ id,
165
+ name: harness.name,
166
+ portable: harness.portable,
167
+ })),
151
168
  agents: agentList().map((agent) => ({
152
169
  command: agent.alias,
153
170
  name: agent.name,