@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/README.md +61 -2
- package/package.json +1 -1
- package/src/agent.js +9 -0
- package/src/bench.js +478 -0
- package/src/cli.js +375 -33
- package/src/config.js +78 -0
- package/src/model.js +16 -1
- package/src/models.js +31 -0
- package/src/prompts.js +7 -3
- package/src/repl.js +281 -21
- package/src/report.html +569 -0
- package/src/report.js +459 -0
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
|
-
|
|
46
|
-
- You are a
|
|
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
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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;
|