@cargo-ai/cli 1.0.86 → 1.0.88
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/build/commands/ask/claudeConfig.d.ts +2 -0
- package/build/commands/ask/claudeConfig.d.ts.map +1 -1
- package/build/commands/ask/claudeConfig.js +67 -1
- package/build/commands/ask/index.js +1 -1
- package/build/commands/ask/localHarness.d.ts +53 -0
- package/build/commands/ask/localHarness.d.ts.map +1 -1
- package/build/commands/ask/localHarness.js +281 -55
- package/build/commands/ask/prompt.d.ts +7 -1
- package/build/commands/ask/prompt.d.ts.map +1 -1
- package/build/commands/ask/prompt.js +9 -3
- package/build/commands/ask/session.d.ts +8 -1
- package/build/commands/ask/session.d.ts.map +1 -1
- package/build/commands/ask/session.js +426 -63
- package/build/commands/ask/thinking.d.ts +21 -0
- package/build/commands/ask/thinking.d.ts.map +1 -0
- package/build/commands/ask/thinking.js +57 -0
- package/build/commands/ask/typeAhead.d.ts +67 -0
- package/build/commands/ask/typeAhead.d.ts.map +1 -0
- package/build/commands/ask/typeAhead.js +169 -0
- package/build/commands/runHandler.d.ts +7 -0
- package/build/commands/runHandler.d.ts.map +1 -1
- package/build/commands/runHandler.js +39 -4
- package/package.json +6 -4
|
@@ -4,14 +4,16 @@ import { determineIfIsFetcherError } from "@cargo-ai/api";
|
|
|
4
4
|
import { isInteractive, selectOne } from "@cargo-ai/cdk/cli";
|
|
5
5
|
import { getConfig } from "../../config.js";
|
|
6
6
|
import { openPromptInput } from "../../utils/prompt.js";
|
|
7
|
-
import { colors, ExitCodes, failWith, handleApiCall, outputJson, pollUntilFinished, startSpinner, } from "../runHandler.js";
|
|
7
|
+
import { clipToWidth, colors, ExitCodes, failWith, handleApiCall, outputJson, pollUntilFinished, startSpinner, } from "../runHandler.js";
|
|
8
8
|
import { loadChatHistoryPreamble } from "./history.js";
|
|
9
|
-
import { createLocalHarness, HarnessTurnStuck, } from "./localHarness.js";
|
|
9
|
+
import { createLocalHarness, HarnessTurnStalled, HarnessTurnStuck, isBridgeClosedError, } from "./localHarness.js";
|
|
10
10
|
import { streamMessageTextDeltas } from "./messageStream.js";
|
|
11
11
|
import { saveAskState } from "./persist.js";
|
|
12
12
|
import { readAskLine } from "./prompt.js";
|
|
13
13
|
import { parseSlash } from "./slash.js";
|
|
14
14
|
import { ASK_STATE_VERSION } from "./state.js";
|
|
15
|
+
import { pickThinkingWord } from "./thinking.js";
|
|
16
|
+
import { collectTypeAhead, EMPTY_TYPE_AHEAD, } from "./typeAhead.js";
|
|
15
17
|
function requireInteractive() {
|
|
16
18
|
if (isInteractive() === false || process.stdout.isTTY !== true) {
|
|
17
19
|
failWith("Ask needs a terminal. Re-run with --print for one JSON turn.", {
|
|
@@ -21,6 +23,18 @@ function requireInteractive() {
|
|
|
21
23
|
}
|
|
22
24
|
/** Sentinel for `/agent` → Local workspace (no agentUuid). */
|
|
23
25
|
const LOCAL_WORKSPACE = "__local_workspace__";
|
|
26
|
+
/**
|
|
27
|
+
* What an assistant turn is marked with, alongside `◇` for a step and `│` for
|
|
28
|
+
* the rail.
|
|
29
|
+
*
|
|
30
|
+
* A text glyph rather than an emoji, so it takes the same `marker + two
|
|
31
|
+
* spaces` as every other row and the whole tree stays on one left edge. An
|
|
32
|
+
* emoji is two columns wide and needs its own spacing rule to line up, which
|
|
33
|
+
* is one more thing to get wrong every time a line is drawn.
|
|
34
|
+
*/
|
|
35
|
+
const ASSISTANT_MARKER = "■";
|
|
36
|
+
/** Completed tool call, so the transcript keeps what was actually run. */
|
|
37
|
+
const TOOL_MARKER = "⏺";
|
|
24
38
|
/**
|
|
25
39
|
* How long before a proxy token's stated expiry to stop trusting it. Covers
|
|
26
40
|
* the gap between deciding to reuse the harness and the first proxy call the
|
|
@@ -193,14 +207,18 @@ export async function doesChatExist(api, chatUuid) {
|
|
|
193
207
|
*/
|
|
194
208
|
function treeBodyWidth() {
|
|
195
209
|
const columns = process.stderr.columns === undefined ? 80 : process.stderr.columns;
|
|
196
|
-
// Marker + two spaces (`◇ `, `│ `, `◆ `)
|
|
197
|
-
|
|
210
|
+
// Marker + two spaces (`◇ `, `│ `, `◆ `), and one column of margin: a
|
|
211
|
+
// line filling the last cell makes some terminals wrap on their own, and the
|
|
212
|
+
// `\n` after it then lands a row further down than the tree expects.
|
|
213
|
+
return Math.max(24, columns - 4);
|
|
198
214
|
}
|
|
199
215
|
/**
|
|
200
216
|
* Greedy wrap on spaces. A word longer than the width keeps its own line
|
|
201
217
|
* rather than being cut mid-token.
|
|
218
|
+
*
|
|
219
|
+
* Exported for tests.
|
|
202
220
|
*/
|
|
203
|
-
function wrapLine(text, width) {
|
|
221
|
+
export function wrapLine(text, width) {
|
|
204
222
|
const lines = [];
|
|
205
223
|
for (const word of text.split(/\s+/).filter((part) => part.length > 0)) {
|
|
206
224
|
const current = lines[lines.length - 1];
|
|
@@ -248,8 +266,25 @@ function forTerminal(text) {
|
|
|
248
266
|
* ◇ Agent
|
|
249
267
|
* │ reply…
|
|
250
268
|
*/
|
|
269
|
+
/**
|
|
270
|
+
* A `◇` step: the first line beside the marker, the rest continuing on the
|
|
271
|
+
* rail under it.
|
|
272
|
+
*
|
|
273
|
+
* Wrapped here rather than left to the terminal. A label long enough to reach
|
|
274
|
+
* the edge — a stall notice, an API error — soft-wraps mid-word and starts its
|
|
275
|
+
* next line at column zero, outside the tree, which is how a one-sentence
|
|
276
|
+
* notice ends up looking like the output is corrupted.
|
|
277
|
+
*/
|
|
278
|
+
function writeDiamondBlock(body) {
|
|
279
|
+
const lines = body === "" ? [""] : wrapLine(body, treeBodyWidth());
|
|
280
|
+
const first = lines[0] === undefined ? "" : lines[0];
|
|
281
|
+
process.stderr.write(`${colors.dim("◇")} ${first}\n`);
|
|
282
|
+
for (const line of lines.slice(1)) {
|
|
283
|
+
writeRail(line);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
251
286
|
function showStep(label, body) {
|
|
252
|
-
|
|
287
|
+
writeDiamondBlock(label);
|
|
253
288
|
for (const paragraph of body.split("\n")) {
|
|
254
289
|
writeRailWrapped(paragraph);
|
|
255
290
|
}
|
|
@@ -270,15 +305,7 @@ function showCard(body) {
|
|
|
270
305
|
* `◇ ask hey --continue`. No "You" label.
|
|
271
306
|
*/
|
|
272
307
|
function showUserTurn(body) {
|
|
273
|
-
|
|
274
|
-
const first = lines.length === 0 ? "" : lines[0] === undefined ? "" : lines[0];
|
|
275
|
-
process.stderr.write(`${colors.dim("◇")} ${first}\n`);
|
|
276
|
-
for (let index = 1; index < lines.length; index += 1) {
|
|
277
|
-
const line = lines[index];
|
|
278
|
-
if (line !== undefined) {
|
|
279
|
-
writeRail(line);
|
|
280
|
-
}
|
|
281
|
-
}
|
|
308
|
+
writeDiamondBlock(body);
|
|
282
309
|
}
|
|
283
310
|
/**
|
|
284
311
|
* Display lines for an assistant body. First line is the brick header body;
|
|
@@ -312,9 +339,7 @@ function assistantDisplayLines(body) {
|
|
|
312
339
|
}
|
|
313
340
|
function writeAssistantLines(lines) {
|
|
314
341
|
const first = lines[0] === undefined ? "" : lines[0];
|
|
315
|
-
|
|
316
|
-
// lines up with `◇ ` / `│ `.
|
|
317
|
-
process.stderr.write(`🧱 ${first}\n`);
|
|
342
|
+
process.stderr.write(`${ASSISTANT_MARKER} ${first}\n`);
|
|
318
343
|
for (let index = 1; index < lines.length; index += 1) {
|
|
319
344
|
const line = lines[index];
|
|
320
345
|
if (line !== undefined) {
|
|
@@ -328,25 +353,56 @@ function clearPaintedLines(count) {
|
|
|
328
353
|
}
|
|
329
354
|
process.stderr.write(`\x1B[${String(count)}A\r\x1B[J`);
|
|
330
355
|
}
|
|
331
|
-
/** Rail gap, then an empty
|
|
356
|
+
/** Rail gap, then an empty marker line ready for deltas. */
|
|
332
357
|
function beginAssistantTurn() {
|
|
333
358
|
writeRail("");
|
|
334
|
-
process.stderr.write(
|
|
335
|
-
return { buffer: "", paintedLines: 1 };
|
|
359
|
+
process.stderr.write(`${ASSISTANT_MARKER} \n`);
|
|
360
|
+
return { buffer: "", paintedLines: 1, footer: "" };
|
|
336
361
|
}
|
|
337
362
|
function appendAssistantDelta(live, delta) {
|
|
338
|
-
|
|
363
|
+
// A tool between two pieces of narration inserts a paragraph break, so that
|
|
364
|
+
// "Let me look." and what it found do not run together. When the tool also
|
|
365
|
+
// closed the block on screen, that break arrives as the first thing in the
|
|
366
|
+
// *next* block — and renders as an empty rail line under the marker before a
|
|
367
|
+
// word has been said. Kept in the stored answer, where the paragraphs still
|
|
368
|
+
// belong; dropped here, where the block itself is already the separation.
|
|
369
|
+
const text = live.buffer === "" ? delta.replace(/^\n+/, "") : delta;
|
|
370
|
+
if (text === "") {
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
live.buffer += text;
|
|
374
|
+
repaintAssistantTurn(live);
|
|
375
|
+
}
|
|
376
|
+
/** Draw the block again from its buffer, with the typed line under it. */
|
|
377
|
+
function repaintAssistantTurn(live) {
|
|
339
378
|
const lines = assistantDisplayLines(live.buffer);
|
|
340
379
|
clearPaintedLines(live.paintedLines);
|
|
341
380
|
writeAssistantLines(lines);
|
|
342
381
|
live.paintedLines = lines.length;
|
|
382
|
+
if (live.footer !== "") {
|
|
383
|
+
// Clipped for the same reason the spinner's is: it has to occupy exactly
|
|
384
|
+
// one row, or the next repaint clears from the wrong line.
|
|
385
|
+
process.stderr.write(`${colors.dim(clipToWidth(live.footer))}\n`);
|
|
386
|
+
live.paintedLines += 1;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
/** Show, change or remove the question being typed over this reply. */
|
|
390
|
+
function setAssistantFooter(live, footer) {
|
|
391
|
+
if (live.footer === footer) {
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
live.footer = footer;
|
|
395
|
+
repaintAssistantTurn(live);
|
|
343
396
|
}
|
|
344
397
|
/** Trailing rail gap before the next user turn / ◆ prompt. */
|
|
345
|
-
function endAssistantTurn(
|
|
398
|
+
function endAssistantTurn(live) {
|
|
399
|
+
// The typed line belongs to whatever draws next — the spinner, or the block
|
|
400
|
+
// after this one. Left here it would be printed twice.
|
|
401
|
+
setAssistantFooter(live, "");
|
|
346
402
|
writeRail("");
|
|
347
403
|
}
|
|
348
404
|
/**
|
|
349
|
-
* Finished assistant turn — same tree as a user step,
|
|
405
|
+
* Finished assistant turn — same tree as a user step, with `■` instead of `◇`.
|
|
350
406
|
*/
|
|
351
407
|
function showAssistantTurn(body) {
|
|
352
408
|
const live = beginAssistantTurn();
|
|
@@ -357,11 +413,21 @@ function showAssistantTurn(body) {
|
|
|
357
413
|
* Ctrl-C during a turn cancels the turn, not the CLI: the harness interrupts
|
|
358
414
|
* Claude Code, the tree says so, and the ◆ prompt comes back. A second Ctrl-C
|
|
359
415
|
* while the first is still settling exits for real.
|
|
416
|
+
*
|
|
417
|
+
* Except once the turn has reached teardown, where exiting is the one thing
|
|
418
|
+
* that must not happen: `process.exit` skips the `finally` that shuts Claude
|
|
419
|
+
* Code down, so an impatient second press — the natural reaction to a few
|
|
420
|
+
* seconds of apparently nothing — is exactly what strands the process this
|
|
421
|
+
* teardown exists to stop. Presses are ignored for the length of it, which is
|
|
422
|
+
* bounded: `close` gives up after `SESSION_DESTROY_TIMEOUT_MS` either way.
|
|
360
423
|
*/
|
|
361
424
|
function beginCancellableTurn(onCancel) {
|
|
362
425
|
const controller = new AbortController();
|
|
363
|
-
const state = { cancelled: false };
|
|
426
|
+
const state = { cancelled: false, tearingDown: false };
|
|
364
427
|
const handleInterrupt = () => {
|
|
428
|
+
if (state.tearingDown === true) {
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
365
431
|
if (state.cancelled === true) {
|
|
366
432
|
// 130 is the conventional "terminated by SIGINT" status.
|
|
367
433
|
process.exit(130);
|
|
@@ -374,11 +440,60 @@ function beginCancellableTurn(onCancel) {
|
|
|
374
440
|
return {
|
|
375
441
|
signal: controller.signal,
|
|
376
442
|
wasCancelled: () => state.cancelled,
|
|
443
|
+
abort: () => {
|
|
444
|
+
controller.abort();
|
|
445
|
+
},
|
|
446
|
+
beginTeardown: () => {
|
|
447
|
+
state.tearingDown = true;
|
|
448
|
+
},
|
|
449
|
+
interrupt: handleInterrupt,
|
|
377
450
|
release: () => {
|
|
378
451
|
process.removeListener("SIGINT", handleInterrupt);
|
|
379
452
|
},
|
|
380
453
|
};
|
|
381
454
|
}
|
|
455
|
+
/**
|
|
456
|
+
* Let the next question be typed while this one is still being answered.
|
|
457
|
+
*
|
|
458
|
+
* The collector owns the terminal for the length of the turn; the spinner
|
|
459
|
+
* shows what has been typed so far, because raw mode means the terminal is no
|
|
460
|
+
* longer echoing it.
|
|
461
|
+
*
|
|
462
|
+
* Degrades to no type-ahead rather than failing when there is no terminal to
|
|
463
|
+
* read — `--print` and the non-interactive paths never get here, but a session
|
|
464
|
+
* whose stdin went away should still finish the turn it is running.
|
|
465
|
+
*/
|
|
466
|
+
function beginTurnTyping(input) {
|
|
467
|
+
const terminal = openPromptInput();
|
|
468
|
+
if (terminal === undefined) {
|
|
469
|
+
return {
|
|
470
|
+
pause: async () => undefined,
|
|
471
|
+
resume: () => undefined,
|
|
472
|
+
finish: () => EMPTY_TYPE_AHEAD,
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
const collector = collectTypeAhead({
|
|
476
|
+
stream: terminal.stream,
|
|
477
|
+
initialLine: input.carriedLine,
|
|
478
|
+
onInterrupt: input.onInterrupt,
|
|
479
|
+
onChange: input.onTyped,
|
|
480
|
+
});
|
|
481
|
+
input.onTyped(input.carriedLine);
|
|
482
|
+
return {
|
|
483
|
+
pause: collector.pause,
|
|
484
|
+
resume: () => {
|
|
485
|
+
collector.resume();
|
|
486
|
+
// The prompt that borrowed the terminal drew over the line; put it back.
|
|
487
|
+
input.onTyped(collector.current());
|
|
488
|
+
},
|
|
489
|
+
finish: () => {
|
|
490
|
+
const typed = collector.stop();
|
|
491
|
+
input.onTyped("");
|
|
492
|
+
terminal.close();
|
|
493
|
+
return typed;
|
|
494
|
+
},
|
|
495
|
+
};
|
|
496
|
+
}
|
|
382
497
|
/**
|
|
383
498
|
* Stop a remote turn the user walked away from.
|
|
384
499
|
*
|
|
@@ -415,6 +530,28 @@ function describeToolActivity(toolName, summary) {
|
|
|
415
530
|
? `${line.slice(0, ACTIVITY_MAX_LENGTH - 1)}…`
|
|
416
531
|
: line;
|
|
417
532
|
}
|
|
533
|
+
/**
|
|
534
|
+
* A tool that has finished, left in the transcript.
|
|
535
|
+
*
|
|
536
|
+
* The spinner shows what is running, but it is one line that is overwritten
|
|
537
|
+
* and then cleared, so once a turn is over there is no record of what it did —
|
|
538
|
+
* only the prose describing it. A turn that read four files and ran two
|
|
539
|
+
* commands should say so afterwards, the way the approval prompt does for the
|
|
540
|
+
* tools it happened to ask about.
|
|
541
|
+
*
|
|
542
|
+
* Written through the spinner's `log` when one is up, which wipes the spinner
|
|
543
|
+
* line, leaves this behind, and lets the next frame redraw underneath.
|
|
544
|
+
*/
|
|
545
|
+
function showToolRun(input) {
|
|
546
|
+
const marker = input.isError === true ? colors.red("✗") : colors.dim(TOOL_MARKER);
|
|
547
|
+
const label = describeToolActivity(input.toolName, input.summary);
|
|
548
|
+
const line = `${marker} ${colors.dim(label)}`;
|
|
549
|
+
if (input.spinner !== undefined) {
|
|
550
|
+
input.spinner.log(line);
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
process.stderr.write(`${line}\n`);
|
|
554
|
+
}
|
|
418
555
|
/**
|
|
419
556
|
* A path inside the session directory reads better relative to it; anything
|
|
420
557
|
* outside stays absolute so it is obvious the turn left the project.
|
|
@@ -481,7 +618,9 @@ async function askToolApproval(approval) {
|
|
|
481
618
|
* and path-like tokens. Empty Enter leaves. A first message from the palette
|
|
482
619
|
* is written as a finished user turn, then sent.
|
|
483
620
|
*/
|
|
484
|
-
async function readQuestion(firstMessage, dir
|
|
621
|
+
async function readQuestion(firstMessage, dir,
|
|
622
|
+
/** Typed while the last turn was still answering, and not sent as its own. */
|
|
623
|
+
typedAhead = "") {
|
|
485
624
|
if (firstMessage !== undefined) {
|
|
486
625
|
showUserTurn(firstMessage);
|
|
487
626
|
return firstMessage;
|
|
@@ -493,7 +632,7 @@ async function readQuestion(firstMessage, dir) {
|
|
|
493
632
|
});
|
|
494
633
|
}
|
|
495
634
|
try {
|
|
496
|
-
const answer = await readAskLine(input.stream, dir);
|
|
635
|
+
const answer = await readAskLine(input.stream, dir, typedAhead);
|
|
497
636
|
if (answer === undefined) {
|
|
498
637
|
return undefined;
|
|
499
638
|
}
|
|
@@ -504,10 +643,36 @@ async function readQuestion(firstMessage, dir) {
|
|
|
504
643
|
input.close();
|
|
505
644
|
}
|
|
506
645
|
}
|
|
646
|
+
/**
|
|
647
|
+
* Where the next question comes from, once type-ahead is in the picture.
|
|
648
|
+
*
|
|
649
|
+
* A line the user finished with Enter during the last reply is already their
|
|
650
|
+
* next question, so it is sent rather than parked at the prompt — the way it
|
|
651
|
+
* would have been had they waited. Anything typed after that Enter, finished
|
|
652
|
+
* or not, is carried into the turn it kicks off so it survives to the prompt
|
|
653
|
+
* after that: one question can be queued behind a reply, not a series of them.
|
|
654
|
+
* An unfinished line with no Enter simply opens the prompt with itself in
|
|
655
|
+
* place.
|
|
656
|
+
*/
|
|
657
|
+
async function nextQuestion(input) {
|
|
658
|
+
const { queued } = input;
|
|
659
|
+
const pending = `${input.carried}${queued.pending}`;
|
|
660
|
+
if (queued.submitted !== undefined) {
|
|
661
|
+
return {
|
|
662
|
+
question: await readQuestion(queued.submitted, input.dir),
|
|
663
|
+
carriedLine: pending,
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
return {
|
|
667
|
+
// Consumed by the prompt, so nothing is carried past it.
|
|
668
|
+
question: await readQuestion(input.firstMessage, input.dir, pending),
|
|
669
|
+
carriedLine: "",
|
|
670
|
+
};
|
|
671
|
+
}
|
|
507
672
|
/**
|
|
508
673
|
* Same tree grammar as the palette:
|
|
509
674
|
* ◇ <your message>
|
|
510
|
-
*
|
|
675
|
+
* ■ reply…
|
|
511
676
|
* ◆
|
|
512
677
|
* Empty Enter or /exit leaves to the parent palette.
|
|
513
678
|
*/
|
|
@@ -536,14 +701,29 @@ export async function runAskSession(options) {
|
|
|
536
701
|
const sessionId = randomUUID();
|
|
537
702
|
// "Yes to everything" lasts until the chat is reset, never on disk.
|
|
538
703
|
let approveEveryTool = false;
|
|
704
|
+
// Typed while the last turn was still answering: a finished line goes
|
|
705
|
+
// straight through as the next question, an unfinished one opens the prompt
|
|
706
|
+
// with it already in place.
|
|
707
|
+
let typedAhead = EMPTY_TYPE_AHEAD;
|
|
708
|
+
// The tail of a type-ahead whose first line was submitted, waiting for the
|
|
709
|
+
// next turn's collector to pick up where it left off.
|
|
710
|
+
let carriedLine = "";
|
|
539
711
|
if (options.continueChat === true && options.chatUuid !== undefined) {
|
|
540
712
|
chatCreated = await doesChatExist(options.api, options.chatUuid);
|
|
541
713
|
}
|
|
542
714
|
try {
|
|
543
715
|
for (;;) {
|
|
544
716
|
const mode = modeForAgent(agent);
|
|
545
|
-
const
|
|
717
|
+
const next = await nextQuestion({
|
|
718
|
+
queued: typedAhead,
|
|
719
|
+
carried: carriedLine,
|
|
720
|
+
firstMessage: pendingFirst,
|
|
721
|
+
dir: options.dir,
|
|
722
|
+
});
|
|
723
|
+
typedAhead = EMPTY_TYPE_AHEAD;
|
|
724
|
+
carriedLine = next.carriedLine;
|
|
546
725
|
pendingFirst = undefined;
|
|
726
|
+
const { question } = next;
|
|
547
727
|
if (question === undefined) {
|
|
548
728
|
return "palette";
|
|
549
729
|
}
|
|
@@ -615,25 +795,39 @@ export async function runAskSession(options) {
|
|
|
615
795
|
local = undefined;
|
|
616
796
|
}
|
|
617
797
|
if (local === undefined) {
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
//
|
|
622
|
-
//
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
798
|
+
// Nothing has been drawn since the question was echoed, and the
|
|
799
|
+
// Claude Code that has to answer it is not running yet: on a cold
|
|
800
|
+
// config directory that is ten to fifteen seconds of a blank tree,
|
|
801
|
+
// which reads as the CLI having hung on the first thing it was ever
|
|
802
|
+
// asked. Warm, this comes and goes inside a fifth of a second.
|
|
803
|
+
//
|
|
804
|
+
// Retired by `startSpinner` when the turn starts its own, and by the
|
|
805
|
+
// `finally` on the path where the setup throws instead.
|
|
806
|
+
const starting = startSpinner("Starting the coding session…");
|
|
807
|
+
try {
|
|
808
|
+
const minted = await handleApiCall(() => options.api.ai.chat.mintHarnessProxyToken(chatUuid), { spinner: false });
|
|
809
|
+
localTokenMintedAtMs = Date.now();
|
|
810
|
+
localTokenTtlMs = minted.expiresInSeconds * 1000;
|
|
811
|
+
// A new Claude Code session knows nothing of this chat, whether it
|
|
812
|
+
// is being continued from an earlier run or was reset by /agent.
|
|
813
|
+
const history = await loadChatHistoryPreamble({
|
|
814
|
+
api: options.api,
|
|
815
|
+
chatUuid,
|
|
816
|
+
});
|
|
817
|
+
local = await createLocalHarness({
|
|
818
|
+
api: options.api,
|
|
819
|
+
apiBaseUrl: config.baseUrl,
|
|
820
|
+
dir: options.dir,
|
|
821
|
+
chatUuid,
|
|
822
|
+
agentUuid: agent === undefined ? undefined : agent.uuid,
|
|
823
|
+
proxyToken: minted.token,
|
|
824
|
+
history,
|
|
825
|
+
turnOwnerId: sessionId,
|
|
826
|
+
});
|
|
827
|
+
}
|
|
828
|
+
finally {
|
|
829
|
+
starting.stop();
|
|
830
|
+
}
|
|
637
831
|
}
|
|
638
832
|
// Nothing about a local turn is visible to the server until it is
|
|
639
833
|
// ingested, so the chat reads as idle while Claude Code works and a
|
|
@@ -665,6 +859,31 @@ export async function runAskSession(options) {
|
|
|
665
859
|
let saidSomething = false;
|
|
666
860
|
const touchedFiles = new Set();
|
|
667
861
|
const notices = [];
|
|
862
|
+
// Reads and Cargo MCP tools are batched; the first one to finish must
|
|
863
|
+
// not wipe the spinner back to a thinking word while siblings run.
|
|
864
|
+
const activeTools = [];
|
|
865
|
+
// What the user has typed into this turn so far, drawn under the
|
|
866
|
+
// spinner. It lives here rather than on the spinner because a turn
|
|
867
|
+
// stops and restarts several of those — for an approval, for each tool
|
|
868
|
+
// label — and the line has to outlive all of them.
|
|
869
|
+
let typedLine = "";
|
|
870
|
+
// Plain text: it is clipped to the terminal width, and slicing a
|
|
871
|
+
// styled string would cut an escape sequence in half.
|
|
872
|
+
//
|
|
873
|
+
// Drawn by whichever of the two owns the screen. A reply streaming in
|
|
874
|
+
// has replaced the spinner with a live block, and that is exactly when
|
|
875
|
+
// someone is most likely to be typing over it — routing this to the
|
|
876
|
+
// spinner alone leaves the keyboard working and nothing showing for it.
|
|
877
|
+
const paintTyped = () => {
|
|
878
|
+
const footer = typedLine === "" ? "" : `◆ ${typedLine}`;
|
|
879
|
+
if (live !== undefined) {
|
|
880
|
+
setAssistantFooter(live, footer);
|
|
881
|
+
return;
|
|
882
|
+
}
|
|
883
|
+
if (spinner !== undefined) {
|
|
884
|
+
spinner.footer(footer);
|
|
885
|
+
}
|
|
886
|
+
};
|
|
668
887
|
const stopSpinner = () => {
|
|
669
888
|
if (spinner !== undefined) {
|
|
670
889
|
spinner.stop();
|
|
@@ -674,22 +893,80 @@ export async function runAskSession(options) {
|
|
|
674
893
|
const showSpinner = (label) => {
|
|
675
894
|
if (spinner === undefined) {
|
|
676
895
|
spinner = startSpinner(label);
|
|
896
|
+
}
|
|
897
|
+
else {
|
|
898
|
+
spinner.update(label);
|
|
899
|
+
}
|
|
900
|
+
paintTyped();
|
|
901
|
+
};
|
|
902
|
+
// A fresh word each time the turn goes back to thinking, so a long
|
|
903
|
+
// turn visibly moves through its steps instead of sitting on one label
|
|
904
|
+
// from the first second to the last. Held between draws — the spinner
|
|
905
|
+
// is repainted on every tool start and finish, and re-rolling there
|
|
906
|
+
// would make the word flicker rather than mark progress.
|
|
907
|
+
let thinkingWord = pickThinkingWord();
|
|
908
|
+
let wasRunningTool = false;
|
|
909
|
+
// The most recent tool labels the spinner. Nothing running means the
|
|
910
|
+
// turn is thinking rather than working.
|
|
911
|
+
const showActiveToolSpinner = () => {
|
|
912
|
+
const current = activeTools[activeTools.length - 1];
|
|
913
|
+
if (current === undefined) {
|
|
914
|
+
if (wasRunningTool === true) {
|
|
915
|
+
thinkingWord = pickThinkingWord(thinkingWord);
|
|
916
|
+
wasRunningTool = false;
|
|
917
|
+
}
|
|
918
|
+
showSpinner(thinkingWord);
|
|
677
919
|
return;
|
|
678
920
|
}
|
|
679
|
-
|
|
921
|
+
wasRunningTool = true;
|
|
922
|
+
showSpinner(describeToolActivity(current.toolName, current.summary));
|
|
923
|
+
};
|
|
924
|
+
// A tool is announced twice — once when its name arrives, again with a
|
|
925
|
+
// summary once its input is known — so the second has to overwrite the
|
|
926
|
+
// first rather than stack a duplicate label.
|
|
927
|
+
const rememberTool = (tool) => {
|
|
928
|
+
const at = activeTools.findIndex((active) => active.toolCallId === tool.toolCallId);
|
|
929
|
+
if (at === -1) {
|
|
930
|
+
activeTools.push(tool);
|
|
931
|
+
}
|
|
932
|
+
else {
|
|
933
|
+
activeTools[at] = tool;
|
|
934
|
+
}
|
|
935
|
+
showActiveToolSpinner();
|
|
936
|
+
};
|
|
937
|
+
const forgetTool = (toolCallId) => {
|
|
938
|
+
const at = activeTools.findIndex((active) => active.toolCallId === toolCallId);
|
|
939
|
+
if (at !== -1) {
|
|
940
|
+
activeTools.splice(at, 1);
|
|
941
|
+
}
|
|
942
|
+
showActiveToolSpinner();
|
|
680
943
|
};
|
|
681
944
|
// Registered before the spinner so our SIGINT handler runs first and
|
|
682
945
|
// clack's spinner is already stopped when its own handler fires —
|
|
683
946
|
// otherwise Ctrl-C prints its cancel line under ours.
|
|
684
947
|
const cancellation = beginCancellableTurn(stopSpinner);
|
|
685
|
-
spinner = startSpinner(
|
|
948
|
+
spinner = startSpinner(thinkingWord);
|
|
949
|
+
const typing = beginTurnTyping({
|
|
950
|
+
carriedLine,
|
|
951
|
+
onInterrupt: () => cancellation.interrupt(),
|
|
952
|
+
onTyped: (text) => {
|
|
953
|
+
typedLine = text;
|
|
954
|
+
paintTyped();
|
|
955
|
+
},
|
|
956
|
+
});
|
|
957
|
+
carriedLine = "";
|
|
686
958
|
try {
|
|
687
959
|
await local.streamPrompt(question, {
|
|
688
960
|
abortSignal: cancellation.signal,
|
|
961
|
+
onStall: () => {
|
|
962
|
+
cancellation.abort();
|
|
963
|
+
},
|
|
689
964
|
onTextDelta: (delta) => {
|
|
690
965
|
if (live === undefined) {
|
|
691
966
|
stopSpinner();
|
|
692
967
|
live = beginAssistantTurn();
|
|
968
|
+
// The spinner was carrying the typed line; it goes with it.
|
|
969
|
+
paintTyped();
|
|
693
970
|
}
|
|
694
971
|
saidSomething = true;
|
|
695
972
|
appendAssistantDelta(live, delta);
|
|
@@ -699,15 +976,37 @@ export async function runAskSession(options) {
|
|
|
699
976
|
// not an unexplained pause. Claude Code narrates before it acts,
|
|
700
977
|
// so a tool usually starts with live text on screen: close that
|
|
701
978
|
// block first, and the next delta opens a new one.
|
|
979
|
+
//
|
|
980
|
+
// "dropped" is a tool the harness announced and then abandoned.
|
|
981
|
+
// It has no result coming and it never ran, so it is forgotten
|
|
982
|
+
// without a line — the transcript should hold what happened, not
|
|
983
|
+
// what was briefly considered.
|
|
984
|
+
if (activity.kind === "dropped") {
|
|
985
|
+
forgetTool(activity.toolCallId);
|
|
986
|
+
return;
|
|
987
|
+
}
|
|
702
988
|
if (activity.kind === "end") {
|
|
703
|
-
|
|
989
|
+
// Read back before forgetting: only the remembered call knows
|
|
990
|
+
// the input, and `end` carries the name and the outcome alone.
|
|
991
|
+
const finished = activeTools.find((active) => active.toolCallId === activity.toolCallId);
|
|
992
|
+
showToolRun({
|
|
993
|
+
spinner,
|
|
994
|
+
toolName: activity.toolName,
|
|
995
|
+
summary: finished === undefined ? "" : finished.summary,
|
|
996
|
+
isError: activity.isError,
|
|
997
|
+
});
|
|
998
|
+
forgetTool(activity.toolCallId);
|
|
704
999
|
return;
|
|
705
1000
|
}
|
|
706
1001
|
if (live !== undefined) {
|
|
707
1002
|
endAssistantTurn(live);
|
|
708
1003
|
live = undefined;
|
|
709
1004
|
}
|
|
710
|
-
|
|
1005
|
+
rememberTool({
|
|
1006
|
+
toolCallId: activity.toolCallId,
|
|
1007
|
+
toolName: activity.toolName,
|
|
1008
|
+
summary: activity.summary,
|
|
1009
|
+
});
|
|
711
1010
|
},
|
|
712
1011
|
onFileTouched: (path) => {
|
|
713
1012
|
touchedFiles.add(path);
|
|
@@ -722,14 +1021,40 @@ export async function runAskSession(options) {
|
|
|
722
1021
|
live = undefined;
|
|
723
1022
|
}
|
|
724
1023
|
stopSpinner();
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
1024
|
+
let decision;
|
|
1025
|
+
if (approveEveryTool === true) {
|
|
1026
|
+
decision = "always";
|
|
1027
|
+
}
|
|
1028
|
+
else {
|
|
1029
|
+
// The picker reads the keyboard itself, so the type-ahead
|
|
1030
|
+
// collector has to let go of it — otherwise it swallows the
|
|
1031
|
+
// arrow keys and the approval can never be answered. Awaited,
|
|
1032
|
+
// because letting go settles first: the picker appears under
|
|
1033
|
+
// whatever the user is already typing and defaults to Yes, so
|
|
1034
|
+
// a keystroke in flight must not be able to answer it.
|
|
1035
|
+
await typing.pause();
|
|
1036
|
+
try {
|
|
1037
|
+
decision = await askToolApproval(approval);
|
|
1038
|
+
}
|
|
1039
|
+
finally {
|
|
1040
|
+
typing.resume();
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
728
1043
|
if (decision === "always") {
|
|
729
1044
|
approveEveryTool = true;
|
|
730
1045
|
}
|
|
731
|
-
|
|
732
|
-
|
|
1046
|
+
if (decision === "no") {
|
|
1047
|
+
forgetTool(approval.toolCallId);
|
|
1048
|
+
return false;
|
|
1049
|
+
}
|
|
1050
|
+
// The call was announced before the prompt; keep its label up
|
|
1051
|
+
// while it runs instead of falling back to a thinking word.
|
|
1052
|
+
rememberTool({
|
|
1053
|
+
toolCallId: approval.toolCallId,
|
|
1054
|
+
toolName: approval.toolName,
|
|
1055
|
+
summary: approval.summary,
|
|
1056
|
+
});
|
|
1057
|
+
return true;
|
|
733
1058
|
},
|
|
734
1059
|
});
|
|
735
1060
|
stopSpinner();
|
|
@@ -747,7 +1072,35 @@ export async function runAskSession(options) {
|
|
|
747
1072
|
if (live !== undefined) {
|
|
748
1073
|
endAssistantTurn(live);
|
|
749
1074
|
}
|
|
1075
|
+
// The turn is over, whichever way it went: every branch below either
|
|
1076
|
+
// replaces the Claude Code session or unwinds the command. So a
|
|
1077
|
+
// Ctrl-C from here cannot stop anything the user would want stopped
|
|
1078
|
+
// — all it can do is skip the teardown, since `process.exit` runs no
|
|
1079
|
+
// `finally` and the `finally` blocks are what stop Claude Code.
|
|
1080
|
+
//
|
|
1081
|
+
// Marked for the whole unwind rather than the cancel branch alone. A
|
|
1082
|
+
// stall waits on the same `close` with the collector still holding
|
|
1083
|
+
// the terminal, and it is the likelier press of the two: minutes of
|
|
1084
|
+
// spinner is what makes someone reach for Ctrl-C in the first place.
|
|
1085
|
+
//
|
|
1086
|
+
// It also freezes the flag the branch below reads, so a press
|
|
1087
|
+
// landing here cannot make a stall look like a user cancel.
|
|
1088
|
+
cancellation.beginTeardown();
|
|
750
1089
|
if (cancellation.wasCancelled() === true) {
|
|
1090
|
+
// Ctrl-C aborts the harness stream, and what it leaves behind is a
|
|
1091
|
+
// session that answers nothing: the next question opens a stream
|
|
1092
|
+
// that yields its `start` part and then goes silent until the
|
|
1093
|
+
// stall watchdog gives up on it two minutes later. The harness
|
|
1094
|
+
// does not report it as an unfinished turn, so there is nothing to
|
|
1095
|
+
// drain and no way to tell from the outside — the only cure is a
|
|
1096
|
+
// new Claude Code process.
|
|
1097
|
+
//
|
|
1098
|
+
// Paid here rather than on the next question so the wait belongs
|
|
1099
|
+
// to the interrupt the user just pressed. The chat, its history
|
|
1100
|
+
// and the working copy all survive; the next turn replays the
|
|
1101
|
+
// history into a fresh session.
|
|
1102
|
+
await local.close();
|
|
1103
|
+
local = undefined;
|
|
751
1104
|
showTouchedFiles(touchedFiles, options.dir);
|
|
752
1105
|
showNotices(notices);
|
|
753
1106
|
showCard("Cancelled.");
|
|
@@ -758,19 +1111,27 @@ export async function runAskSession(options) {
|
|
|
758
1111
|
continue;
|
|
759
1112
|
}
|
|
760
1113
|
showNotices(notices);
|
|
761
|
-
// The runtime could not be wound down,
|
|
762
|
-
//
|
|
763
|
-
//
|
|
1114
|
+
// The runtime could not be wound down, went silent, or the bridge
|
|
1115
|
+
// dropped — this session will refuse every question from here.
|
|
1116
|
+
// Replace it rather than exiting Ask: the chat, its history and the
|
|
764
1117
|
// working copy all survive; only the Claude Code process is new.
|
|
765
|
-
if (error instanceof HarnessTurnStuck
|
|
1118
|
+
if (error instanceof HarnessTurnStuck ||
|
|
1119
|
+
error instanceof HarnessTurnStalled ||
|
|
1120
|
+
isBridgeClosedError(error) === true) {
|
|
766
1121
|
await local.close();
|
|
767
1122
|
local = undefined;
|
|
768
|
-
showCard(
|
|
1123
|
+
showCard(error instanceof HarnessTurnStalled
|
|
1124
|
+
? `${error.message} The coding session has been replaced — ask again, or try smaller steps.`
|
|
1125
|
+
: "That coding session stopped responding, so it has been replaced. Ask again — the chat is unchanged.");
|
|
769
1126
|
continue;
|
|
770
1127
|
}
|
|
771
1128
|
throw error;
|
|
772
1129
|
}
|
|
773
1130
|
finally {
|
|
1131
|
+
// Before `cancellation.release()`: while the collector still owns
|
|
1132
|
+
// the terminal, Ctrl-C is a keypress it turns into an interrupt, and
|
|
1133
|
+
// dropping the handler first would leave a window with neither.
|
|
1134
|
+
typedAhead = typing.finish();
|
|
774
1135
|
cancellation.release();
|
|
775
1136
|
await releaseChatTurn(options.api, chatUuid, sessionId);
|
|
776
1137
|
}
|
|
@@ -808,7 +1169,9 @@ export async function runAskSession(options) {
|
|
|
808
1169
|
// Registered before the spinner so our handler runs before clack's own
|
|
809
1170
|
// — same ordering as the local turn.
|
|
810
1171
|
const cancellation = beginCancellableTurn(stopRemoteSpinner);
|
|
811
|
-
|
|
1172
|
+
// A remote turn is one stream with no tool steps of its own to mark, so
|
|
1173
|
+
// it gets the one word rather than a sequence of them.
|
|
1174
|
+
spinner = startSpinner(pickThinkingWord());
|
|
812
1175
|
let assistantMessageUuid;
|
|
813
1176
|
try {
|
|
814
1177
|
const deltas = streamMessageTextDeltas({
|