@cargo-ai/cli 1.0.92 → 1.0.95

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.
Files changed (130) hide show
  1. package/README.md +17 -17
  2. package/build/commands/ai/chat.d.ts.map +1 -1
  3. package/build/commands/ai/chat.js +2 -1
  4. package/build/commands/ai/message.d.ts.map +1 -1
  5. package/build/commands/ai/message.js +2 -1
  6. package/build/commands/ai/release.d.ts.map +1 -1
  7. package/build/commands/ai/release.js +7 -6
  8. package/build/commands/ask/harness/approvals.d.ts +7 -0
  9. package/build/commands/ask/harness/approvals.d.ts.map +1 -0
  10. package/build/commands/ask/harness/approvals.js +82 -0
  11. package/build/commands/ask/harness/artifacts.d.ts.map +1 -0
  12. package/build/commands/ask/harness/claudeConfig.d.ts.map +1 -0
  13. package/build/commands/ask/harness/create.d.ts +59 -0
  14. package/build/commands/ask/harness/create.d.ts.map +1 -0
  15. package/build/commands/ask/harness/create.js +292 -0
  16. package/build/commands/ask/harness/index.d.ts +8 -0
  17. package/build/commands/ask/harness/index.d.ts.map +1 -0
  18. package/build/commands/ask/harness/index.js +7 -0
  19. package/build/commands/ask/harness/ingest.d.ts +25 -0
  20. package/build/commands/ask/harness/ingest.d.ts.map +1 -0
  21. package/build/commands/ask/harness/ingest.js +35 -0
  22. package/build/commands/ask/harness/instructions.d.ts +13 -0
  23. package/build/commands/ask/harness/instructions.d.ts.map +1 -0
  24. package/build/commands/ask/harness/instructions.js +41 -0
  25. package/build/commands/ask/harness/layout.d.ts +23 -0
  26. package/build/commands/ask/harness/layout.d.ts.map +1 -0
  27. package/build/commands/ask/harness/layout.js +32 -0
  28. package/build/commands/ask/harness/mcp.d.ts +25 -0
  29. package/build/commands/ask/harness/mcp.d.ts.map +1 -0
  30. package/build/commands/ask/harness/mcp.js +72 -0
  31. package/build/commands/ask/harness/pnpmShim.d.ts.map +1 -0
  32. package/build/commands/ask/harness/release.d.ts +18 -0
  33. package/build/commands/ask/harness/release.d.ts.map +1 -0
  34. package/build/commands/ask/harness/release.js +117 -0
  35. package/build/commands/ask/harness/sandbox.d.ts +9 -0
  36. package/build/commands/ask/harness/sandbox.d.ts.map +1 -0
  37. package/build/commands/ask/harness/sandbox.js +27 -0
  38. package/build/commands/ask/harness/timing.d.ts +16 -0
  39. package/build/commands/ask/harness/timing.d.ts.map +1 -0
  40. package/build/commands/ask/harness/timing.js +72 -0
  41. package/build/commands/ask/harness/turn.d.ts +145 -0
  42. package/build/commands/ask/harness/turn.d.ts.map +1 -0
  43. package/build/commands/ask/harness/turn.js +626 -0
  44. package/build/commands/ask/harness/usage.d.ts +50 -0
  45. package/build/commands/ask/harness/usage.d.ts.map +1 -0
  46. package/build/commands/ask/harness/usage.js +87 -0
  47. package/build/commands/ask/index.d.ts +9 -0
  48. package/build/commands/ask/index.d.ts.map +1 -1
  49. package/build/commands/ask/index.js +7 -7
  50. package/build/commands/ask/input/completer.d.ts.map +1 -0
  51. package/build/commands/ask/input/index.d.ts +5 -0
  52. package/build/commands/ask/input/index.d.ts.map +1 -0
  53. package/build/commands/ask/input/index.js +4 -0
  54. package/build/commands/ask/input/prompt.d.ts.map +1 -0
  55. package/build/commands/ask/{prompt.js → input/prompt.js} +1 -1
  56. package/build/commands/ask/input/slash.d.ts.map +1 -0
  57. package/build/commands/ask/{slash.js → input/slash.js} +2 -2
  58. package/build/commands/ask/input/typeAhead.d.ts.map +1 -0
  59. package/build/commands/ask/{history.d.ts → persist/history.d.ts} +1 -1
  60. package/build/commands/ask/persist/history.d.ts.map +1 -0
  61. package/build/commands/ask/persist/index.d.ts +4 -0
  62. package/build/commands/ask/persist/index.d.ts.map +1 -0
  63. package/build/commands/ask/persist/index.js +3 -0
  64. package/build/commands/ask/persist/persist.d.ts.map +1 -0
  65. package/build/commands/ask/persist/state.d.ts.map +1 -0
  66. package/build/commands/ask/session/claim.d.ts +56 -0
  67. package/build/commands/ask/session/claim.d.ts.map +1 -0
  68. package/build/commands/ask/session/claim.js +76 -0
  69. package/build/commands/ask/session/index.d.ts +5 -0
  70. package/build/commands/ask/session/index.d.ts.map +1 -0
  71. package/build/commands/ask/session/index.js +4 -0
  72. package/build/commands/ask/session/run.d.ts +36 -0
  73. package/build/commands/ask/session/run.d.ts.map +1 -0
  74. package/build/commands/ask/session/run.js +832 -0
  75. package/build/commands/ask/session/thinking.d.ts.map +1 -0
  76. package/build/commands/ask/session/tree.d.ts +90 -0
  77. package/build/commands/ask/session/tree.d.ts.map +1 -0
  78. package/build/commands/ask/session/tree.js +318 -0
  79. package/build/commands/start/brief.js +1 -1
  80. package/build/commands/start/index.d.ts +1 -1
  81. package/build/commands/start/index.d.ts.map +1 -1
  82. package/build/commands/start/index.js +7 -7
  83. package/build/index.js +6 -1
  84. package/build/paletteLayout.d.ts.map +1 -1
  85. package/build/paletteLayout.js +9 -8
  86. package/build/utils/cdkState.js +2 -2
  87. package/build/utils/loadCompiledAgent.js +1 -1
  88. package/package.json +6 -5
  89. package/build/commands/ask/artifacts.d.ts.map +0 -1
  90. package/build/commands/ask/binding.d.ts +0 -13
  91. package/build/commands/ask/binding.d.ts.map +0 -1
  92. package/build/commands/ask/binding.js +0 -19
  93. package/build/commands/ask/claudeConfig.d.ts.map +0 -1
  94. package/build/commands/ask/completer.d.ts.map +0 -1
  95. package/build/commands/ask/history.d.ts.map +0 -1
  96. package/build/commands/ask/localHarness.d.ts +0 -278
  97. package/build/commands/ask/localHarness.d.ts.map +0 -1
  98. package/build/commands/ask/localHarness.js +0 -1300
  99. package/build/commands/ask/messageStream.d.ts +0 -33
  100. package/build/commands/ask/messageStream.d.ts.map +0 -1
  101. package/build/commands/ask/messageStream.js +0 -114
  102. package/build/commands/ask/persist.d.ts.map +0 -1
  103. package/build/commands/ask/pnpmShim.d.ts.map +0 -1
  104. package/build/commands/ask/prompt.d.ts.map +0 -1
  105. package/build/commands/ask/session.d.ts +0 -101
  106. package/build/commands/ask/session.d.ts.map +0 -1
  107. package/build/commands/ask/session.js +0 -1328
  108. package/build/commands/ask/slash.d.ts.map +0 -1
  109. package/build/commands/ask/state.d.ts.map +0 -1
  110. package/build/commands/ask/thinking.d.ts.map +0 -1
  111. package/build/commands/ask/typeAhead.d.ts.map +0 -1
  112. /package/build/commands/ask/{artifacts.d.ts → harness/artifacts.d.ts} +0 -0
  113. /package/build/commands/ask/{artifacts.js → harness/artifacts.js} +0 -0
  114. /package/build/commands/ask/{claudeConfig.d.ts → harness/claudeConfig.d.ts} +0 -0
  115. /package/build/commands/ask/{claudeConfig.js → harness/claudeConfig.js} +0 -0
  116. /package/build/commands/ask/{pnpmShim.d.ts → harness/pnpmShim.d.ts} +0 -0
  117. /package/build/commands/ask/{pnpmShim.js → harness/pnpmShim.js} +0 -0
  118. /package/build/commands/ask/{completer.d.ts → input/completer.d.ts} +0 -0
  119. /package/build/commands/ask/{completer.js → input/completer.js} +0 -0
  120. /package/build/commands/ask/{prompt.d.ts → input/prompt.d.ts} +0 -0
  121. /package/build/commands/ask/{slash.d.ts → input/slash.d.ts} +0 -0
  122. /package/build/commands/ask/{typeAhead.d.ts → input/typeAhead.d.ts} +0 -0
  123. /package/build/commands/ask/{typeAhead.js → input/typeAhead.js} +0 -0
  124. /package/build/commands/ask/{history.js → persist/history.js} +0 -0
  125. /package/build/commands/ask/{persist.d.ts → persist/persist.d.ts} +0 -0
  126. /package/build/commands/ask/{persist.js → persist/persist.js} +0 -0
  127. /package/build/commands/ask/{state.d.ts → persist/state.d.ts} +0 -0
  128. /package/build/commands/ask/{state.js → persist/state.js} +0 -0
  129. /package/build/commands/ask/{thinking.d.ts → session/thinking.d.ts} +0 -0
  130. /package/build/commands/ask/{thinking.js → session/thinking.js} +0 -0
@@ -1,1328 +0,0 @@
1
- import { randomUUID } from "node:crypto";
2
- import { isAbsolute, relative } from "node:path";
3
- import { determineIfIsFetcherError } from "@cargo-ai/api";
4
- import { isInteractive, selectOne } from "@cargo-ai/cdk/cli";
5
- import { getConfig } from "../../config.js";
6
- import { openPromptInput } from "../../utils/prompt.js";
7
- import { clipToWidth, colors, ExitCodes, failWith, handleApiCall, outputJson, pollUntilFinished, startSpinner, } from "../runHandler.js";
8
- import { loadChatHistoryPreamble } from "./history.js";
9
- import { createLocalHarness, HarnessTurnStalled, HarnessTurnStuck, isBridgeClosedError, } from "./localHarness.js";
10
- import { streamMessageTextDeltas } from "./messageStream.js";
11
- import { saveAskState } from "./persist.js";
12
- import { readAskLine } from "./prompt.js";
13
- import { parseSlash } from "./slash.js";
14
- import { ASK_STATE_VERSION } from "./state.js";
15
- import { pickThinkingWord } from "./thinking.js";
16
- import { collectTypeAhead, EMPTY_TYPE_AHEAD, } from "./typeAhead.js";
17
- function requireInteractive() {
18
- if (isInteractive() === false || process.stdout.isTTY !== true) {
19
- failWith("Ask needs a terminal. Re-run with --print for one JSON turn.", {
20
- code: ExitCodes.InvalidUsage,
21
- });
22
- }
23
- }
24
- /** Sentinel for `/agent` → Local workspace (no agentUuid). */
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 = "⏺";
38
- /**
39
- * How long before a proxy token's stated expiry to stop trusting it. Covers
40
- * the gap between deciding to reuse the harness and the first proxy call the
41
- * turn makes.
42
- */
43
- const TOKEN_REFRESH_MARGIN_MS = 5 * 60_000;
44
- /**
45
- * When the proxy token baked into the running harness stops being safe to
46
- * reuse.
47
- *
48
- * The server re-arms the token on every proxy call, so the clock runs from
49
- * the last turn that reached Anthropic rather than from when it was minted.
50
- * Anchoring it to mint time instead would tear down a conversation that is
51
- * still going — a cold start, a replayed history preamble and the loss of
52
- * Claude Code's in-session memory, for a token that had not expired.
53
- *
54
- * Exported for tests.
55
- */
56
- export function proxyTokenDeadlineMs(input) {
57
- const armedAtMs = input.lastUsedAtMs === undefined ? input.mintedAtMs : input.lastUsedAtMs;
58
- return armedAtMs + input.ttlMs - TOKEN_REFRESH_MARGIN_MS;
59
- }
60
- const PRINT_POLL_INTERVAL_MS = 5_000;
61
- /**
62
- * How long `--print` waits for a remote turn. Generous, because a multi-step
63
- * agent legitimately runs for a while — but bounded, because `--print` is the
64
- * path used by pipes and CI, where nobody is there to notice a wait that
65
- * never ends.
66
- */
67
- const PRINT_POLL_TIMEOUT_MS = 30 * 60_000;
68
- async function loadAgent(api, agentUuid) {
69
- const result = await handleApiCall(() => api.ai.agent.get(agentUuid));
70
- return result.agent;
71
- }
72
- /**
73
- * No agent → local Claude Code on the host. Any agent streams remotely, like
74
- * the webapp chat: what it runs — a tool loop or a harness against its bound
75
- * repo — follows from its deployed release, which the API resolves itself.
76
- *
77
- * Exported for tests.
78
- */
79
- export function modeForAgent(agent) {
80
- return agent === undefined ? { kind: "local" } : { kind: "remote" };
81
- }
82
- /**
83
- * Pick a workspace agent, or Local workspace (clears the agent).
84
- * Returns undefined when the user chooses the local default.
85
- */
86
- async function pickAgent(api, currentUuid) {
87
- const { agents } = await handleApiCall(() => api.ai.agent.all());
88
- const chosen = await selectOne({
89
- message: "Agent for the next question",
90
- recommended: currentUuid !== undefined ? currentUuid : LOCAL_WORKSPACE,
91
- choices: [
92
- {
93
- value: LOCAL_WORKSPACE,
94
- label: "Local workspace",
95
- hint: "Claude Code on this machine (default)",
96
- fallback: true,
97
- },
98
- ...agents.map((agent) => ({
99
- value: agent.uuid,
100
- label: agent.name,
101
- hint: agent.uuid,
102
- })),
103
- ],
104
- });
105
- if (chosen === LOCAL_WORKSPACE) {
106
- return undefined;
107
- }
108
- return chosen;
109
- }
110
- /**
111
- * `harnessSlug` is deliberately not sent. On the API it is a chat-level
112
- * override that outranks the agent's release for the life of the chat, so a
113
- * chat stamped here would keep routing to a harness even after `/agent` picks
114
- * something else — and a local chat's harness runs on this machine, where the
115
- * API could not reach it anyway. Letting the release speak for itself keeps the
116
- * chat reusable by any agent.
117
- */
118
- async function ensureChat(input) {
119
- if (input.created === true) {
120
- return;
121
- }
122
- await handleApiCall(() => input.api.ai.chat.create({
123
- uuid: input.chatUuid,
124
- trigger: { type: "cli" },
125
- agentUuid: input.agentUuid,
126
- }), { spinner: false });
127
- }
128
- function persistAskState(input) {
129
- saveAskState({
130
- version: ASK_STATE_VERSION,
131
- chatUuid: input.chatUuid,
132
- agentUuid: input.agentUuid,
133
- updatedAt: new Date().toISOString(),
134
- });
135
- }
136
- /**
137
- * Take the chat for one local turn.
138
- *
139
- * Exported for tests.
140
- */
141
- export async function claimChatTurn(api, chatUuid, ownerId) {
142
- try {
143
- await api.ai.chat.claimTurn(chatUuid, { ownerId });
144
- return { outcome: "claimed" };
145
- }
146
- catch (error) {
147
- if (determineIfIsFetcherError(error) === true &&
148
- error.status === 409) {
149
- return { outcome: "busy" };
150
- }
151
- // Handed back rather than reported from here. Reporting an API failure
152
- // ends the process, and the interactive session has a Claude Code process
153
- // to shut down first — `process.exit` does not run the `finally` that
154
- // would have done it.
155
- return { outcome: "unavailable", error };
156
- }
157
- }
158
- /**
159
- * Report an API failure the way every other call in the CLI does, and exit.
160
- * Never returns: `handleApiCall` exits on a recognised API error, and anything
161
- * else is rethrown for the top-level handler.
162
- */
163
- async function reportApiFailure(error) {
164
- await handleApiCall(async () => {
165
- throw error;
166
- }, { spinner: false });
167
- throw error;
168
- }
169
- /**
170
- * Hand the chat back after a turn.
171
- *
172
- * Recording the turn already does this, but a turn that spent nothing has
173
- * nothing to record — a stall, an expired token, a workspace out of credits —
174
- * and the claim would sit there until it expired, telling the next process the
175
- * chat was busy.
176
- *
177
- * Best effort: the TTL is the backstop, and a reply the user has already read
178
- * must not fail on the way out.
179
- */
180
- async function releaseChatTurn(api, chatUuid, ownerId) {
181
- try {
182
- await api.ai.chat.releaseTurn(chatUuid, { ownerId });
183
- }
184
- catch {
185
- // Nothing useful to say to the user about this one.
186
- }
187
- }
188
- export async function doesChatExist(api, chatUuid) {
189
- try {
190
- await api.ai.chat.get(chatUuid);
191
- return true;
192
- }
193
- catch (error) {
194
- if (determineIfIsFetcherError(error) === true &&
195
- error.status === 404) {
196
- return false;
197
- }
198
- await handleApiCall(async () => {
199
- throw error;
200
- }, { spinner: false });
201
- return false;
202
- }
203
- }
204
- /**
205
- * Body width under `◇ ` / `│ ` / `◆ `. Soft wrap without this drops the
206
- * rail mid-line and the tree looks broken.
207
- */
208
- function treeBodyWidth() {
209
- const columns = process.stderr.columns === undefined ? 80 : process.stderr.columns;
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);
214
- }
215
- /**
216
- * Greedy wrap on spaces. A word longer than the width keeps its own line
217
- * rather than being cut mid-token.
218
- *
219
- * Exported for tests.
220
- */
221
- export function wrapLine(text, width) {
222
- const lines = [];
223
- for (const word of text.split(/\s+/).filter((part) => part.length > 0)) {
224
- const current = lines[lines.length - 1];
225
- if (current === undefined || `${current} ${word}`.length > width) {
226
- lines.push(word);
227
- continue;
228
- }
229
- lines[lines.length - 1] = `${current} ${word}`;
230
- }
231
- return lines;
232
- }
233
- /** One line under the current ◆/◇, with the │ rail (same indent as clack). */
234
- function writeRail(body) {
235
- process.stderr.write(`${colors.dim("│")} ${body}\n`);
236
- }
237
- /** Word-wrap onto the │ rail so the terminal never soft-wraps mid-line. */
238
- function writeRailWrapped(body) {
239
- // Keep the rail on blank paragraphs so the tree does not break mid-reply.
240
- if (body === "") {
241
- writeRail("");
242
- return;
243
- }
244
- const lines = wrapLine(body, treeBodyWidth());
245
- if (lines.length === 0) {
246
- writeRail("");
247
- return;
248
- }
249
- for (const line of lines) {
250
- writeRail(line);
251
- }
252
- }
253
- /**
254
- * Light cleanup so model markdown does not fight the tree. Keeps the words;
255
- * drops heading marks, bold markers, and horizontal rules.
256
- */
257
- function forTerminal(text) {
258
- return text
259
- .replace(/^#{1,6}\s+/gm, "")
260
- .replace(/\*\*([^*]+)\*\*/g, "$1")
261
- .replace(/^---+$/gm, "");
262
- }
263
- /**
264
- * One finished step: label on the diamond, body on the rail.
265
- *
266
- * ◇ Agent
267
- * │ reply…
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
- }
286
- function showStep(label, body) {
287
- writeDiamondBlock(label);
288
- for (const paragraph of body.split("\n")) {
289
- writeRailWrapped(paragraph);
290
- }
291
- }
292
- /**
293
- * Slash help / notes — finished step like the palette:
294
- * ◇ /help
295
- * │ …
296
- */
297
- function showCard(body) {
298
- const paragraphs = body.split("\n");
299
- const title = paragraphs[0] === undefined ? "" : paragraphs[0];
300
- const rest = paragraphs.slice(1).join("\n");
301
- showStep(title, rest);
302
- }
303
- /**
304
- * Finished user turn — message on the diamond, like the palette's
305
- * `◇ ask hey --continue`. No "You" label.
306
- */
307
- function showUserTurn(body) {
308
- writeDiamondBlock(body);
309
- }
310
- /**
311
- * Display lines for an assistant body. First line is the brick header body;
312
- * later lines sit on the │ rail. Blank paragraphs keep an empty rail.
313
- */
314
- function assistantDisplayLines(body) {
315
- const paragraphs = forTerminal(body).split("\n");
316
- const lines = [];
317
- for (let index = 0; index < paragraphs.length; index += 1) {
318
- const paragraph = paragraphs[index];
319
- if (paragraph === undefined) {
320
- continue;
321
- }
322
- if (paragraph === "") {
323
- lines.push("");
324
- continue;
325
- }
326
- const wrapped = wrapLine(paragraph, treeBodyWidth());
327
- if (wrapped.length === 0) {
328
- lines.push("");
329
- continue;
330
- }
331
- for (const line of wrapped) {
332
- lines.push(line);
333
- }
334
- }
335
- if (lines.length === 0) {
336
- return [""];
337
- }
338
- return lines;
339
- }
340
- function writeAssistantLines(lines) {
341
- const first = lines[0] === undefined ? "" : lines[0];
342
- process.stderr.write(`${ASSISTANT_MARKER} ${first}\n`);
343
- for (let index = 1; index < lines.length; index += 1) {
344
- const line = lines[index];
345
- if (line !== undefined) {
346
- writeRail(line);
347
- }
348
- }
349
- }
350
- function clearPaintedLines(count) {
351
- if (count <= 0 || process.stderr.isTTY !== true) {
352
- return;
353
- }
354
- process.stderr.write(`\x1B[${String(count)}A\r\x1B[J`);
355
- }
356
- /** Rail gap, then an empty marker line ready for deltas. */
357
- function beginAssistantTurn() {
358
- writeRail("");
359
- process.stderr.write(`${ASSISTANT_MARKER} \n`);
360
- return { buffer: "", paintedLines: 1, footer: "" };
361
- }
362
- function appendAssistantDelta(live, delta) {
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) {
378
- const lines = assistantDisplayLines(live.buffer);
379
- clearPaintedLines(live.paintedLines);
380
- writeAssistantLines(lines);
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);
396
- }
397
- /** Trailing rail gap before the next user turn / ◆ prompt. */
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, "");
402
- writeRail("");
403
- }
404
- /**
405
- * Finished assistant turn — same tree as a user step, with `■` instead of `◇`.
406
- */
407
- function showAssistantTurn(body) {
408
- const live = beginAssistantTurn();
409
- appendAssistantDelta(live, body);
410
- endAssistantTurn(live);
411
- }
412
- /**
413
- * Ctrl-C during a turn cancels the turn, not the CLI: the harness interrupts
414
- * Claude Code, the tree says so, and the ◆ prompt comes back. A second Ctrl-C
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.
423
- */
424
- function beginCancellableTurn(onCancel) {
425
- const controller = new AbortController();
426
- const state = { cancelled: false, tearingDown: false };
427
- const handleInterrupt = () => {
428
- if (state.tearingDown === true) {
429
- return;
430
- }
431
- if (state.cancelled === true) {
432
- // 130 is the conventional "terminated by SIGINT" status.
433
- process.exit(130);
434
- }
435
- state.cancelled = true;
436
- onCancel();
437
- controller.abort();
438
- };
439
- process.on("SIGINT", handleInterrupt);
440
- return {
441
- signal: controller.signal,
442
- wasCancelled: () => state.cancelled,
443
- abort: () => {
444
- controller.abort();
445
- },
446
- beginTeardown: () => {
447
- state.tearingDown = true;
448
- },
449
- interrupt: handleInterrupt,
450
- release: () => {
451
- process.removeListener("SIGINT", handleInterrupt);
452
- },
453
- };
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
- }
497
- /**
498
- * Stop a remote turn the user walked away from.
499
- *
500
- * Best effort: the uuid is only known once the server has announced the
501
- * message, and a failed cancel must not take the chat session down with it —
502
- * the next turn reports `lastMessageNotFinished` if this did not land.
503
- */
504
- async function cancelRemoteMessage(api, messageUuid) {
505
- if (messageUuid === undefined) {
506
- return;
507
- }
508
- try {
509
- await api.ai.message.cancel({ uuids: [messageUuid] });
510
- }
511
- catch {
512
- // Nothing useful to do here; surfacing it would bury the "Cancelled." card.
513
- }
514
- }
515
- /** Common tool names arrive lowercase (`bash`); natives keep their case. */
516
- function toolLabel(toolName) {
517
- if (toolName.length === 0) {
518
- return "tool";
519
- }
520
- return `${toolName.slice(0, 1).toUpperCase()}${toolName.slice(1)}`;
521
- }
522
- const ACTIVITY_MAX_LENGTH = 72;
523
- function describeToolActivity(toolName, summary) {
524
- const label = toolLabel(toolName);
525
- if (summary === "") {
526
- return label;
527
- }
528
- const line = `${label}: ${summary}`;
529
- return line.length > ACTIVITY_MAX_LENGTH
530
- ? `${line.slice(0, ACTIVITY_MAX_LENGTH - 1)}…`
531
- : line;
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
- }
555
- /**
556
- * A path inside the session directory reads better relative to it; anything
557
- * outside stays absolute so it is obvious the turn left the project.
558
- *
559
- * Exported for tests.
560
- */
561
- export function displayPath(path, dir) {
562
- const relativePath = relative(dir, path);
563
- return relativePath === "" ||
564
- relativePath.startsWith("..") === true ||
565
- isAbsolute(relativePath) === true
566
- ? path
567
- : relativePath;
568
- }
569
- /**
570
- * Closing step for a turn that edited the working copy, so a reply that says
571
- * "done" is backed by the list of files it touched.
572
- */
573
- function showTouchedFiles(paths, dir) {
574
- if (paths.size === 0) {
575
- return;
576
- }
577
- const listed = [...paths].map((path) => displayPath(path, dir)).sort();
578
- showStep(`${String(listed.length)} file${listed.length === 1 ? "" : "s"} changed`, listed.join("\n"));
579
- }
580
- /**
581
- * Side notes from a turn — a chat-history save that failed, say. They are not
582
- * part of the reply, so they land after it rather than inside it.
583
- */
584
- function showNotices(notices) {
585
- while (notices.length > 0) {
586
- const notice = notices.shift();
587
- if (notice !== undefined) {
588
- showCard(notice);
589
- }
590
- }
591
- }
592
- /**
593
- * Local Claude Code runs against the user's own working copy, so anything
594
- * beyond a read is confirmed before it happens:
595
- *
596
- * ◆ Run Bash: npm test?
597
- * │ ● Yes …
598
- */
599
- async function askToolApproval(approval) {
600
- const decision = await selectOne({
601
- message: approval.summary === ""
602
- ? `Run ${approval.toolName}?`
603
- : `Run ${approval.toolName}: ${approval.summary}?`,
604
- recommended: "yes",
605
- // Escape here means "not that tool", not "leave the chat" — the turn
606
- // continues and the model reports what it could not do.
607
- cancelValue: "no",
608
- choices: [
609
- { value: "yes", label: "Yes", hint: "just this once" },
610
- { value: "always", label: "Yes to everything", hint: "for this chat" },
611
- { value: "no", label: "No", hint: "tell the model to skip it" },
612
- ],
613
- });
614
- return decision === "always" ? "always" : decision === "no" ? "no" : "yes";
615
- }
616
- /**
617
- * Live prompt: `◆ ` with an as-you-type suggestion menu for `/…` commands
618
- * and path-like tokens. Empty Enter leaves. A first message from the palette
619
- * is written as a finished user turn, then sent.
620
- */
621
- async function readQuestion(firstMessage, dir,
622
- /** Typed while the last turn was still answering, and not sent as its own. */
623
- typedAhead = "") {
624
- if (firstMessage !== undefined) {
625
- showUserTurn(firstMessage);
626
- return firstMessage;
627
- }
628
- const input = openPromptInput();
629
- if (input === undefined) {
630
- failWith("Ask needs a terminal. Re-run with --print for one JSON turn.", {
631
- code: ExitCodes.InvalidUsage,
632
- });
633
- }
634
- try {
635
- const answer = await readAskLine(input.stream, dir, typedAhead);
636
- if (answer === undefined) {
637
- return undefined;
638
- }
639
- showUserTurn(answer);
640
- return answer;
641
- }
642
- finally {
643
- input.close();
644
- }
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
- }
672
- /**
673
- * Same tree grammar as the palette:
674
- * ◇ <your message>
675
- * ■ reply…
676
- * ◆
677
- * Empty Enter or /exit leaves to the parent palette.
678
- */
679
- export async function runAskSession(options) {
680
- requireInteractive();
681
- const config = getConfig();
682
- // An API token from CARGO_API_TOKEN is workspace-bound on the server, so
683
- // config.workspaceUuid can be unset — same as whoami / every other command.
684
- if (config.getAccessToken === undefined) {
685
- failWith("Not authenticated. Run 'cargo-ai login --email <email>' or 'cargo-ai login --oauth'.", { code: ExitCodes.NotAuthenticated });
686
- }
687
- let agent = options.agentUuid !== undefined
688
- ? await loadAgent(options.api, options.agentUuid)
689
- : undefined;
690
- let chatUuid = options.chatUuid !== undefined ? options.chatUuid : randomUUID();
691
- let chatCreated = false;
692
- let pendingFirst = options.message;
693
- let local;
694
- // The proxy token is fixed in the Claude Code process env at spawn, so the
695
- // only way to refresh it is to rebuild the harness. These two say when that
696
- // becomes necessary; see `proxyTokenDeadlineMs`.
697
- let localTokenMintedAtMs = 0;
698
- let localTokenTtlMs = 0;
699
- // Identifies this process to the server's per-chat turn claim, so our own
700
- // next turn renews it rather than colliding with it.
701
- const sessionId = randomUUID();
702
- // "Yes to everything" lasts until the chat is reset, never on disk.
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 = "";
711
- if (options.continueChat === true && options.chatUuid !== undefined) {
712
- chatCreated = await doesChatExist(options.api, options.chatUuid);
713
- }
714
- try {
715
- for (;;) {
716
- const mode = modeForAgent(agent);
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;
725
- pendingFirst = undefined;
726
- const { question } = next;
727
- if (question === undefined) {
728
- return "palette";
729
- }
730
- const slash = parseSlash(question);
731
- if (slash !== undefined) {
732
- if (slash.kind === "card") {
733
- showCard(slash.text);
734
- continue;
735
- }
736
- if (slash.action === "exit") {
737
- return "palette";
738
- }
739
- if (slash.action === "cancel") {
740
- showCard("Nothing is running — this prompt only appears between turns. Press Ctrl-C while a turn is in flight to stop it.");
741
- continue;
742
- }
743
- if (slash.action === "new" || slash.action === "clear") {
744
- if (local !== undefined) {
745
- await local.close();
746
- local = undefined;
747
- }
748
- // Fresh session returns to the local default (no agent).
749
- agent = undefined;
750
- chatUuid = randomUUID();
751
- chatCreated = false;
752
- approveEveryTool = false;
753
- showCard("Started a fresh chat.");
754
- continue;
755
- }
756
- if (slash.action === "pick-agent") {
757
- if (local !== undefined) {
758
- await local.close();
759
- local = undefined;
760
- }
761
- const nextUuid = await pickAgent(options.api, agent === undefined ? undefined : agent.uuid);
762
- agent =
763
- nextUuid === undefined
764
- ? undefined
765
- : await loadAgent(options.api, nextUuid);
766
- // A blanket "yes" was given to one agent's local session, not to
767
- // whatever the next one wants to run.
768
- approveEveryTool = false;
769
- continue;
770
- }
771
- }
772
- if (mode.kind === "local") {
773
- if (chatCreated === false) {
774
- await ensureChat({
775
- api: options.api,
776
- chatUuid,
777
- agentUuid: agent === undefined ? undefined : agent.uuid,
778
- created: false,
779
- });
780
- chatCreated = true;
781
- }
782
- // A session left at the prompt long enough for its token to lapse
783
- // would send the next turn with a dead key, which surfaces as an
784
- // authentication failure from the proxy rather than anything the user
785
- // can act on. Rebuilding costs a cold start and a history replay, so
786
- // it happens only once the token really is past saving.
787
- if (local !== undefined &&
788
- Date.now() >=
789
- proxyTokenDeadlineMs({
790
- mintedAtMs: localTokenMintedAtMs,
791
- lastUsedAtMs: local.proxyLastUsedAtMs(),
792
- ttlMs: localTokenTtlMs,
793
- })) {
794
- await local.close();
795
- local = undefined;
796
- }
797
- if (local === undefined) {
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
- }
831
- }
832
- // Nothing about a local turn is visible to the server until it is
833
- // ingested, so the chat reads as idle while Claude Code works and a
834
- // second session resuming it would spend alongside this one.
835
- //
836
- // Taken here rather than before the setup above: the claim is only
837
- // released by a finished turn, so holding it across a mint or a
838
- // harness build that then fails would block the chat for the whole
839
- // TTL. Nothing in the setup needs protecting — mint hands back the
840
- // token already in play rather than replacing it.
841
- const claim = await claimChatTurn(options.api, chatUuid, sessionId);
842
- if (claim.outcome === "busy") {
843
- showCard("Another session is already running a turn in this chat. Wait for it to finish, or /new to start a fresh one.");
844
- continue;
845
- }
846
- if (claim.outcome === "unavailable") {
847
- // Reporting this ends the process, and the harness is already
848
- // running — shut it down here, because `process.exit` skips the
849
- // `finally` below that would otherwise have done it. Left assigned
850
- // so the `finally` still closes it on the one path that unwinds
851
- // instead of exiting; closing twice is harmless.
852
- await local.close();
853
- await reportApiFailure(claim.error);
854
- }
855
- let spinner;
856
- let live;
857
- // A tool interrupting the narration closes the live block, so `live`
858
- // alone cannot tell whether the turn ever said anything.
859
- let saidSomething = false;
860
- const touchedFiles = new Set();
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
- };
887
- const stopSpinner = () => {
888
- if (spinner !== undefined) {
889
- spinner.stop();
890
- spinner = undefined;
891
- }
892
- };
893
- const showSpinner = (label) => {
894
- if (spinner === undefined) {
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);
919
- return;
920
- }
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();
943
- };
944
- // Registered before the spinner so our SIGINT handler runs first and
945
- // clack's spinner is already stopped when its own handler fires —
946
- // otherwise Ctrl-C prints its cancel line under ours.
947
- const cancellation = beginCancellableTurn(stopSpinner);
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 = "";
958
- try {
959
- await local.streamPrompt(question, {
960
- abortSignal: cancellation.signal,
961
- onStall: () => {
962
- cancellation.abort();
963
- },
964
- onTextDelta: (delta) => {
965
- if (live === undefined) {
966
- stopSpinner();
967
- live = beginAssistantTurn();
968
- // The spinner was carrying the typed line; it goes with it.
969
- paintTyped();
970
- }
971
- saidSomething = true;
972
- appendAssistantDelta(live, delta);
973
- },
974
- onActivity: (activity) => {
975
- // Tool work replaces the spinner text so a long Bash step is
976
- // not an unexplained pause. Claude Code narrates before it acts,
977
- // so a tool usually starts with live text on screen: close that
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
- }
988
- if (activity.kind === "end") {
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);
999
- return;
1000
- }
1001
- if (live !== undefined) {
1002
- endAssistantTurn(live);
1003
- live = undefined;
1004
- }
1005
- rememberTool({
1006
- toolCallId: activity.toolCallId,
1007
- toolName: activity.toolName,
1008
- summary: activity.summary,
1009
- });
1010
- },
1011
- onFileTouched: (path) => {
1012
- touchedFiles.add(path);
1013
- },
1014
- onNotice: (message) => {
1015
- notices.push(message);
1016
- },
1017
- onApproval: async (approval) => {
1018
- // Close whatever is on screen: the prompt owns the line next.
1019
- if (live !== undefined) {
1020
- endAssistantTurn(live);
1021
- live = undefined;
1022
- }
1023
- stopSpinner();
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
- }
1043
- if (decision === "always") {
1044
- approveEveryTool = true;
1045
- }
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;
1058
- },
1059
- });
1060
- stopSpinner();
1061
- if (live !== undefined) {
1062
- endAssistantTurn(live);
1063
- }
1064
- else if (saidSomething === false) {
1065
- showAssistantTurn("");
1066
- }
1067
- showTouchedFiles(touchedFiles, options.dir);
1068
- showNotices(notices);
1069
- }
1070
- catch (error) {
1071
- stopSpinner();
1072
- if (live !== undefined) {
1073
- endAssistantTurn(live);
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();
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;
1104
- showTouchedFiles(touchedFiles, options.dir);
1105
- showNotices(notices);
1106
- showCard("Cancelled.");
1107
- persistAskState({
1108
- chatUuid,
1109
- agentUuid: agent === undefined ? undefined : agent.uuid,
1110
- });
1111
- continue;
1112
- }
1113
- showNotices(notices);
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
1117
- // working copy all survive; only the Claude Code process is new.
1118
- if (error instanceof HarnessTurnStuck ||
1119
- error instanceof HarnessTurnStalled ||
1120
- isBridgeClosedError(error) === true) {
1121
- await local.close();
1122
- local = undefined;
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.");
1126
- continue;
1127
- }
1128
- throw error;
1129
- }
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();
1135
- cancellation.release();
1136
- await releaseChatTurn(options.api, chatUuid, sessionId);
1137
- }
1138
- persistAskState({
1139
- chatUuid,
1140
- agentUuid: agent === undefined ? undefined : agent.uuid,
1141
- });
1142
- continue;
1143
- }
1144
- if (agent === undefined) {
1145
- failWith("Ask remote stream needs an agent.", {
1146
- code: ExitCodes.GenericError,
1147
- });
1148
- }
1149
- if (chatCreated === false) {
1150
- await ensureChat({
1151
- api: options.api,
1152
- chatUuid,
1153
- agentUuid: agent.uuid,
1154
- created: false,
1155
- });
1156
- chatCreated = true;
1157
- }
1158
- if (config.getAccessToken === undefined) {
1159
- failWith("Not authenticated. Run 'cargo-ai login --email <email>' or 'cargo-ai login --oauth'.", { code: ExitCodes.NotAuthenticated });
1160
- }
1161
- let live;
1162
- let spinner;
1163
- const stopRemoteSpinner = () => {
1164
- if (spinner !== undefined) {
1165
- spinner.stop();
1166
- spinner = undefined;
1167
- }
1168
- };
1169
- // Registered before the spinner so our handler runs before clack's own
1170
- // — same ordering as the local turn.
1171
- const cancellation = beginCancellableTurn(stopRemoteSpinner);
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());
1175
- let assistantMessageUuid;
1176
- try {
1177
- const deltas = streamMessageTextDeltas({
1178
- baseUrl: config.baseUrl,
1179
- getAccessToken: config.getAccessToken,
1180
- workspaceUuid: config.workspaceUuid,
1181
- chatUuid,
1182
- messageUuid: randomUUID(),
1183
- parts: [{ type: "text", text: question }],
1184
- agentUuid: agent.uuid,
1185
- onAssistantMessageUuid: (uuid) => {
1186
- assistantMessageUuid = uuid;
1187
- },
1188
- abortSignal: cancellation.signal,
1189
- });
1190
- for await (const delta of deltas) {
1191
- if (live === undefined) {
1192
- stopRemoteSpinner();
1193
- live = beginAssistantTurn();
1194
- }
1195
- appendAssistantDelta(live, delta);
1196
- }
1197
- if (live === undefined) {
1198
- stopRemoteSpinner();
1199
- showAssistantTurn("");
1200
- }
1201
- else {
1202
- endAssistantTurn(live);
1203
- }
1204
- }
1205
- catch (error) {
1206
- stopRemoteSpinner();
1207
- if (live !== undefined) {
1208
- endAssistantTurn(live);
1209
- }
1210
- // Ctrl-C only stops the CLI reading the stream, so the message has to
1211
- // be cancelled server-side too — an unfinished one makes the next
1212
- // question in this chat fail with lastMessageNotFinished.
1213
- if (cancellation.wasCancelled() === true) {
1214
- await cancelRemoteMessage(options.api, assistantMessageUuid);
1215
- showCard("Cancelled.");
1216
- persistAskState({ chatUuid, agentUuid: agent.uuid });
1217
- continue;
1218
- }
1219
- if (error instanceof Error) {
1220
- failWith(error.message, { code: ExitCodes.GenericError });
1221
- }
1222
- throw error;
1223
- }
1224
- finally {
1225
- cancellation.release();
1226
- }
1227
- persistAskState({
1228
- chatUuid,
1229
- agentUuid: agent.uuid,
1230
- });
1231
- }
1232
- }
1233
- finally {
1234
- if (local !== undefined) {
1235
- await local.close();
1236
- }
1237
- }
1238
- }
1239
- export async function runAskPrint(input) {
1240
- const config = getConfig();
1241
- if (config.getAccessToken === undefined) {
1242
- failWith("Not authenticated. Run 'cargo-ai login --email <email>' or 'cargo-ai login --oauth'.", { code: ExitCodes.NotAuthenticated });
1243
- }
1244
- const agent = input.agentUuid !== undefined
1245
- ? await loadAgent(input.api, input.agentUuid)
1246
- : undefined;
1247
- const mode = modeForAgent(agent);
1248
- const chatUuid = input.chatUuid !== undefined ? input.chatUuid : randomUUID();
1249
- // Same lifecycle as the interactive session: a caller-supplied uuid may
1250
- // already be on the server (--chat-uuid, or --continue reopening the last
1251
- // thread), in which case creating it again would fail; a fresh one has to
1252
- // be created before the first message.
1253
- const chatCreated = input.chatUuid !== undefined
1254
- ? await doesChatExist(input.api, input.chatUuid)
1255
- : false;
1256
- if (mode.kind === "local") {
1257
- await ensureChat({
1258
- api: input.api,
1259
- chatUuid,
1260
- agentUuid: agent === undefined ? undefined : agent.uuid,
1261
- created: chatCreated,
1262
- });
1263
- const minted = await handleApiCall(() => input.api.ai.chat.mintHarnessProxyToken(chatUuid), { spinner: false });
1264
- const history = await loadChatHistoryPreamble({
1265
- api: input.api,
1266
- chatUuid,
1267
- });
1268
- const ownerId = randomUUID();
1269
- // Before the harness is built, because both exits below end the process
1270
- // and `process.exit` does not run `finally` blocks — either one below a
1271
- // live harness would leave Claude Code and its bridge running.
1272
- const claim = await claimChatTurn(input.api, chatUuid, ownerId);
1273
- if (claim.outcome === "busy") {
1274
- failWith("Another session is already running a turn in this chat. Wait for it to finish, or pass --chat-uuid for a different one.", { code: ExitCodes.GenericError });
1275
- }
1276
- if (claim.outcome === "unavailable") {
1277
- await reportApiFailure(claim.error);
1278
- }
1279
- let local;
1280
- try {
1281
- local = await createLocalHarness({
1282
- api: input.api,
1283
- apiBaseUrl: config.baseUrl,
1284
- dir: input.dir,
1285
- chatUuid,
1286
- agentUuid: agent === undefined ? undefined : agent.uuid,
1287
- proxyToken: minted.token,
1288
- history,
1289
- turnOwnerId: ownerId,
1290
- });
1291
- const text = await local.streamPrompt(input.message);
1292
- persistAskState({
1293
- chatUuid,
1294
- agentUuid: agent === undefined ? undefined : agent.uuid,
1295
- });
1296
- outputJson({ chatUuid, text });
1297
- }
1298
- finally {
1299
- // Covers the build as well as the turn: a turn that errored spent
1300
- // nothing and so recorded nothing, and a build that failed never got as
1301
- // far as a turn. Either would leave the chat claimed until the TTL.
1302
- await releaseChatTurn(input.api, chatUuid, ownerId);
1303
- if (local !== undefined) {
1304
- await local.close();
1305
- }
1306
- }
1307
- return;
1308
- }
1309
- if (agent === undefined) {
1310
- failWith("Ask remote print needs an agent.", {
1311
- code: ExitCodes.GenericError,
1312
- });
1313
- }
1314
- await ensureChat({
1315
- api: input.api,
1316
- chatUuid,
1317
- agentUuid: agent.uuid,
1318
- created: chatCreated,
1319
- });
1320
- const result = await handleApiCall(() => input.api.ai.message.create({
1321
- chatUuid,
1322
- agentUuid: agent.uuid,
1323
- parts: [{ type: "text", text: input.message }],
1324
- }));
1325
- const finalResult = await pollUntilFinished(() => input.api.ai.message.get(result.assistantMessage.uuid), (row) => row.message.finishedAt, PRINT_POLL_INTERVAL_MS, PRINT_POLL_TIMEOUT_MS);
1326
- persistAskState({ chatUuid, agentUuid: agent.uuid });
1327
- outputJson(finalResult);
1328
- }