@hackerrank/astra-cli 0.1.0 → 0.1.2

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/src/model.js CHANGED
@@ -55,6 +55,10 @@ export class GatewayModel {
55
55
  // Cumulative token usage across all calls (exact, from the API).
56
56
  this.totalPromptTokens = 0;
57
57
  this.totalCompletionTokens = 0;
58
+ this.totalReasoningTokens = 0;
59
+ this.totalCachedTokens = 0;
60
+ this.totalCacheWriteTokens = 0;
61
+ this.nRetries = 0;
58
62
  // Cumulative USD cost. `reported` = summed from the gateway's usage.cost;
59
63
  // `estimated` = summed from the public price table (prices.js).
60
64
  this.totalCostUsd = 0;
@@ -63,6 +67,9 @@ export class GatewayModel {
63
67
  this.costSource = null; // "reported" | "estimated" | "mixed" | null
64
68
  // Preferred token-cap parameter; swapped automatically on 400 if needed.
65
69
  this._tokenParam = "max_tokens";
70
+ // Optional AbortSignal; when set and aborted, in-flight requests are
71
+ // cancelled (used to force-quit a long model call on a second Ctrl+C).
72
+ this.signal = null;
66
73
  if (!this.apiKey) {
67
74
  throw new Error(
68
75
  "GatewayModel: no API key. Set ASTRA_GATEWAY_API_KEY or configure ~/.astra/config.json."
@@ -96,6 +103,7 @@ export class GatewayModel {
96
103
  Authorization: `Bearer ${this.apiKey}`,
97
104
  },
98
105
  body: JSON.stringify(body),
106
+ signal: this.signal || undefined,
99
107
  });
100
108
 
101
109
  if (!res.ok) {
@@ -113,6 +121,7 @@ export class GatewayModel {
113
121
  // backoff (honoring Retry-After when the server provides it).
114
122
  if ((res.status === 429 || res.status >= 500) && attempt < this.maxRetries) {
115
123
  const wait = retryAfterMs(res.headers) ?? backoffMs(attempt);
124
+ this.nRetries++;
116
125
  this.onRetry({
117
126
  attempt: attempt + 1,
118
127
  maxRetries: this.maxRetries,
@@ -133,6 +142,9 @@ export class GatewayModel {
133
142
  const usage = normalizeUsage(data?.usage);
134
143
  this.totalPromptTokens += usage.prompt_tokens;
135
144
  this.totalCompletionTokens += usage.completion_tokens;
145
+ this.totalReasoningTokens += usage.reasoning_tokens || 0;
146
+ this.totalCachedTokens += usage.cached_tokens || 0;
147
+ this.totalCacheWriteTokens += usage.cache_write_tokens || 0;
136
148
  // Cost: prefer the gateway's exact number; otherwise estimate from the
137
149
  // public price table. Attach per-call cost + source to usage.
138
150
  const cost = this._accountCost(usage);
@@ -145,6 +157,7 @@ export class GatewayModel {
145
157
  // (auth, quota, context window) are re-thrown immediately.
146
158
  if (attempt < this.maxRetries && isRetryable(err)) {
147
159
  const wait = backoffMs(attempt);
160
+ this.nRetries++;
148
161
  this.onRetry({
149
162
  attempt: attempt + 1,
150
163
  maxRetries: this.maxRetries,
@@ -219,8 +232,10 @@ export class GatewayModel {
219
232
 
220
233
  function isRetryable(err) {
221
234
  // Never retry classified terminal errors (auth/quota/bad-request) or context
222
- // overflow — those won't succeed on retry.
235
+ // overflow — those won't succeed on retry. Also never retry an aborted
236
+ // request (user force-quit via Ctrl+C).
223
237
  if (err instanceof GatewayError || err instanceof ContextWindowError) return false;
238
+ if (err?.name === "AbortError") return false;
224
239
  const msg = String(err?.message || err);
225
240
  return /HTTP 5\d\d|HTTP 429|ECONNRESET|ETIMEDOUT|fetch failed|network/i.test(msg);
226
241
  }
package/src/models.js ADDED
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Central catalog of gateway models and reasoning levels used by astra.
3
+ *
4
+ * Kept in one place so the CLI, interactive setup, and any list-based pickers
5
+ * stay in sync. The list mirrors the context-window table in repl.js.
6
+ */
7
+
8
+ /** Selectable model ids on the gateway. */
9
+ export const AVAILABLE_MODELS = [
10
+ "claude-opus-5",
11
+ "claude-sonnet-5",
12
+ "gemini-3.7-flash",
13
+ "gpt-5.6-luna",
14
+ "gpt-5.6-sol",
15
+ "gpt-5.6-terra",
16
+ "grok-4.6",
17
+ "deepseek-v4-pro",
18
+ "glm-5.2",
19
+ "kimi-k3",
20
+ "qwen-3.8",
21
+ ];
22
+
23
+ /** Selectable reasoning-effort levels. */
24
+ export const REASONING_LEVELS = ["off", "low", "medium", "high"];
25
+
26
+ /**
27
+ * Ultimate fallback when no model is configured, cached, or chosen.
28
+ * (Requested behavior: fall back to glm-5.2 with medium reasoning.)
29
+ */
30
+ export const DEFAULT_MODEL = "glm-5.2";
31
+ export const DEFAULT_REASONING = "medium";
package/src/prompts.js CHANGED
@@ -42,13 +42,17 @@ Autonomous mode:
42
42
  After that command you cannot continue.`;
43
43
 
44
44
  export const INTERACTIVE_RULES = `
45
- Interactive mode:
46
- - You are a helpful pair-programming assistant. Work with the user turn by turn.
45
+ Agent mode (interactive SWE assistant):
46
+ - You are astra, a sharp, pragmatic senior software engineer pair-programming
47
+ with the user in their shell. Be concise, direct, and technically precise.
48
+ - Work turn by turn. Prefer small, verifiable steps over large speculative ones.
47
49
  - To run a shell command, use the bash block as described above.
48
50
  - To talk to the user (answer a question, ask for clarification, summarize, or
49
51
  report you are done) respond with plain text and NO bash block. That hands
50
52
  the turn back to the user.
51
- - Prefer small, verifiable steps. Do not fabricate command output.`;
53
+ - Explain what you are about to do in one or two lines before acting.
54
+ - Never fabricate command output; always run the command and react to what you
55
+ actually observe.`;
52
56
 
53
57
  export const INSTANCE_TEMPLATE = `Please complete this task:
54
58
 
package/src/repl.js CHANGED
@@ -15,7 +15,8 @@ import path from "node:path";
15
15
  import { execFileSync } from "node:child_process";
16
16
  import readline from "node:readline";
17
17
  import { fileURLToPath } from "node:url";
18
- import { ask } from "./config.js";
18
+ import { ask, saveAgentPrefs } from "./config.js";
19
+ import { AVAILABLE_MODELS, REASONING_LEVELS } from "./models.js";
19
20
 
20
21
  const C = {
21
22
  dim: (s) => `\x1b[2m${s}\x1b[0m`,
@@ -55,9 +56,9 @@ function pkgVersion() {
55
56
  // ASCII wordmark (figlet "standard" style) shown when a new chat starts.
56
57
  const BANNER = [
57
58
  " _ ",
58
- " __ _ ___ | |_ __ __ __ _ ",
59
- " / _` |/ __| | __| |__ __| / _` |",
60
- "| (_| |\\__ \\ | |_ | | | (_| |",
59
+ " __ _ ___ | |_ _ __ __ _ ",
60
+ " / _` |/ __| | __| | '__| / _` |",
61
+ "| (_| |\\__ \\ | |_ | | | (_| |",
61
62
  " \\__,_||___/ \\__| |_| \\__,_|",
62
63
  ];
63
64
 
@@ -131,13 +132,16 @@ function turnSummary({ turn, upTok, downTok, costUsd, costKind, seconds }) {
131
132
  * and cumulative token/context/cost usage. Returned as an array so the sticky
132
133
  * screen can pin them to the bottom rows.
133
134
  */
134
- function footerLines(agent, model, yolo) {
135
+ function footerLines(agent, model, yolo, mode = "agent") {
135
136
  const cols = process.stdout.columns || 100;
136
- const rule = "─".repeat(cols);
137
+ // Leave one cell of slack so a full-width rule never wraps to a new row
138
+ // (which pushes the whole footer up and looks broken on some terminals).
139
+ const rule = "─".repeat(Math.max(1, cols - 1));
137
140
 
138
- const reasoning = model.modelKwargs?.reasoning_effort || model.modelKwargs?.reasoning?.effort;
141
+ const reasoning = model.modelKwargs?.reasoning_effort || model.modelKwargs?.reasoning?.effort || "off";
142
+ const modeTag = mode === "bench" ? C.yellow("◆ bench") : C.cyan("◆ agent");
139
143
  const modelBits = [`model: ${model.model}`];
140
- if (reasoning) modelBits.push(`reasoning: ${reasoning}`);
144
+ modelBits.push(`reasoning: ${reasoning}`);
141
145
  modelBits.push(yolo ? "auto-run" : "approve");
142
146
 
143
147
  const up = model.totalPromptTokens;
@@ -152,16 +156,68 @@ function footerLines(agent, model, yolo) {
152
156
 
153
157
  return [
154
158
  C.dim(rule),
155
- C.cyan(repoLabel()) + " " + C.dim(modelBits.join(" ")),
159
+ modeTag + " " + C.cyan(repoLabel()) + " " + C.dim(modelBits.join(" ")),
156
160
  C.dim(usage),
161
+ C.dim("opt+left/right: model · opt+up/down: reasoning · shift+tab: mode"),
157
162
  ];
158
163
  }
159
164
 
160
- const FOOTER_LINES = 3; // divider + repo/model + usage
165
+ const FOOTER_LINES = 4; // divider + repo/model + usage + shortcuts
161
166
  const PROMPT_PREFIX = C.green("› "); // live input row (minimal)
162
167
  const HISTORY_PREFIX = C.green("you › "); // echoed into transcript/history
163
168
  const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
164
169
 
170
+ /**
171
+ * Recognize an Option/Alt + arrow key from a terminal input string.
172
+ * Returns "up" | "down" | "left" | "right" | null.
173
+ *
174
+ * Terminals encode these differently:
175
+ * - xterm modifier form: ESC[1;3A/B/C/D (3 = Alt/Option)
176
+ * - meta word-move (iTerm2 default): ESC b (left word) / ESC f (right word)
177
+ * - some send ESC ESC[A.. (double-ESC) for Alt+arrow
178
+ * When `latched` is true the leading ESC may already have been consumed, so we
179
+ * also accept the tails without the leading ESC.
180
+ */
181
+ function matchOptionArrow(s, latched = false) {
182
+ const arrows = { A: "up", B: "down", C: "right", D: "left" };
183
+ // xterm modifier form with Alt (mod code 3) or Meta (mod code 9).
184
+ let m = s.match(/^\x1b\[1;(?:3|9)([ABCD])$/);
185
+ if (m) return arrows[m[1]];
186
+ // Double-ESC form: ESC ESC [ A..
187
+ m = s.match(/^\x1b\x1b\[([ABCD])$/);
188
+ if (m) return arrows[m[1]];
189
+ // Meta word-move: ESC b / ESC f -> treat as left/right (model cycling).
190
+ if (s === "\x1bb") return "left";
191
+ if (s === "\x1bf") return "right";
192
+ if (latched) {
193
+ // Leading ESC already latched; match the tail we reconstructed as ESC+tail.
194
+ m = s.match(/^\x1b\[1;(?:3|9)([ABCD])$/); // (handled above, kept for clarity)
195
+ if (m) return arrows[m[1]];
196
+ if (s === "\x1bb") return "left";
197
+ if (s === "\x1bf") return "right";
198
+ }
199
+ return null;
200
+ }
201
+
202
+ /** Read the model's current reasoning-effort level ("off" when unset). */
203
+ function currentReasoning(model) {
204
+ const eff = model.modelKwargs?.reasoning_effort || model.modelKwargs?.reasoning?.effort;
205
+ return eff || "off";
206
+ }
207
+
208
+ /** Apply a reasoning-effort level to the live model's request kwargs. */
209
+ function setReasoning(model, level) {
210
+ model.modelKwargs = { ...(model.modelKwargs || {}) };
211
+ const l = String(level || "").toLowerCase();
212
+ if (!l || l === "off" || l === "none" || l === "disabled") {
213
+ delete model.modelKwargs.reasoning_effort;
214
+ delete model.modelKwargs.reasoning;
215
+ } else {
216
+ model.modelKwargs.reasoning_effort = l;
217
+ delete model.modelKwargs.reasoning;
218
+ }
219
+ }
220
+
165
221
  /**
166
222
  * A bottom-anchored screen: conversation output scrolls in the top region
167
223
  * (bounded by a DECSTBM scroll margin) while the footer + input prompt stay
@@ -189,8 +245,7 @@ class Screen {
189
245
  this._onResize = this._onResize.bind(this);
190
246
  }
191
247
 
192
- get rows() { return this.out.rows || 24; }
193
- // Footer block (4 rows) pinned to the bottom: divider, prompt, repo/model, usage.
248
+ get rows() { return this.out.rows || 24; } // Footer block (4 rows) pinned to the bottom: divider, prompt, repo/model, usage.
194
249
  get reserved() { return FOOTER_LINES + 1; } // +1 for the prompt row
195
250
  get scrollBottom() { return Math.max(1, this.rows - this.reserved); } // last scrollable row
196
251
  get footerTop() { return this.scrollBottom + 1; } // divider row
@@ -216,6 +271,12 @@ class Screen {
216
271
  this.out.write(`\x1b[${this.rows};1H\n`); // move below footer
217
272
  }
218
273
 
274
+ /** Cancel any pending lone-ESC latch/timer. */
275
+ _clearEscLatch() {
276
+ this._pendingEsc = false;
277
+ clearTimeout(this._escTimer);
278
+ }
279
+
219
280
  /** Write a block of text into the scrolling region (may contain newlines). */
220
281
  log(text) {
221
282
  this.out.write(`\x1b[${this.scrollBottom};1H`); // park at bottom of scroll area
@@ -278,6 +339,11 @@ class Screen {
278
339
  this.drawPrompt();
279
340
  }
280
341
 
342
+ /** True while the agent is working (a turn is in progress). */
343
+ isBusy() {
344
+ return this._busy || this._busyResume;
345
+ }
346
+
281
347
  /**
282
348
  * Resolve with the next full line the user types. An optional prompt label
283
349
  * replaces the default "you › " (used by the approval gate so the question
@@ -312,6 +378,86 @@ class Screen {
312
378
  }
313
379
 
314
380
  _onData(chunk) {
381
+ const s = chunk.toString();
382
+ // Optional key debug: set ASTRA_KEYDEBUG=1 to print the raw bytes of every
383
+ // keypress into the transcript. Use it to discover what your terminal
384
+ // actually sends for Option/Alt+Tab, then report it.
385
+ if (process.env.ASTRA_KEYDEBUG && this.log) {
386
+ const bytes = Array.from(chunk).map((b) => "0x" + b.toString(16).padStart(2, "0")).join(" ");
387
+ this.log(`\x1b[35m[keydebug] ${bytes}\x1b[0m`);
388
+ }
389
+ // --- Key sequences: mode toggle, model/reasoning cycling, Esc-to-clear ---
390
+ //
391
+ // Mode toggle: Alt/Option+Tab is delivered as ESC+Tab ("\x1b\t"); Shift+Tab
392
+ // ("\x1b[Z") is a reliable fallback. Model/reasoning cycling uses Option +
393
+ // arrow keys. A bare Esc (nothing following it) clears the input line.
394
+ //
395
+ // Because some terminals split escape sequences across reads (a lone ESC,
396
+ // then the rest), we latch a lone ESC briefly. If a Tab follows -> toggle;
397
+ // if nothing follows within the window -> treat it as bare Esc (clear).
398
+
399
+ // Whole-chunk fast paths first.
400
+ if (this.onToggleMode && (s === "\x1b\t" || s === "\x1b[Z")) {
401
+ this._clearEscLatch();
402
+ this.onToggleMode();
403
+ return;
404
+ }
405
+ {
406
+ const opt = matchOptionArrow(s, false);
407
+ if (opt) {
408
+ this._clearEscLatch();
409
+ if ((opt === "left" || opt === "right") && this.onCycleModel) {
410
+ this.onCycleModel(opt === "right" ? 1 : -1);
411
+ return;
412
+ }
413
+ if ((opt === "up" || opt === "down") && this.onCycleReasoning) {
414
+ this.onCycleReasoning(opt === "up" ? 1 : -1);
415
+ return;
416
+ }
417
+ return; // recognized an option-arrow we don't act on; swallow it
418
+ }
419
+ }
420
+
421
+ // A Tab following a latched ESC -> mode toggle (split Alt+Tab delivery).
422
+ if (this._pendingEsc && s === "\t" && this.onToggleMode) {
423
+ this._clearEscLatch();
424
+ this.onToggleMode();
425
+ return;
426
+ }
427
+ // A latched ESC followed by an arrow tail (e.g. "[C", "b", "f") -> option-arrow.
428
+ if (this._pendingEsc) {
429
+ const opt = matchOptionArrow("\x1b" + s, true);
430
+ if (opt) {
431
+ this._clearEscLatch();
432
+ if ((opt === "left" || opt === "right") && this.onCycleModel) {
433
+ this.onCycleModel(opt === "right" ? 1 : -1);
434
+ return;
435
+ }
436
+ if ((opt === "up" || opt === "down") && this.onCycleReasoning) {
437
+ this.onCycleReasoning(opt === "up" ? 1 : -1);
438
+ return;
439
+ }
440
+ return;
441
+ }
442
+ // Any other input right after a latched ESC cancels the latch and is
443
+ // processed normally below.
444
+ this._clearEscLatch();
445
+ }
446
+
447
+ // A lone ESC: latch briefly to disambiguate from split escape sequences.
448
+ // If nothing arrives, the timer treats it as a bare Esc and clears input.
449
+ if (s === "\x1b") {
450
+ this._pendingEsc = true;
451
+ clearTimeout(this._escTimer);
452
+ this._escTimer = setTimeout(() => {
453
+ this._pendingEsc = false;
454
+ if (this.onClearInput) this.onClearInput();
455
+ this.buf = "";
456
+ this.drawPrompt();
457
+ }, 60);
458
+ return;
459
+ }
460
+
315
461
  for (const ch of chunk) {
316
462
  if (ch === "\r" || ch === "\n") {
317
463
  // Collapse a CRLF pair into one Enter: ignore a \n right after a \r.
@@ -348,8 +494,12 @@ class Screen {
348
494
  this.buf = this.buf.slice(0, -1);
349
495
  this.drawPrompt();
350
496
  } else if (ch === "\x03") { // Ctrl+C
497
+ // If a prompt is pending (idle), resolve it so the loop can react.
498
+ // Also always notify the interrupt hook so a *running* agent turn can
499
+ // be aborted even when no readLine is pending.
351
500
  const r = this.resolve; this.resolve = null;
352
501
  if (r) r("__SIGINT__");
502
+ if (this.onInterrupt) this.onInterrupt();
353
503
  } else if (ch === "\x04") { // Ctrl+D
354
504
  const r = this.resolve; this.resolve = null;
355
505
  if (r) r("__EOF__");
@@ -370,24 +520,32 @@ Interactive commands:
370
520
  /history print the message history
371
521
  /tokens show token usage so far
372
522
  /yolo toggle auto-run of commands (no confirmation)
523
+
524
+ Keyboard shortcuts:
525
+ alt/opt+tab switch agent / bench mode (shift+tab also works)
526
+ opt+left/right cycle the model
527
+ opt+up/down cycle reasoning effort (off/low/medium/high)
528
+ esc clear the input line
373
529
  `;
374
530
 
375
531
  /**
376
532
  * Run the interactive loop against a prepared Agent.
377
533
  * @param {Agent} agent a started (or restored) agent in interactive mode
378
- * @param {object} opts { model, autoRun }
534
+ * @param {object} opts { model, autoRun, fresh, reasoning, runBench }
535
+ * runBench(taskText, log) => Promise optional; invoked when the user submits
536
+ * a message while the REPL is toggled to bench mode. Should run the task in
537
+ * an isolated bench workspace and record metrics.
379
538
  */
380
- export async function runRepl(agent, { model, autoRun = false, fresh = true } = {}) {
539
+ export async function runRepl(agent, { model, autoRun = false, fresh = true, reasoning = "", runBench = null } = {}) {
381
540
  let yolo = autoRun;
382
541
  const tty = !!process.stdout.isTTY && !!process.stdin.isTTY;
383
542
 
384
543
  const intro = fresh
385
544
  ? bannerLines({ session: agent.sessionId })
386
545
  : [C.dim(`[astra] resumed session ${agent.sessionId} · model=${model.model}`)];
387
- intro.push(C.dim(`Type a request, or /help. Commands ${yolo ? "auto-run" : "need approval"}.`));
388
546
 
389
547
  if (tty) {
390
- await runStickyRepl(agent, model, yolo, intro);
548
+ await runStickyRepl(agent, model, yolo, intro, { runBench });
391
549
  } else {
392
550
  for (const line of intro) console.error(line);
393
551
  await runPlainRepl(agent, model, yolo);
@@ -395,10 +553,65 @@ export async function runRepl(agent, { model, autoRun = false, fresh = true } =
395
553
  }
396
554
 
397
555
  /** Sticky bottom-anchored REPL (real terminals). */
398
- async function runStickyRepl(agent, model, yolo, intro = []) {
556
+ async function runStickyRepl(agent, model, yolo, intro = [], { runBench = null } = {}) {
399
557
  const screen = new Screen();
400
558
  const log = (text) => screen.log(text);
401
- const refresh = () => screen.refresh(footerLines(agent, model, yolo));
559
+ let mode = "agent"; // "agent" | "bench"
560
+ const refresh = () => screen.refresh(footerLines(agent, model, yolo, mode));
561
+
562
+ // Alt/Option+Tab (or Shift+Tab) toggles between agent and bench mode.
563
+ screen.onToggleMode = () => {
564
+ mode = mode === "agent" ? "bench" : "agent";
565
+ refresh();
566
+ };
567
+
568
+ // Option+Left/Right cycles the model; Option+Up/Down cycles reasoning effort.
569
+ // Changes apply to the live model and are cached as the preferred agent
570
+ // prefs so the next `astra` launch reuses them.
571
+ screen.onCycleModel = (dir) => {
572
+ const list = AVAILABLE_MODELS;
573
+ const cur = list.indexOf(model.model);
574
+ const next = ((cur === -1 ? 0 : cur) + dir + list.length) % list.length;
575
+ model.model = list[next];
576
+ saveAgentPrefs({ model: model.model });
577
+ refresh();
578
+ };
579
+ screen.onCycleReasoning = (dir) => {
580
+ const list = REASONING_LEVELS;
581
+ const cur = list.indexOf(currentReasoning(model));
582
+ const next = ((cur === -1 ? 0 : cur) + dir + list.length) % list.length;
583
+ setReasoning(model, list[next]);
584
+ saveAgentPrefs({ reasoning: list[next] });
585
+ refresh();
586
+ };
587
+ // Bare Esc clears the current input line (handled by the screen; nothing
588
+ // extra needed here, but expose a hook for symmetry / future use).
589
+ screen.onClearInput = () => {};
590
+
591
+ // Ctrl+C handling. In raw mode Ctrl+C does NOT raise SIGINT — it arrives as
592
+ // a byte, so we handle it explicitly. The screen's key handler already
593
+ // resolves any pending readLine with "__SIGINT__" (idle case); here we only
594
+ // deal with the *busy* case where no readLine is pending:
595
+ // - first Ctrl+C while working -> request an abort after the current step
596
+ // - second Ctrl+C while working -> force an immediate quit
597
+ const interrupt = { aborted: false, quit: false };
598
+ screen.onInterrupt = () => {
599
+ if (!screen.isBusy()) {
600
+ // Idle: the readLine resolver was already fired with "__SIGINT__", which
601
+ // breaks the loop and quits gracefully. Nothing more to do here.
602
+ return;
603
+ }
604
+ if (interrupt.aborted) {
605
+ // Second Ctrl+C while working -> force quit: abort the in-flight model
606
+ // request so a long call unblocks immediately.
607
+ interrupt.quit = true;
608
+ log(C.red("[astra] force quitting…"));
609
+ if (model._abort) { try { model._abort.abort(); } catch {} }
610
+ return;
611
+ }
612
+ interrupt.aborted = true;
613
+ log(C.yellow("[astra] interrupting… (press Ctrl+C again to force quit)"));
614
+ };
402
615
 
403
616
  // Approval gate: show the command, then ask for y/n/a on the prompt row.
404
617
  agent.confirm = async (command) => {
@@ -436,13 +649,50 @@ async function runStickyRepl(agent, model, yolo, intro = []) {
436
649
 
437
650
  const before = usageSnapshot(model);
438
651
  const t0 = Date.now();
652
+
653
+ // Bench mode: run the message as an autonomous task in an isolated
654
+ // workspace, record metrics, then drop back to agent mode.
655
+ if (mode === "bench") {
656
+ if (!runBench) {
657
+ log(C.red("[astra] bench runner unavailable in this session."));
658
+ mode = "agent"; refresh(); continue;
659
+ }
660
+ screen.startBusy("benching");
661
+ interrupt.aborted = false;
662
+ try {
663
+ await runBench(line, log);
664
+ } catch (err) {
665
+ log(C.red(`[astra] bench failed: ${err?.message || err}`));
666
+ } finally {
667
+ screen.stopBusy();
668
+ }
669
+ if (interrupt.quit) break;
670
+ mode = "agent";
671
+ refresh();
672
+ continue;
673
+ }
674
+
439
675
  agent.addUserMessage(line);
676
+ interrupt.aborted = false; // reset per turn
677
+ // Fresh AbortController for this turn so a force-quit can cancel an
678
+ // in-flight model request. Cleared in `finally`.
679
+ model._abort = new AbortController();
680
+ model.signal = model._abort.signal;
440
681
  screen.startBusy("working");
441
682
  try {
442
- await driveUntilChat(agent, log);
683
+ await driveUntilChat(agent, log, interrupt);
684
+ } catch (err) {
685
+ if (err?.name === "AbortError" || interrupt.quit) {
686
+ // Force-quit path: swallow the abort and fall through to break.
687
+ } else {
688
+ throw err;
689
+ }
443
690
  } finally {
444
691
  screen.stopBusy();
692
+ model.signal = null;
693
+ model._abort = null;
445
694
  }
695
+ if (interrupt.quit) break; // force-quit requested via a second Ctrl+C
446
696
  const after = usageSnapshot(model);
447
697
  log(
448
698
  turnSummary({
@@ -536,10 +786,16 @@ function usageSnapshot(model) {
536
786
 
537
787
  /**
538
788
  * Take turns until the agent produces a chat reply (or terminates). Commands
539
- * run in between; their output goes back to the model automatically.
789
+ * run in between; their output goes back to the model automatically. If an
790
+ * `interrupt` signal is provided, the loop stops cleanly between turns when
791
+ * `interrupt.aborted` becomes true (Ctrl+C while working).
540
792
  */
541
- async function driveUntilChat(agent, log) {
793
+ async function driveUntilChat(agent, log, interrupt = null) {
542
794
  while (true) {
795
+ if (interrupt && interrupt.aborted) {
796
+ log(C.yellow("[astra] interrupted — returning to prompt."));
797
+ return;
798
+ }
543
799
  let turn;
544
800
  try {
545
801
  turn = await agent.runTurn();
@@ -551,6 +807,10 @@ async function driveUntilChat(agent, log) {
551
807
  throw err;
552
808
  }
553
809
 
810
+ if (interrupt && interrupt.aborted) {
811
+ log(C.yellow("[astra] interrupted — returning to prompt."));
812
+ return;
813
+ }
554
814
  if (turn.kind === "chat") {
555
815
  log("\n" + C.cyan("astra › ") + turn.content.trim());
556
816
  return;