claude-simple 0.2.27 → 0.2.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.
Files changed (79) hide show
  1. package/README.md +17 -1
  2. package/bin/edge-ctl.js +151 -4
  3. package/client.js +29 -4
  4. package/dist/addon-fit.min.js +0 -0
  5. package/dist/app.0.2.30.min.js +1308 -0
  6. package/dist/index.html +2 -2
  7. package/dist/style.0.2.30.min.css +2012 -0
  8. package/dist/xterm.min.css +0 -0
  9. package/dist/xterm.min.js +0 -0
  10. package/gateway.js +0 -0
  11. package/package.json +2 -2
  12. package/public/addon-fit.min.js +0 -0
  13. package/public/agent-panel.js +54 -22
  14. package/public/api.js +8 -5
  15. package/public/app.js +7 -5
  16. package/public/base.css +0 -0
  17. package/public/cache.js +19 -0
  18. package/public/chat.js +110 -19
  19. package/public/files.js +26 -10
  20. package/public/git.js +2 -2
  21. package/public/icons/icon-180.png +0 -0
  22. package/public/icons/icon-192.png +0 -0
  23. package/public/icons/icon-512.png +0 -0
  24. package/public/icons/icon-maskable-512.png +0 -0
  25. package/public/index.html +2 -2
  26. package/public/lib.js +0 -0
  27. package/public/manifest.webmanifest +0 -0
  28. package/public/markdown.js +16 -0
  29. package/public/math.js +145 -7
  30. package/public/mobile.js +0 -0
  31. package/public/notes.js +316 -0
  32. package/public/notifications.js +0 -0
  33. package/public/offline.html +0 -0
  34. package/public/pwa.js +1 -1
  35. package/public/router.js +4 -0
  36. package/public/scroll-follow.js +78 -0
  37. package/public/settings.js +41 -0
  38. package/public/shell/boot.js +0 -0
  39. package/public/shell/dashboard.js +0 -0
  40. package/public/shell/hub.js +0 -0
  41. package/public/shell/layout.js +0 -0
  42. package/public/shell/notes.js +0 -0
  43. package/public/shell/session-context.js +0 -0
  44. package/public/shell/session-list.js +79 -21
  45. package/public/shell/session-nav.js +70 -21
  46. package/public/shell/templates.js +3 -0
  47. package/public/shell/util.js +0 -0
  48. package/public/shell.js +15 -2
  49. package/public/state.js +7 -7
  50. package/public/style.css +161 -0
  51. package/public/sw.js +10 -5
  52. package/public/terminal.js +0 -0
  53. package/public/wake-lock.js +0 -0
  54. package/public/workspace-path.js +61 -0
  55. package/public/xterm.min.css +0 -0
  56. package/public/xterm.min.js +0 -0
  57. package/src/agent-quota.js +228 -0
  58. package/src/agents/claude.js +0 -0
  59. package/src/agents/codex.js +2 -0
  60. package/src/agents/cursor.js +2 -1
  61. package/src/agents/gemini.js +2 -1
  62. package/src/agents/grok.js +22 -1
  63. package/src/agents/meta-agent.js +7 -4
  64. package/src/agents/models.js +21 -12
  65. package/src/agents/sessions.js +270 -81
  66. package/src/agents/stream-batch.js +0 -0
  67. package/src/app.js +111 -51
  68. package/src/auto-update.js +2 -0
  69. package/src/collab.js +3 -0
  70. package/src/platform.js +133 -0
  71. package/src/project-api-path.js +14 -0
  72. package/src/runtime/approvals.js +0 -0
  73. package/src/runtime/chat-broadcast.js +12 -0
  74. package/src/runtime/outbox.js +0 -0
  75. package/src/runtime/port-websocket.js +80 -0
  76. package/src/runtime/protocol.js +0 -0
  77. package/src/session-history.js +31 -0
  78. package/dist/app.0.2.27.min.js +0 -1182
  79. package/dist/style.0.2.27.min.css +0 -1851
@@ -11,12 +11,12 @@ import { pickAllowedEnv, loadAgentEnvFile, readSettingsJsonEnv } from '../agent-
11
11
  /** Static fallbacks when local cache/config is missing. */
12
12
  export const FALLBACK_MODELS = {
13
13
  claude: {
14
- model: 'claude-sonnet-4-5',
14
+ model: 'sonnet',
15
15
  models: [
16
- { value: 'claude-sonnet-4-5', label: 'sonnet-4-5' },
17
- { value: 'claude-sonnet-4-6', label: 'sonnet-4-6' },
18
- { value: 'claude-opus-4-5', label: 'opus-4-5' },
19
- { value: 'claude-haiku-4-5', label: 'haiku-4-5' },
16
+ { value: 'fable', label: 'fable' },
17
+ { value: 'opus', label: 'opus' },
18
+ { value: 'sonnet', label: 'sonnet' },
19
+ { value: 'haiku', label: 'haiku' },
20
20
  ],
21
21
  },
22
22
  codex: {
@@ -228,14 +228,22 @@ async function discoverClaude(claudeConfigDirs = []) {
228
228
  }
229
229
  }
230
230
 
231
- let models = uniqModels(found);
232
- let source = 'fallback';
233
- if (models.length) {
234
- source = envMerged.ANTHROPIC_BASE_URL ? 'anthropic-compat' : 'settings';
235
- // Custom gateway / third-party models: list only what is configured
231
+ // Third-party gateways only have the models named in env/settings.
232
+ // Official Claude Code accepts session aliases (fable, opus, sonnet, haiku)
233
+ // or a full model id; settings.model stays the default, not the whole list.
234
+ const customGateway = !!envMerged.ANTHROPIC_BASE_URL;
235
+ let models;
236
+ let source;
237
+ if (customGateway && found.length) {
238
+ models = uniqModels(found);
239
+ source = 'anthropic-compat';
236
240
  } else {
237
- models = FALLBACK_MODELS.claude.models.map(m => ({ ...m }));
238
- defaultModel = FALLBACK_MODELS.claude.model;
241
+ models = uniqModels([
242
+ ...FALLBACK_MODELS.claude.models.map(m => ({ ...m })),
243
+ ...found,
244
+ ]);
245
+ source = found.length ? 'alias+settings' : 'alias';
246
+ if (!defaultModel) defaultModel = FALLBACK_MODELS.claude.model;
239
247
  }
240
248
 
241
249
  if (!defaultModel) defaultModel = models[0]?.value || FALLBACK_MODELS.claude.model;
@@ -381,6 +389,7 @@ async function listAgyModels(cliPath) {
381
389
  // agy renders its model picker through a TTY. `script` supplies a tiny
382
390
  // pseudo-terminal while keeping this request non-interactive.
383
391
  const command = cliPath || 'agy';
392
+ if (process.platform === 'win32') return [];
384
393
  const scriptArgs = process.platform === 'darwin'
385
394
  ? ['-q', '/dev/null', command, 'models']
386
395
  : ['-q', '-c', `${command} models`, '/dev/null'];
@@ -4,6 +4,7 @@ import readline from 'readline';
4
4
  import path from 'path';
5
5
  import os from 'os';
6
6
  import crypto from 'crypto';
7
+ import { pathsEqual } from '../platform.js';
7
8
 
8
9
  // ─── Claude ───────────────────────────────────────────────────────────────────
9
10
 
@@ -105,7 +106,7 @@ async function scanClaudeConfigDir(configDir, nameMap, filterCwd) {
105
106
  const filePath = path.join(projectDir, fileEntry.name);
106
107
  try {
107
108
  const [stat, meta] = await Promise.all([fs.stat(filePath), readClaudeSessionMeta(filePath)]);
108
- if (filterCwd && meta.cwd !== filterCwd) return;
109
+ if (filterCwd && !pathsEqual(meta.cwd, filterCwd)) return;
109
110
  const sessionId = meta.sessionId || fileEntry.name.replace('.jsonl', '');
110
111
  const firstMsg = await readClaudeFirstMessage(filePath);
111
112
  sessions.push({
@@ -286,13 +287,20 @@ async function findCodexRollouts(codexHome) {
286
287
  return files;
287
288
  }
288
289
 
290
+ const codexMetadata = new Map();
289
291
  async function readCodexMeta(filePath) {
292
+ const stat = await fs.stat(filePath);
293
+ const cached = codexMetadata.get(filePath);
294
+ if (cached?.size === stat.size && cached?.mtimeMs === stat.mtimeMs) return cached.meta;
290
295
  let id = null, cwd = null, ts = null, turnCount = 0;
291
296
  try {
292
297
  // Full scan: meta is early; turnCount needs whole file
293
298
  const stream = fsSync.createReadStream(filePath);
294
299
  const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
295
300
  for await (const line of rl) {
301
+ const head = line.slice(0, 400);
302
+ if (!/"type"\s*:\s*"(?:session_meta|turn_context)"/.test(head) &&
303
+ !/"(?:role|type)"\s*:\s*"(?:user|user_message)"/.test(head)) continue;
296
304
  try {
297
305
  const d = JSON.parse(line);
298
306
  if (d.type === 'session_meta' && d.payload) {
@@ -316,7 +324,10 @@ async function readCodexMeta(filePath) {
316
324
  const m = path.basename(filePath).match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i);
317
325
  if (m) id = m[1];
318
326
  }
319
- return { id, cwd, ts, turnCount };
327
+ const meta = { id, cwd, ts, turnCount };
328
+ codexMetadata.set(filePath, { size: stat.size, mtimeMs: stat.mtimeMs, meta });
329
+ while (codexMetadata.size > 2000) codexMetadata.delete(codexMetadata.keys().next().value);
330
+ return meta;
320
331
  }
321
332
 
322
333
  export async function scanCodexSessions(codexHome, filterCwd = null) {
@@ -325,25 +336,27 @@ export async function scanCodexSessions(codexHome, filterCwd = null) {
325
336
  const rollouts = await findCodexRollouts(home);
326
337
  const sessions = [];
327
338
 
328
- await Promise.all(rollouts.map(async filePath => {
329
- try {
330
- const [stat, meta] = await Promise.all([fs.stat(filePath), readCodexMeta(filePath)]);
331
- if (!meta.id) return;
332
- if (filterCwd && meta.cwd && meta.cwd !== filterCwd) return;
333
- const idx = index.get(meta.id);
334
- sessions.push({
335
- sessionId: meta.id,
336
- agent: 'codex',
337
- cwd: meta.cwd || null,
338
- configDir: home,
339
- display: idx?.thread_name || meta.id.slice(0, 8),
340
- updatedAt: stat.mtimeMs,
341
- hasMemory: false,
342
- filePath,
343
- turnCount: meta.turnCount || 0,
344
- });
345
- } catch {}
346
- }));
339
+ for (let offset = 0; offset < rollouts.length; offset += 8) {
340
+ await Promise.all(rollouts.slice(offset, offset + 8).map(async filePath => {
341
+ try {
342
+ const [stat, meta] = await Promise.all([fs.stat(filePath), readCodexMeta(filePath)]);
343
+ if (!meta.id) return;
344
+ if (filterCwd && meta.cwd && !pathsEqual(meta.cwd, filterCwd)) return;
345
+ const idx = index.get(meta.id);
346
+ sessions.push({
347
+ sessionId: meta.id,
348
+ agent: 'codex',
349
+ cwd: meta.cwd || null,
350
+ configDir: home,
351
+ display: idx?.thread_name || meta.id.slice(0, 8),
352
+ updatedAt: stat.mtimeMs,
353
+ hasMemory: false,
354
+ filePath,
355
+ turnCount: meta.turnCount || 0,
356
+ });
357
+ } catch {}
358
+ }));
359
+ }
347
360
  return sessions;
348
361
  }
349
362
 
@@ -361,12 +374,36 @@ export async function findCodexSessionFile(sessionId, codexHome) {
361
374
  return null;
362
375
  }
363
376
 
377
+ const codexTranscripts = new Map();
378
+
364
379
  export async function parseCodexMessages(filePath) {
365
- const messages = [];
380
+ const stat = await fs.stat(filePath);
381
+ let entry = codexTranscripts.get(filePath);
382
+ if (entry?.loading) {
383
+ await entry.loading;
384
+ return parseCodexMessages(filePath);
385
+ }
386
+ if (!entry || entry.ino !== stat.ino || stat.size < entry.size ||
387
+ (stat.size === entry.size && stat.mtimeMs !== entry.mtimeMs)) {
388
+ entry = { ino: stat.ino, size: 0, offset: 0, messages: [], seenAssistant: new Set(), seenCalls: new Set() };
389
+ codexTranscripts.set(filePath, entry);
390
+ }
391
+ codexTranscripts.delete(filePath);
392
+ codexTranscripts.set(filePath, entry);
393
+ while (codexTranscripts.size > 8) codexTranscripts.delete(codexTranscripts.keys().next().value);
394
+ if (entry.size === stat.size && entry.mtimeMs === stat.mtimeMs) return entry.messages;
395
+ const loading = updateCodexTranscript(filePath, entry, stat);
396
+ entry.loading = loading;
397
+ try { await loading; } finally { entry.loading = null; }
398
+ return entry.messages;
399
+ }
400
+
401
+ async function updateCodexTranscript(filePath, entry, stat) {
402
+ const messages = entry.messages;
366
403
  // Codex writes the same assistant turn twice:
367
404
  // response_item (message/assistant) AND event_msg (agent_message)
368
405
  // Prefer response_item; only use event_msg when that text wasn't already seen.
369
- const seenAssistant = new Set();
406
+ const seenAssistant = entry.seenAssistant;
370
407
 
371
408
  const assistantKey = (text, ts) => {
372
409
  const t = String(text || '').trim();
@@ -389,54 +426,81 @@ export async function parseCodexMessages(filePath) {
389
426
  };
390
427
 
391
428
  try {
392
- const stream = fsSync.createReadStream(filePath);
393
- const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
394
- for await (const line of rl) {
395
- let d;
396
- try { d = JSON.parse(line); } catch { continue; }
397
-
398
- if (d.type === 'response_item' && d.payload) {
399
- const p = d.payload;
400
- if (p.type === 'message' && p.role === 'user') {
401
- const texts = (p.content || [])
402
- .filter(c => c.type === 'input_text' || c.type === 'text')
403
- .map(c => c.text || '')
404
- .filter(Boolean);
405
- const text = texts.join('\n').trim();
406
- // skip huge injected system/env blobs
407
- if (text && !text.startsWith('# AGENTS.md') && !text.startsWith('<environment_context')
408
- && !text.startsWith('<permissions') && text.length < 2000) {
409
- messages.push({ role: 'user', type: 'text', content: text, ts: d.timestamp });
429
+ if (stat.size <= entry.offset) return;
430
+ const stream = fsSync.createReadStream(filePath, { start: entry.offset, end: stat.size - 1 });
431
+ let pending = Buffer.alloc(0);
432
+ for await (const chunk of stream) {
433
+ pending = pending.length ? Buffer.concat([pending, chunk]) : chunk;
434
+ let start = 0;
435
+ let end;
436
+ while ((end = pending.indexOf(10, start)) !== -1) {
437
+ const line = pending.subarray(start, end).toString('utf8');
438
+ entry.offset += end - start + 1;
439
+ start = end + 1;
440
+ // Reasoning and tool outputs dominate large rollouts. Do not JSON-parse
441
+ // their multi-megabyte payloads when building the visible transcript.
442
+ const head = line.slice(0, 400);
443
+ if (!/"type"\s*:\s*"(?:response_item|event_msg)"/.test(head) ||
444
+ !/"type"\s*:\s*"(?:message|agent_message|function_call|command_execution|custom_tool_call)"/.test(head)) continue;
445
+ let d;
446
+ try { d = JSON.parse(line); } catch { continue; }
447
+
448
+ if (d.type === 'response_item' && d.payload) {
449
+ const p = d.payload;
450
+ if (p.type === 'message' && p.role === 'user') {
451
+ const texts = (p.content || [])
452
+ .filter(c => c.type === 'input_text' || c.type === 'text')
453
+ .map(c => c.text || '')
454
+ .filter(Boolean);
455
+ const text = texts.join('\n').trim();
456
+ // skip huge injected system/env blobs
457
+ if (text && !text.startsWith('# AGENTS.md') && !text.startsWith('<environment_context')
458
+ && !text.startsWith('<permissions')) {
459
+ seenAssistant.clear();
460
+ messages.push({ role: 'user', type: 'text', content: text, ts: d.timestamp });
461
+ }
462
+ } else if (p.type === 'message' && p.role === 'assistant') {
463
+ const texts = (p.content || [])
464
+ .filter(c => c.type === 'output_text' || c.type === 'text')
465
+ .map(c => c.text || '')
466
+ .filter(Boolean);
467
+ pushAssistant(texts.join('\n'), d.timestamp);
468
+ } else if (p.type === 'function_call') {
469
+ if (p.call_id && entry.seenCalls.has(p.call_id)) continue;
470
+ if (p.call_id) entry.seenCalls.add(p.call_id);
471
+ let input = p.arguments;
472
+ try { input = typeof input === 'string' ? JSON.parse(input) : input; } catch {}
473
+ messages.push({ role: 'tool', type: 'tool_use', name: p.name || 'function', id: p.call_id, input: input ?? {}, ts: d.timestamp });
474
+ } else if (p.type === 'command_execution' || p.type === 'custom_tool_call') {
475
+ if (p.call_id && entry.seenCalls.has(p.call_id)) continue;
476
+ if (p.call_id) entry.seenCalls.add(p.call_id);
477
+ let input = p.input;
478
+ try { input = typeof input === 'string' ? JSON.parse(input) : input; } catch {}
479
+ messages.push({
480
+ role: 'tool',
481
+ type: 'tool_use',
482
+ name: p.type === 'command_execution' ? 'Bash' : (p.name || 'tool'),
483
+ id: p.call_id,
484
+ input: p.type === 'command_execution' ? { command: p.command || '' } : (input ?? {}),
485
+ ts: d.timestamp,
486
+ });
410
487
  }
411
- } else if (p.type === 'message' && p.role === 'assistant') {
412
- const texts = (p.content || [])
413
- .filter(c => c.type === 'output_text' || c.type === 'text')
414
- .map(c => c.text || '')
415
- .filter(Boolean);
416
- pushAssistant(texts.join('\n'), d.timestamp);
417
- } else if (p.type === 'function_call') {
418
- let input = p.arguments;
419
- try { input = typeof input === 'string' ? JSON.parse(input) : input; } catch {}
420
- messages.push({ role: 'tool', type: 'tool_use', name: p.name || 'function', input: input ?? {}, ts: d.timestamp });
421
- } else if (p.type === 'command_execution' || p.type === 'custom_tool_call') {
422
- messages.push({
423
- role: 'tool',
424
- type: 'tool_use',
425
- name: p.type === 'command_execution' ? 'Bash' : (p.name || 'tool'),
426
- input: { command: p.command || p.input || '' },
427
- ts: d.timestamp,
428
- });
429
488
  }
430
- }
431
489
 
432
- // Secondary channel — skip if response_item already recorded the same body
433
- if (d.type === 'event_msg' && d.payload?.type === 'agent_message') {
434
- const text = d.payload.message || d.payload.text;
435
- pushAssistant(text, d.timestamp);
490
+ // Secondary channel — skip if response_item already recorded the same body
491
+ if (d.type === 'event_msg' && d.payload?.type === 'agent_message') {
492
+ const text = d.payload.message || d.payload.text;
493
+ pushAssistant(text, d.timestamp);
494
+ }
436
495
  }
496
+ pending = pending.subarray(start);
437
497
  }
438
- } catch {}
439
- return messages;
498
+ entry.size = stat.size;
499
+ entry.mtimeMs = stat.mtimeMs;
500
+ } catch (error) {
501
+ codexTranscripts.delete(filePath);
502
+ throw error;
503
+ }
440
504
  }
441
505
 
442
506
  function replaceSessionId(value, sourceSessionId, targetSessionId) {
@@ -496,6 +560,105 @@ async function forkCodexNativeSession(sourceSessionId, targetSessionId, codexHom
496
560
  return targetFile;
497
561
  }
498
562
 
563
+ function grokAssistantText(record) {
564
+ if (record?.type !== 'assistant') return '';
565
+ return extractGrokText(record.content).trim();
566
+ }
567
+
568
+ function grokUpdateKind(record) {
569
+ const update = record?.params?.update || record?.update || {};
570
+ return update.sessionUpdate || update.type || '';
571
+ }
572
+
573
+ function grokUpdateText(record) {
574
+ const update = record?.params?.update || record?.update || {};
575
+ return extractGrokText(update.content).trim();
576
+ }
577
+
578
+ /**
579
+ * Grok resumes from a session directory, not a synthetic chat transcript.
580
+ * Fork by cloning the native files and cutting both journals at the selected
581
+ * assistant reply so tool results and encrypted reasoning stay intact.
582
+ */
583
+ async function forkGrokNativeSession(sourceSessionId, targetSessionId, grokHome, assistantText, occurrence) {
584
+ const sourceDir = await findGrokSessionDir(sourceSessionId, grokHome);
585
+ if (!sourceDir) throw new Error('Grok source session was not found');
586
+
587
+ const sourceHistory = await fs.readFile(path.join(sourceDir, 'chat_history.jsonl'), 'utf8');
588
+ const historyOut = [];
589
+ let foundForkPoint = false;
590
+ let seenMatches = 0;
591
+ for (const line of sourceHistory.split(/\r?\n/)) {
592
+ if (!line.trim()) continue;
593
+ let record;
594
+ try { record = JSON.parse(line); } catch { continue; }
595
+ historyOut.push(JSON.stringify(replaceSessionId(record, sourceSessionId, targetSessionId)));
596
+ const text = grokAssistantText(record);
597
+ if (text && text === assistantText && ++seenMatches === occurrence) {
598
+ foundForkPoint = true;
599
+ break;
600
+ }
601
+ }
602
+ if (!foundForkPoint) {
603
+ throw new Error('Selected Grok assistant message was not found in the native transcript');
604
+ }
605
+
606
+ const targetDir = path.join(path.dirname(sourceDir), targetSessionId);
607
+ await fs.mkdir(targetDir, { recursive: true });
608
+ const histTmp = path.join(targetDir, `chat_history.jsonl.tmp-${crypto.randomUUID()}`);
609
+ await fs.writeFile(histTmp, `${historyOut.join('\n')}\n`, 'utf8');
610
+ await fs.rename(histTmp, path.join(targetDir, 'chat_history.jsonl'));
611
+
612
+ try {
613
+ const sourceUpdates = await fs.readFile(path.join(sourceDir, 'updates.jsonl'), 'utf8');
614
+ const updatesOut = [];
615
+ let acc = '';
616
+ let seenUpdateMatches = 0;
617
+ let matched = false;
618
+ for (const line of sourceUpdates.split(/\r?\n/)) {
619
+ if (!line.trim()) continue;
620
+ let record;
621
+ try { record = JSON.parse(line); } catch { continue; }
622
+ const kind = grokUpdateKind(record);
623
+ if (matched && (kind === 'user_message_chunk' || kind === 'agent_message_chunk')) break;
624
+ updatesOut.push(JSON.stringify(replaceSessionId(record, sourceSessionId, targetSessionId)));
625
+ if (kind === 'agent_message_chunk') {
626
+ acc = `${acc}${grokUpdateText(record)}`;
627
+ if (acc.trim() === assistantText && ++seenUpdateMatches === occurrence) matched = true;
628
+ } else if (kind === 'user_message_chunk' || kind === 'turn_completed') {
629
+ acc = '';
630
+ }
631
+ }
632
+ const updTmp = path.join(targetDir, `updates.jsonl.tmp-${crypto.randomUUID()}`);
633
+ await fs.writeFile(updTmp, updatesOut.length ? `${updatesOut.join('\n')}\n` : '', 'utf8');
634
+ await fs.rename(updTmp, path.join(targetDir, 'updates.jsonl'));
635
+ } catch {}
636
+
637
+ const copyNames = ['system_prompt.txt', 'prompt_context.json', 'resources_state.json', 'announcement_state.json', 'signals.json'];
638
+ for (const name of copyNames) {
639
+ try {
640
+ const raw = await fs.readFile(path.join(sourceDir, name), 'utf8');
641
+ let out = raw;
642
+ if (name.endsWith('.json')) {
643
+ try { out = JSON.stringify(replaceSessionId(JSON.parse(raw), sourceSessionId, targetSessionId), null, 2); }
644
+ catch { out = raw.replaceAll(sourceSessionId, targetSessionId); }
645
+ }
646
+ await fs.writeFile(path.join(targetDir, name), out, 'utf8');
647
+ } catch {}
648
+ }
649
+
650
+ try {
651
+ const summaryRaw = await fs.readFile(path.join(sourceDir, 'summary.json'), 'utf8');
652
+ const summary = replaceSessionId(JSON.parse(summaryRaw), sourceSessionId, targetSessionId);
653
+ if (summary.info && typeof summary.info === 'object') summary.info.id = targetSessionId;
654
+ else summary.info = { ...(summary.info || {}), id: targetSessionId };
655
+ summary.updated_at = new Date().toISOString();
656
+ await fs.writeFile(path.join(targetDir, 'summary.json'), JSON.stringify(summary, null, 2), 'utf8');
657
+ } catch {}
658
+
659
+ return targetDir;
660
+ }
661
+
499
662
  // ─── Grok ─────────────────────────────────────────────────────────────────────
500
663
 
501
664
  export async function scanGrokSessions(grokHome, filterCwd = null) {
@@ -512,7 +675,7 @@ export async function scanGrokSessions(grokHome, filterCwd = null) {
512
675
  // dir name is encodeURIComponent(cwd)
513
676
  let cwd;
514
677
  try { cwd = decodeURIComponent(dirEntry.name); } catch { cwd = dirEntry.name; }
515
- if (filterCwd && cwd !== filterCwd) return;
678
+ if (filterCwd && !pathsEqual(cwd, filterCwd)) return;
516
679
 
517
680
  const projectDir = path.join(sessionsRoot, dirEntry.name);
518
681
  let sessionDirs;
@@ -1092,7 +1255,7 @@ export async function scanGeminiSessions(geminiHome, filterCwd = null) {
1092
1255
  records.set(sessionId, existing);
1093
1256
  }));
1094
1257
  } catch {}
1095
- return [...records.values()].filter(s => !filterCwd || s.cwd === filterCwd);
1258
+ return [...records.values()].filter(s => !filterCwd || pathsEqual(s.cwd, filterCwd));
1096
1259
  }
1097
1260
 
1098
1261
  export async function parseGeminiMessages(filePath) {
@@ -1230,7 +1393,7 @@ export async function scanCursorSessions(cursorHome, filterCwd = null) {
1230
1393
  });
1231
1394
  }));
1232
1395
  } catch {}
1233
- return sessions.filter(s => !filterCwd || s.cwd === filterCwd);
1396
+ return sessions.filter(s => !filterCwd || pathsEqual(s.cwd, filterCwd));
1234
1397
  }
1235
1398
 
1236
1399
  async function loadCursorContext(sessionId, cursorHome) {
@@ -1654,10 +1817,10 @@ export async function readSessionMemoryFile(sessionId, agent, fileId, opts) {
1654
1817
  }
1655
1818
 
1656
1819
  /** Return bounded raw JSONL for a particular agent session, for Meta Agent analysis. */
1657
- export async function loadSessionJsonl(sessionId, agent, { claudeConfigDirs, codexHome, grokHome, geminiHome, cursorHome }, { offset = 0, maxLines = 500 } = {}) {
1820
+ export async function loadSessionJsonl(sessionId, agent, { claudeConfigDirs, codexHome, grokHome, geminiHome, cursorHome }, { offset = 0, maxLines = 500, tail = false, maxChars = 24000 } = {}) {
1658
1821
  if (!agent) {
1659
1822
  for (const candidate of ['claude', 'codex', 'grok', 'gemini', 'cursor']) {
1660
- const found = await loadSessionJsonl(sessionId, candidate, { claudeConfigDirs, codexHome, grokHome, geminiHome, cursorHome }, { offset, maxLines });
1823
+ const found = await loadSessionJsonl(sessionId, candidate, { claudeConfigDirs, codexHome, grokHome, geminiHome, cursorHome }, { offset, maxLines, tail, maxChars });
1661
1824
  if (found) return found;
1662
1825
  }
1663
1826
  return null;
@@ -1673,20 +1836,35 @@ export async function loadSessionJsonl(sessionId, agent, { claudeConfigDirs, cod
1673
1836
  file = dir ? path.join(dir, 'chat_history.jsonl') : null;
1674
1837
  } else file = await findClaudeSessionFile(sessionId, claudeConfigDirs);
1675
1838
  if (!file) return null;
1676
- const start = Math.max(0, Number(offset) || 0);
1677
- const limit = Math.min(Math.max(1, Number(maxLines) || 500), 2000);
1839
+ const start = Math.max(0, Math.floor(Number(offset) || 0));
1840
+ const limit = Math.min(Math.max(1, Math.floor(Number(maxLines) || 100)), 500);
1841
+ const budget = Math.min(Math.max(1000, Number(maxChars) || 24000), 64000);
1678
1842
  const lines = [];
1679
- let n = 0;
1680
- let hasMore = false;
1843
+ let n = 0, chars = 0, hasMore = false, clipped = false;
1681
1844
  const stream = fsSync.createReadStream(file, { encoding: 'utf8' });
1682
1845
  const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
1683
- for await (const line of rl) {
1684
- if (n++ < start) continue;
1685
- if (lines.length >= limit) { hasMore = true; rl.close(); stream.destroy(); break; }
1686
- lines.push(line);
1687
- }
1846
+ try {
1847
+ for await (const line of rl) {
1848
+ const index = n++;
1849
+ if (!tail && index < start) continue;
1850
+ if (!tail && (lines.length >= limit || chars >= budget)) { hasMore = true; break; }
1851
+ // Bound huge tool output rows as well as total response size. Clipping
1852
+ // is explicit: a clipped line must not be mistaken for complete JSON.
1853
+ const cap = tail ? budget : budget - chars;
1854
+ const text = line.slice(0, cap);
1855
+ if (text.length < line.length) clipped = true;
1856
+ lines.push(text); chars += text.length;
1857
+ if (tail) while (lines.length > limit || (chars > budget && lines.length > 1)) {
1858
+ chars -= lines.shift().length;
1859
+ }
1860
+ }
1861
+ } finally { rl.close(); stream.destroy(); }
1688
1862
  const stat = await fs.stat(file).catch(() => null);
1689
- return { agent: a, fileName: path.basename(file), offset: start, lines, nextOffset: start + lines.length, truncated: hasMore, size: stat?.size || 0 };
1863
+ const actualOffset = tail ? Math.max(0, n - lines.length) : start;
1864
+ return { agent: a, fileName: path.basename(file), offset: actualOffset, lines,
1865
+ nextOffset: actualOffset + lines.length, truncated: hasMore, clipped,
1866
+ tail, size: stat?.size || 0, updatedAt: stat?.mtimeMs || null };
1867
+
1690
1868
  }
1691
1869
 
1692
1870
  export async function getSessionMeta(sessionId, agent, { claudeConfigDirs, codexHome, grokHome, geminiHome, cursorHome }) {
@@ -1796,6 +1974,17 @@ export async function convertSession(sourceSessionId, sourceAgent, targetAgent,
1796
1974
  } catch {}
1797
1975
  return { targetSessionId };
1798
1976
  }
1977
+
1978
+ if (ta === 'grok' && sourceAgent.toLowerCase() === 'grok' && Number.isInteger(upTo)) {
1979
+ const selected = messages[messages.length - 1];
1980
+ if (selected?.role !== 'assistant' || !selected.content?.trim()) {
1981
+ throw new Error('A Grok fork must end at a persisted assistant message');
1982
+ }
1983
+ const occurrence = messages.filter(message => message.role === 'assistant'
1984
+ && message.content?.trim() === selected.content.trim()).length;
1985
+ await forkGrokNativeSession(sourceSessionId, targetSessionId, grokHome, selected.content.trim(), occurrence);
1986
+ return { targetSessionId };
1987
+ }
1799
1988
 
1800
1989
  if (ta === 'claude') {
1801
1990
  const configDir = claudeConfigDirs?.[0] || path.join(os.homedir(), '.claude');
File without changes