compact-agent 1.27.2 → 1.28.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -5,6 +5,7 @@ import { readFileSync as fsReadFileSync, writeFileSync as fsWriteFileSync, unlin
5
5
  import { tmpdir } from 'node:os';
6
6
  import { join as pathJoin } from 'node:path';
7
7
  import { spawnSync } from 'node:child_process';
8
+ import { initDebug, emit as dbgEmit, setDebugLevel, getDebugStatus, tailDebug } from './debug.js';
8
9
  import chalk from 'chalk';
9
10
  import { loadConfig, saveConfig, configExists, getConfigDir } from './config.js';
10
11
  import { resetClient } from './api.js';
@@ -404,9 +405,19 @@ export function handleSlashCommand(input, config, messages, session, mode) {
404
405
  }
405
406
  return { handled: true };
406
407
  // ── Clear ─────────────────────────────────────────
407
- case '/clear':
408
+ case '/clear': {
409
+ // Also reset the global state that's keyed to the conversation
410
+ // so Shift+F3 / Shift+F1 / F12 don't surface stale data from
411
+ // before the clear. The main REPL loop replaces `messages` via
412
+ // `newMessages`; we just nuke the side-channel globals.
413
+ const g = globalThis;
414
+ g.__crowcoderQueuedInput = '';
415
+ g.__voiceLastFullResponse = null;
416
+ g.__voiceLastChunk = null;
417
+ g.__lastToolCall = null;
408
418
  console.log(chalk.dim(' Conversation cleared.'));
409
419
  return { handled: true, newMessages: [] };
420
+ }
410
421
  // ── Backtrack — rewind to a prior user turn (Codex audit item 4) ──
411
422
  // /back list recent user messages with numbers
412
423
  // /back <n> truncate conversation to BEFORE the nth most-recent
@@ -465,14 +476,23 @@ export function handleSlashCommand(input, config, messages, session, mode) {
465
476
  case '/fork':
466
477
  case '/branch': {
467
478
  const forkName = args.trim() || `fork of ${session.name}`;
468
- // Save the current session under its existing ID first so the
469
- // pre-fork state is recoverable via /resume.
470
- saveSession({ ...session, messages: [...messages] }).catch(() => { });
479
+ // Snapshot the pre-fork state in a SEPARATE object before
480
+ // mutating the live `session` reference — otherwise both save
481
+ // calls write to whichever ID the mutation has currently set,
482
+ // overwriting the original branch and silently breaking the
483
+ // "previous session reachable via /resume" promise. (Bug found
484
+ // by audit; previously the spread happened AFTER `session.id`
485
+ // was reassigned, so both saves landed under the new ID.)
471
486
  const previousId = session.id;
472
- // Mutate the active session in place: new ID + name + timestamps,
473
- // same messages. The REPL keeps running against `session` so
474
- // mutation is enough — no need to plumb a "swap" through the
475
- // return value.
487
+ const previousSnapshot = {
488
+ ...session,
489
+ id: previousId,
490
+ messages: [...messages],
491
+ };
492
+ saveSession(previousSnapshot).catch(() => { });
493
+ // Now mutate the active session in place. The REPL keeps
494
+ // running against `session` so mutation is enough — no need
495
+ // to plumb a swap through the return value.
476
496
  session.id = generateSessionId();
477
497
  session.name = forkName;
478
498
  session.createdAt = new Date().toISOString();
@@ -544,6 +564,72 @@ export function handleSlashCommand(input, config, messages, session, mode) {
544
564
  console.log(chalk.dim(` Sending ${result.length} chars from editor…`));
545
565
  return { handled: true, injectPrompt: result };
546
566
  }
567
+ // ── Debug — toggle instrumentation + tail event log ──
568
+ // Surface for the NDJSON debug stream written to
569
+ // ~/.compact-agent/debug/<sessionId>.jsonl. Used by reviewers
570
+ // driving the agent + by users diagnosing their own issues.
571
+ //
572
+ // /debug show current level + log path + event count
573
+ // /debug on [level] turn instrumentation on (default level: info)
574
+ // /debug off turn instrumentation off (existing log file is kept)
575
+ // /debug tail [N] print the last N events (default 20) inline
576
+ case '/debug': {
577
+ const parts = args.trim().split(/\s+/).filter(Boolean);
578
+ const sub = (parts[0] || '').toLowerCase();
579
+ if (!sub) {
580
+ const s = getDebugStatus();
581
+ console.log(chalk.dim(` Debug level: ${s.level}`));
582
+ console.log(chalk.dim(` Log file: ${s.logPath || '(no log — level is off)'}`));
583
+ console.log(chalk.dim(` Events: ${s.eventCount} this session`));
584
+ console.log(chalk.dim(` Uptime: ${(s.uptimeMs / 1000).toFixed(1)}s`));
585
+ console.log('');
586
+ console.log(chalk.dim(` /debug on [info|debug|trace]`));
587
+ console.log(chalk.dim(` /debug off`));
588
+ console.log(chalk.dim(` /debug tail [N]`));
589
+ return { handled: true };
590
+ }
591
+ if (sub === 'off') {
592
+ setDebugLevel('off');
593
+ console.log(chalk.green(' Debug: off'));
594
+ return { handled: true };
595
+ }
596
+ if (sub === 'on') {
597
+ const lvl = (parts[1] || 'info').toLowerCase();
598
+ if (lvl !== 'info' && lvl !== 'debug' && lvl !== 'trace') {
599
+ console.log(chalk.yellow(` Unknown level "${lvl}". Use info, debug, or trace.`));
600
+ return { handled: true };
601
+ }
602
+ setDebugLevel(lvl);
603
+ const s = getDebugStatus();
604
+ console.log(chalk.green(` Debug: ${lvl} — writing to ${s.logPath}`));
605
+ return { handled: true };
606
+ }
607
+ if (sub === 'tail') {
608
+ const n = parseInt(parts[1] || '20', 10);
609
+ const lines = tailDebug(Number.isFinite(n) && n > 0 ? n : 20);
610
+ if (lines.length === 0) {
611
+ console.log(chalk.dim(' (no events — turn on with /debug on)'));
612
+ return { handled: true };
613
+ }
614
+ console.log(chalk.dim(` Last ${lines.length} debug events:`));
615
+ for (const ln of lines) {
616
+ try {
617
+ const rec = JSON.parse(ln);
618
+ const ts = String(rec.rel || 0).padStart(6) + 'ms';
619
+ const lvl = (rec.lvl || '').toUpperCase().padEnd(5);
620
+ const ev = rec.ev || '';
621
+ const d = rec.data ? ' ' + JSON.stringify(rec.data).slice(0, 120) : '';
622
+ console.log(chalk.dim(` ${ts} ${lvl} ${ev}${d}`));
623
+ }
624
+ catch {
625
+ console.log(chalk.dim(` ${ln.slice(0, 200)}`));
626
+ }
627
+ }
628
+ return { handled: true };
629
+ }
630
+ console.log(chalk.yellow(` Unknown /debug subcommand "${sub}". Try: /debug, /debug on, /debug off, /debug tail`));
631
+ return { handled: true };
632
+ }
547
633
  // ── History ───────────────────────────────────────
548
634
  case '/history': {
549
635
  const stats = getCompactionStats(messages);
@@ -2033,11 +2119,22 @@ export function handleSlashCommand(input, config, messages, session, mode) {
2033
2119
  // user is testing the pipeline or running under a terminal that strips
2034
2120
  // function keys. Records up to 30s, transcribes, injects as next prompt.
2035
2121
  case '/dictate': {
2036
- const maxSec = parseInt(args, 10) || 30;
2037
- console.log(chalk.dim(` /dictate recording up to ${maxSec}s…`));
2122
+ // Parse + clamp the duration argument.
2123
+ // /dictate 30s default
2124
+ // /dictate 60 → 60s, clamped to [1, 300]
2125
+ // /dictate 0 → 30s (user clearly wanted default or
2126
+ // cancel; previously parseInt(0) || 30
2127
+ // gave 30 silently)
2128
+ // /dictate -5 → 30s (negative is nonsense)
2129
+ // /dictate abc → 30s default
2130
+ const parsed = parseInt(args, 10);
2131
+ const sec = Number.isFinite(parsed) && parsed > 0
2132
+ ? Math.min(300, parsed)
2133
+ : 30;
2134
+ console.log(chalk.dim(` /dictate — recording up to ${sec}s…`));
2038
2135
  // Return as an async-injected prompt; we resolve the recording
2039
2136
  // synchronously here for simplicity (REPL is blocking anyway).
2040
- return { handled: true, injectPrompt: '__DICTATE__' + maxSec };
2137
+ return { handled: true, injectPrompt: '__DICTATE__' + sec };
2041
2138
  }
2042
2139
  // /accessibility — show or toggle the accessibility sub-block
2043
2140
  // /accessibility — print status
@@ -2203,6 +2300,19 @@ async function main() {
2203
2300
  // Create session
2204
2301
  const mode = { current: 'dev' };
2205
2302
  const session = createSession(process.cwd(), config.model, config.provider, mode.current);
2303
+ // ── Debug instrumentation ─────────────────────────────────
2304
+ // Initialize early so subsequent emit() calls land in the right file.
2305
+ // Reads $COMPACT_AGENT_DEBUG which bin/crowcoder.js may have set from
2306
+ // the --debug CLI flag. Level 'off' is a no-op; non-off opens an
2307
+ // NDJSON log at ~/.compact-agent/debug/<sessionId>.jsonl.
2308
+ initDebug(session.id);
2309
+ dbgEmit('info', 'session.start', {
2310
+ cwd: process.cwd(),
2311
+ model: config.model,
2312
+ provider: config.provider,
2313
+ mode: mode.current,
2314
+ permissionMode: config.permissionMode,
2315
+ });
2206
2316
  const messages = [];
2207
2317
  // Session start hook + memory persistence
2208
2318
  await runHooks({ event: 'SessionStart', sessionId: session.id, cwd: process.cwd(), permissionMode: config.permissionMode });
@@ -2534,15 +2644,29 @@ async function main() {
2534
2644
  // Second Esc within window — fire /back.
2535
2645
  lastEscapeMs = 0;
2536
2646
  // Enqueue the slash command as queued input. The REPL loop
2537
- // picks it up + executes /back the same way as if typed.
2647
+ // picks it up + dispatches /back on the next iteration.
2538
2648
  globalThis.__crowcoderQueuedInput = '/back\n';
2539
2649
  announce('Esc-Esc', 'Rewinding to previous user turn.');
2540
- // Nudge readline by writing an empty line so the question
2541
- // resolves and the main loop's queued-input drain fires.
2650
+ // Resolve the pending rl.question() so the main loop
2651
+ // actually moves on. Previously this used stdin.write('\n')
2652
+ // which DOESN'T interrupt readline — it merely adds a
2653
+ // newline to the input stream that readline reads as if
2654
+ // the user typed it, leaving the REPL stuck until the user
2655
+ // pressed Enter manually. emit('line', '') triggers the
2656
+ // 'line' event that rl.question internally listens for,
2657
+ // resolving the promise immediately. (Bug found by audit.)
2542
2658
  try {
2543
- stdin.write('\n');
2659
+ rl.emit('line', '');
2660
+ }
2661
+ catch {
2662
+ // Fallback path if rl.emit isn't accepted for some reason
2663
+ // (e.g. on a future readline version): still nudge via
2664
+ // stdin so the user can recover by pressing any key.
2665
+ try {
2666
+ stdin.write('\n');
2667
+ }
2668
+ catch { /* noop */ }
2544
2669
  }
2545
- catch { /* noop */ }
2546
2670
  return;
2547
2671
  }
2548
2672
  lastEscapeMs = now;
@@ -2868,6 +2992,8 @@ async function main() {
2868
2992
  }
2869
2993
  // Slash commands
2870
2994
  if (trimmed.startsWith('/')) {
2995
+ // Truncate arg preview so debug logs don't blow up on /editor seeds etc.
2996
+ dbgEmit('debug', 'slash.dispatch', { input: trimmed.slice(0, 200) });
2871
2997
  const result = handleSlashCommand(trimmed, config, messages, session, mode);
2872
2998
  if (result.shouldExit)
2873
2999
  break;