compact-agent 1.33.6 → 1.35.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.
package/bin/crowcoder.js CHANGED
@@ -61,4 +61,98 @@
61
61
  }
62
62
  })();
63
63
 
64
+ // ── --prompt / --prompt-file (non-interactive single-chain mode) ──
65
+ //
66
+ // When this CLI is being driven by an external harness (Terminal-Bench,
67
+ // CI scripts, etc.) we need a way to:
68
+ // 1. accept a prompt without opening the REPL
69
+ // 2. run one runQuery chain to completion
70
+ // 3. exit with a meaningful code (0 = success, 1 = error)
71
+ //
72
+ // Two surface forms:
73
+ // --prompt "do the thing" — inline text
74
+ // --prompt-file path/to/task.txt — read from disk; useful for long
75
+ // or multi-line task descriptions
76
+ // that would otherwise need careful
77
+ // shell quoting.
78
+ //
79
+ // We export the resolved prompt via COMPACT_AGENT_PROMPT (or
80
+ // COMPACT_AGENT_PROMPT_FILE, which the loader reads with fs.readFile).
81
+ // src/index.ts branches on these env vars near the top of main() and
82
+ // skips the REPL entirely.
83
+ //
84
+ // A bare `--non-interactive` flag is also accepted as a no-prompt
85
+ // signal — useful when paired with a config that already has
86
+ // `__crowcoderQueuedInput` set, but mostly an alias for the same path.
87
+ // ESM-safe sync FS load. import() is async (returns a promise) and the
88
+ // flag parser MUST run synchronously before the dynamic import of
89
+ // dist/index.js below. createRequire gives us a real CommonJS require
90
+ // inside ESM — same path Node recommends for sync fs in ESM scripts.
91
+ const { createRequire } = await import('node:module');
92
+ const __require = createRequire(import.meta.url);
93
+
94
+ (() => {
95
+ const fs = __require('node:fs');
96
+ const argv = process.argv;
97
+ for (let i = 2; i < argv.length; i++) {
98
+ const a = argv[i];
99
+ if (a === '--prompt') {
100
+ const next = argv[i + 1];
101
+ if (typeof next !== 'string') {
102
+ process.stderr.write('[compact-agent] --prompt requires an argument.\n');
103
+ process.exit(2);
104
+ }
105
+ process.env.COMPACT_AGENT_PROMPT = next;
106
+ process.env.COMPACT_AGENT_NON_INTERACTIVE = '1';
107
+ argv.splice(i, 2);
108
+ i--;
109
+ continue;
110
+ }
111
+ if (a && a.startsWith('--prompt=')) {
112
+ process.env.COMPACT_AGENT_PROMPT = a.slice('--prompt='.length);
113
+ process.env.COMPACT_AGENT_NON_INTERACTIVE = '1';
114
+ argv.splice(i, 1);
115
+ i--;
116
+ continue;
117
+ }
118
+ if (a === '--prompt-file') {
119
+ const next = argv[i + 1];
120
+ if (typeof next !== 'string') {
121
+ process.stderr.write('[compact-agent] --prompt-file requires a path.\n');
122
+ process.exit(2);
123
+ }
124
+ try {
125
+ process.env.COMPACT_AGENT_PROMPT = fs.readFileSync(next, 'utf8');
126
+ } catch (err) {
127
+ process.stderr.write(`[compact-agent] could not read --prompt-file: ${err && err.message ? err.message : err}\n`);
128
+ process.exit(2);
129
+ }
130
+ process.env.COMPACT_AGENT_NON_INTERACTIVE = '1';
131
+ argv.splice(i, 2);
132
+ i--;
133
+ continue;
134
+ }
135
+ if (a === '--non-interactive') {
136
+ process.env.COMPACT_AGENT_NON_INTERACTIVE = '1';
137
+ argv.splice(i, 1);
138
+ i--;
139
+ continue;
140
+ }
141
+ // --perm <mode>: override permission mode without touching saved
142
+ // config. Critical for harness runs that want yolo without
143
+ // mutating the user's interactive config file.
144
+ if (a === '--perm') {
145
+ const next = argv[i + 1];
146
+ if (next && /^(ask|auto|yolo)$/.test(next)) {
147
+ process.env.COMPACT_AGENT_PERM_OVERRIDE = next;
148
+ argv.splice(i, 2);
149
+ i--;
150
+ continue;
151
+ }
152
+ process.stderr.write('[compact-agent] --perm requires ask|auto|yolo.\n');
153
+ process.exit(2);
154
+ }
155
+ }
156
+ })();
157
+
64
158
  import('../dist/index.js');
package/dist/index.js CHANGED
@@ -2679,14 +2679,33 @@ async function main() {
2679
2679
  }
2680
2680
  catch { /* never block startup on this */ }
2681
2681
  }
2682
- // Load or create config
2682
+ // Load or create config.
2683
+ //
2684
+ // Non-interactive mode (COMPACT_AGENT_NON_INTERACTIVE=1) requires a
2685
+ // pre-existing config — the setup wizard would block on stdin
2686
+ // forever in a piped/headless environment. We bail with a clear
2687
+ // error if no config is on disk, so the caller knows to run the
2688
+ // wizard interactively first (`compact-agent` with no args).
2689
+ const nonInteractive = process.env.COMPACT_AGENT_NON_INTERACTIVE === '1';
2683
2690
  let config;
2684
2691
  if (!configExists()) {
2692
+ if (nonInteractive) {
2693
+ process.stderr.write('[compact-agent] non-interactive mode requires a pre-existing config at ~/.compact-agent/config.json.\n' +
2694
+ 'Run `compact-agent` once interactively to walk through the setup wizard, OR write the config manually.\n');
2695
+ process.exit(2);
2696
+ }
2685
2697
  config = await setupWizard(rl);
2686
2698
  }
2687
2699
  else {
2688
2700
  config = loadConfig();
2689
2701
  }
2702
+ // Per-invocation permission override (--perm flag). Doesn't touch
2703
+ // saved config — purely a runtime knob so harness runs can force
2704
+ // yolo without mutating the user's interactive permission setting.
2705
+ const permOverride = process.env.COMPACT_AGENT_PERM_OVERRIDE;
2706
+ if (permOverride === 'ask' || permOverride === 'auto' || permOverride === 'yolo') {
2707
+ config.permissionMode = permOverride;
2708
+ }
2690
2709
  // Apply the user's chosen color palette before anything paints. setPalette
2691
2710
  // mutates the exported `theme` object in place so the banner, prompt, and
2692
2711
  // every subsequent log line render in the right colors.
@@ -2740,9 +2759,14 @@ async function main() {
2740
2759
  if (memoryContext) {
2741
2760
  messages.push({ role: 'system', content: memoryContext });
2742
2761
  }
2743
- // Show startup display based on theme setting
2762
+ // Show startup display based on theme setting. Skipped entirely in
2763
+ // non-interactive mode — banners are noise when a harness is parsing
2764
+ // our stdout.
2744
2765
  const themeMode = config.theme || 'full';
2745
- if (themeMode === 'full') {
2766
+ if (nonInteractive) {
2767
+ // intentionally no output
2768
+ }
2769
+ else if (themeMode === 'full') {
2746
2770
  // Full mode: banner. ASCII splash removed per user request — both `full`
2747
2771
  // and `compact` themes now render the same banner block.
2748
2772
  printThemedBanner(config.provider, config.model, mode.current, config.permissionMode, session.id, ALL_TOOLS.map((t) => t.name));
@@ -2819,7 +2843,13 @@ async function main() {
2819
2843
  // (the promises variant doesn't expose it). Some platforms / terminals
2820
2844
  // don't deliver every F-key — failure here is a silent no-op; users can
2821
2845
  // fall back to /dictate and /voice slash commands.
2846
+ //
2847
+ // Skipped in non-interactive mode — there's no user at the keyboard,
2848
+ // and listening to keypress would consume bytes from the harness's
2849
+ // piped stdin that may or may not look like F-keys.
2822
2850
  try {
2851
+ if (nonInteractive)
2852
+ throw new Error('skip:nonInteractive');
2823
2853
  const readlineCb = await import('node:readline');
2824
2854
  const { describeStatus, describeLocation } = await import('./status.js');
2825
2855
  readlineCb.emitKeypressEvents(stdin);
@@ -3530,6 +3560,85 @@ async function main() {
3530
3560
  permissionMode: config.permissionMode,
3531
3561
  cwd: process.cwd(),
3532
3562
  });
3563
+ // ── Non-interactive single-chain mode ─────────────────
3564
+ //
3565
+ // Triggered by `--prompt <text>` / `--prompt-file <path>` (parsed in
3566
+ // bin/crowcoder.js and stashed on COMPACT_AGENT_PROMPT). We push the
3567
+ // prompt as one user message, run a single runQuery to completion,
3568
+ // and exit. No REPL, no banner, no hotkey listener, no live queue.
3569
+ //
3570
+ // This is the entrypoint that lets external harnesses (Terminal-Bench,
3571
+ // CI scripts, etc.) drive compact-agent with a single task and read
3572
+ // its output cleanly. Stdin is left untouched — readline never
3573
+ // attaches — so piped stdin won't confuse anything.
3574
+ if (process.env.COMPACT_AGENT_NON_INTERACTIVE === '1') {
3575
+ const promptText = process.env.COMPACT_AGENT_PROMPT;
3576
+ if (!promptText || !promptText.trim()) {
3577
+ process.stderr.write('[compact-agent] non-interactive mode requires --prompt <text> or --prompt-file <path>.\n');
3578
+ process.exit(2);
3579
+ }
3580
+ // ── F9: Empty-engagement guard (non-interactive nudge) ──
3581
+ //
3582
+ // Some failures in the 2026-05-25 baseline run came from the
3583
+ // model emitting a single no-tool-call response and exiting —
3584
+ // never actually attempting the work. polyglot-c-py, solana-data,
3585
+ // and vim-terminal-task all showed this pattern. The model
3586
+ // interpreted some aspect of the spec as "I can't do this" (e.g.
3587
+ // "use vim" suggesting interactive editing) and bailed.
3588
+ //
3589
+ // In non-interactive mode there's no human to push back, so we
3590
+ // prepend a system message that explicitly frames the contract:
3591
+ // the agent must DO the work, with tools. Responses without tool
3592
+ // calls are interpreted as "I'm done" — and F5+ DeCRIM will
3593
+ // then walk the agent through verification.
3594
+ //
3595
+ // This is system-prompt-level and doesn't repeat per-turn (that
3596
+ // would bloat context). It's a one-shot priming injection.
3597
+ messages.push({
3598
+ role: 'system',
3599
+ content: 'You are running in NON-INTERACTIVE mode: no human will answer follow-up questions. ' +
3600
+ 'You must DO the work using the available tools (bash, write, edit, read, glob, grep, etc.) — ' +
3601
+ 'not describe what would need to be done. ' +
3602
+ 'If the task mentions a specific tool you do not have direct access to (e.g. "use vim"), ' +
3603
+ 'achieve the equivalent effect with the tools you do have. ' +
3604
+ 'If you lack information, USE A TOOL to investigate; do not ask the user. ' +
3605
+ 'A response with no tool calls is interpreted as "I am done" and triggers final verification.',
3606
+ });
3607
+ messages.push({ role: 'user', content: promptText.trim() });
3608
+ try {
3609
+ await runQuery({
3610
+ config,
3611
+ messages,
3612
+ cwd: process.cwd(),
3613
+ rl,
3614
+ sessionId: session.id,
3615
+ mode: mode.current,
3616
+ });
3617
+ // Run any session-stop hooks the user registered.
3618
+ try {
3619
+ await runHooks({ event: 'SessionStop', sessionId: session.id, cwd: process.cwd(), permissionMode: config.permissionMode });
3620
+ }
3621
+ catch { /* never fail an otherwise-successful run on hook errors */ }
3622
+ // Close readline so the Node process can exit cleanly. Without
3623
+ // this, the readline interface keeps the event loop alive until
3624
+ // the user types something (which they can't, since stdin is
3625
+ // piped from a harness).
3626
+ try {
3627
+ rl.close();
3628
+ }
3629
+ catch { /* noop */ }
3630
+ process.exit(0);
3631
+ }
3632
+ catch (err) {
3633
+ const msg = err instanceof Error ? err.message : String(err);
3634
+ process.stderr.write(`[compact-agent] chain failed: ${msg}\n`);
3635
+ try {
3636
+ rl.close();
3637
+ }
3638
+ catch { /* noop */ }
3639
+ process.exit(1);
3640
+ }
3641
+ }
3533
3642
  // Main REPL loop
3534
3643
  while (true) {
3535
3644
  let input;