@cargo-ai/cli 1.0.94 → 1.0.96

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 (123) hide show
  1. package/build/commands/ai/chat.d.ts.map +1 -1
  2. package/build/commands/ai/chat.js +2 -1
  3. package/build/commands/ai/message.d.ts.map +1 -1
  4. package/build/commands/ai/message.js +2 -1
  5. package/build/commands/ai/release.d.ts.map +1 -1
  6. package/build/commands/ai/release.js +7 -6
  7. package/build/commands/ask/harness/approvals.d.ts +7 -0
  8. package/build/commands/ask/harness/approvals.d.ts.map +1 -0
  9. package/build/commands/ask/harness/approvals.js +81 -0
  10. package/build/commands/ask/harness/artifacts.d.ts.map +1 -0
  11. package/build/commands/ask/harness/askUserQuestion.d.ts +69 -0
  12. package/build/commands/ask/harness/askUserQuestion.d.ts.map +1 -0
  13. package/build/commands/ask/harness/askUserQuestion.js +218 -0
  14. package/build/commands/ask/harness/claudeConfig.d.ts.map +1 -0
  15. package/build/commands/ask/{claudeConfig.js → harness/claudeConfig.js} +28 -13
  16. package/build/commands/ask/harness/create.d.ts +59 -0
  17. package/build/commands/ask/harness/create.d.ts.map +1 -0
  18. package/build/commands/ask/harness/create.js +314 -0
  19. package/build/commands/ask/harness/index.d.ts +9 -0
  20. package/build/commands/ask/harness/index.d.ts.map +1 -0
  21. package/build/commands/ask/harness/index.js +8 -0
  22. package/build/commands/ask/harness/ingest.d.ts +25 -0
  23. package/build/commands/ask/harness/ingest.d.ts.map +1 -0
  24. package/build/commands/ask/harness/ingest.js +35 -0
  25. package/build/commands/ask/harness/instructions.d.ts +13 -0
  26. package/build/commands/ask/harness/instructions.d.ts.map +1 -0
  27. package/build/commands/ask/harness/instructions.js +43 -0
  28. package/build/commands/ask/harness/layout.d.ts +23 -0
  29. package/build/commands/ask/harness/layout.d.ts.map +1 -0
  30. package/build/commands/ask/harness/layout.js +32 -0
  31. package/build/commands/ask/harness/mcp.d.ts +25 -0
  32. package/build/commands/ask/harness/mcp.d.ts.map +1 -0
  33. package/build/commands/ask/harness/mcp.js +72 -0
  34. package/build/commands/ask/harness/pnpmShim.d.ts.map +1 -0
  35. package/build/commands/ask/harness/release.d.ts +18 -0
  36. package/build/commands/ask/harness/release.d.ts.map +1 -0
  37. package/build/commands/ask/harness/release.js +117 -0
  38. package/build/commands/ask/harness/sandbox.d.ts +9 -0
  39. package/build/commands/ask/harness/sandbox.d.ts.map +1 -0
  40. package/build/commands/ask/harness/sandbox.js +27 -0
  41. package/build/commands/ask/harness/timing.d.ts +16 -0
  42. package/build/commands/ask/harness/timing.d.ts.map +1 -0
  43. package/build/commands/ask/harness/timing.js +72 -0
  44. package/build/commands/ask/harness/turn.d.ts +152 -0
  45. package/build/commands/ask/harness/turn.d.ts.map +1 -0
  46. package/build/commands/ask/harness/turn.js +676 -0
  47. package/build/commands/ask/harness/usage.d.ts +50 -0
  48. package/build/commands/ask/harness/usage.d.ts.map +1 -0
  49. package/build/commands/ask/harness/usage.js +87 -0
  50. package/build/commands/ask/index.d.ts +9 -0
  51. package/build/commands/ask/index.d.ts.map +1 -1
  52. package/build/commands/ask/index.js +7 -7
  53. package/build/commands/ask/input/completer.d.ts.map +1 -0
  54. package/build/commands/ask/input/index.d.ts +5 -0
  55. package/build/commands/ask/input/index.d.ts.map +1 -0
  56. package/build/commands/ask/input/index.js +4 -0
  57. package/build/commands/ask/input/prompt.d.ts.map +1 -0
  58. package/build/commands/ask/{prompt.js → input/prompt.js} +1 -1
  59. package/build/commands/ask/input/slash.d.ts.map +1 -0
  60. package/build/commands/ask/{slash.js → input/slash.js} +1 -1
  61. package/build/commands/ask/input/typeAhead.d.ts.map +1 -0
  62. package/build/commands/ask/{history.d.ts → persist/history.d.ts} +1 -1
  63. package/build/commands/ask/persist/history.d.ts.map +1 -0
  64. package/build/commands/ask/persist/index.d.ts +4 -0
  65. package/build/commands/ask/persist/index.d.ts.map +1 -0
  66. package/build/commands/ask/persist/index.js +3 -0
  67. package/build/commands/ask/persist/persist.d.ts.map +1 -0
  68. package/build/commands/ask/persist/state.d.ts.map +1 -0
  69. package/build/commands/ask/session/claim.d.ts +56 -0
  70. package/build/commands/ask/session/claim.d.ts.map +1 -0
  71. package/build/commands/ask/session/claim.js +76 -0
  72. package/build/commands/ask/session/index.d.ts +5 -0
  73. package/build/commands/ask/session/index.d.ts.map +1 -0
  74. package/build/commands/ask/session/index.js +4 -0
  75. package/build/commands/ask/session/run.d.ts +36 -0
  76. package/build/commands/ask/session/run.d.ts.map +1 -0
  77. package/build/commands/ask/session/run.js +846 -0
  78. package/build/commands/ask/session/thinking.d.ts.map +1 -0
  79. package/build/commands/ask/session/tree.d.ts +99 -0
  80. package/build/commands/ask/session/tree.d.ts.map +1 -0
  81. package/build/commands/ask/session/tree.js +356 -0
  82. package/package.json +6 -5
  83. package/build/commands/ask/artifacts.d.ts.map +0 -1
  84. package/build/commands/ask/binding.d.ts +0 -13
  85. package/build/commands/ask/binding.d.ts.map +0 -1
  86. package/build/commands/ask/binding.js +0 -19
  87. package/build/commands/ask/claudeConfig.d.ts.map +0 -1
  88. package/build/commands/ask/completer.d.ts.map +0 -1
  89. package/build/commands/ask/history.d.ts.map +0 -1
  90. package/build/commands/ask/localHarness.d.ts +0 -278
  91. package/build/commands/ask/localHarness.d.ts.map +0 -1
  92. package/build/commands/ask/localHarness.js +0 -1300
  93. package/build/commands/ask/messageStream.d.ts +0 -33
  94. package/build/commands/ask/messageStream.d.ts.map +0 -1
  95. package/build/commands/ask/messageStream.js +0 -114
  96. package/build/commands/ask/persist.d.ts.map +0 -1
  97. package/build/commands/ask/pnpmShim.d.ts.map +0 -1
  98. package/build/commands/ask/prompt.d.ts.map +0 -1
  99. package/build/commands/ask/session.d.ts +0 -101
  100. package/build/commands/ask/session.d.ts.map +0 -1
  101. package/build/commands/ask/session.js +0 -1328
  102. package/build/commands/ask/slash.d.ts.map +0 -1
  103. package/build/commands/ask/state.d.ts.map +0 -1
  104. package/build/commands/ask/thinking.d.ts.map +0 -1
  105. package/build/commands/ask/typeAhead.d.ts.map +0 -1
  106. /package/build/commands/ask/{artifacts.d.ts → harness/artifacts.d.ts} +0 -0
  107. /package/build/commands/ask/{artifacts.js → harness/artifacts.js} +0 -0
  108. /package/build/commands/ask/{claudeConfig.d.ts → harness/claudeConfig.d.ts} +0 -0
  109. /package/build/commands/ask/{pnpmShim.d.ts → harness/pnpmShim.d.ts} +0 -0
  110. /package/build/commands/ask/{pnpmShim.js → harness/pnpmShim.js} +0 -0
  111. /package/build/commands/ask/{completer.d.ts → input/completer.d.ts} +0 -0
  112. /package/build/commands/ask/{completer.js → input/completer.js} +0 -0
  113. /package/build/commands/ask/{prompt.d.ts → input/prompt.d.ts} +0 -0
  114. /package/build/commands/ask/{slash.d.ts → input/slash.d.ts} +0 -0
  115. /package/build/commands/ask/{typeAhead.d.ts → input/typeAhead.d.ts} +0 -0
  116. /package/build/commands/ask/{typeAhead.js → input/typeAhead.js} +0 -0
  117. /package/build/commands/ask/{history.js → persist/history.js} +0 -0
  118. /package/build/commands/ask/{persist.d.ts → persist/persist.d.ts} +0 -0
  119. /package/build/commands/ask/{persist.js → persist/persist.js} +0 -0
  120. /package/build/commands/ask/{state.d.ts → persist/state.d.ts} +0 -0
  121. /package/build/commands/ask/{state.js → persist/state.js} +0 -0
  122. /package/build/commands/ask/{thinking.d.ts → session/thinking.d.ts} +0 -0
  123. /package/build/commands/ask/{thinking.js → session/thinking.js} +0 -0
@@ -0,0 +1,846 @@
1
+ import { determineIfIsFetcherError } from "@cargo-ai/api";
2
+ import { isInteractive, selectOne } from "@cargo-ai/cdk/cli";
3
+ import { v4 as uuidv4 } from "uuid";
4
+ import { getConfig } from "../../../config.js";
5
+ import { openPromptInput } from "../../../utils/prompt.js";
6
+ import { ExitCodes, failWith, handleApiCall, outputJson, startSpinner, } from "../../runHandler.js";
7
+ import { createLocalHarness, HarnessTurnStalled, HarnessTurnStuck, isBridgeClosedError, } from "../harness/index.js";
8
+ import { readAskLine } from "../input/prompt.js";
9
+ import { parseSlash } from "../input/slash.js";
10
+ import { collectTypeAhead, EMPTY_TYPE_AHEAD, } from "../input/typeAhead.js";
11
+ import { loadChatHistoryPreamble } from "../persist/history.js";
12
+ import { saveAskState } from "../persist/persist.js";
13
+ import { ASK_STATE_VERSION } from "../persist/state.js";
14
+ import { claimChatTurn, proxyTokenDeadlineMs, releaseChatTurn, reportApiFailure, } from "./claim.js";
15
+ import { pickThinkingWord } from "./thinking.js";
16
+ import { appendAssistantDelta, askToolApproval, askUserQuestions, beginAssistantTurn, describeToolActivity, endAssistantTurn, setAssistantFooter, showAssistantTurn, showCard, showNotices, showToolRun, showTouchedFiles, showUserTurn, } from "./tree.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
+ async function loadAgent(api, agentUuid) {
27
+ const result = await handleApiCall(() => api.ai.agent.get(agentUuid));
28
+ return result.agent;
29
+ }
30
+ async function loadDeployedRelease(api, agent) {
31
+ if (agent === undefined) {
32
+ return undefined;
33
+ }
34
+ if (agent.deployedRelease === undefined) {
35
+ return undefined;
36
+ }
37
+ const { release } = await handleApiCall(() => api.ai.release.getDeployed({
38
+ agentUuid: agent.uuid,
39
+ }), { spinner: false });
40
+ return release;
41
+ }
42
+ /**
43
+ * Pick a workspace agent, or Local workspace (clears the agent).
44
+ * Returns undefined when the user chooses the local default.
45
+ */
46
+ async function pickAgent(api, currentUuid) {
47
+ const { agents } = await handleApiCall(() => api.ai.agent.all());
48
+ const chosen = await selectOne({
49
+ message: "Agent for the next question",
50
+ recommended: currentUuid !== undefined ? currentUuid : LOCAL_WORKSPACE,
51
+ choices: [
52
+ {
53
+ value: LOCAL_WORKSPACE,
54
+ label: "Local workspace",
55
+ hint: "Claude Code on this machine (default)",
56
+ fallback: true,
57
+ },
58
+ ...agents.map((agent) => ({
59
+ value: agent.uuid,
60
+ label: agent.name,
61
+ hint: agent.uuid,
62
+ })),
63
+ ],
64
+ });
65
+ if (chosen === LOCAL_WORKSPACE) {
66
+ return undefined;
67
+ }
68
+ return chosen;
69
+ }
70
+ /**
71
+ * cargo ask always runs in local Claude Code, including after `/agent`, so
72
+ * the chat is stamped claudeCode for ingest.
73
+ */
74
+ async function ensureChat(input) {
75
+ if (input.created === true) {
76
+ return;
77
+ }
78
+ await handleApiCall(() => input.api.ai.chat.create({
79
+ uuid: input.chatUuid,
80
+ trigger: { type: "cli" },
81
+ agentUuid: input.agentUuid,
82
+ harnessSlug: "claudeCode",
83
+ }), { spinner: false });
84
+ }
85
+ function persistAskState(input) {
86
+ saveAskState({
87
+ version: ASK_STATE_VERSION,
88
+ chatUuid: input.chatUuid,
89
+ agentUuid: input.agentUuid,
90
+ updatedAt: new Date().toISOString(),
91
+ });
92
+ }
93
+ /**
94
+ * True when the chat is already on the server. A saved `--continue` uuid can
95
+ * point at nothing (Esc before the first turn, wiped local DB, different API),
96
+ * and that must become a create — not chatNotFound on the first message.
97
+ *
98
+ * Careful with the false case: it means "not readable", which covers a chat
99
+ * that was deleted as well as one that never existed. Deletion is soft, so
100
+ * the row keeps the uuid and creating it again fails on the primary key —
101
+ * see where `--continue` resolves its uuid.
102
+ */
103
+ export async function doesChatExist(api, chatUuid) {
104
+ try {
105
+ await api.ai.chat.get(chatUuid);
106
+ return true;
107
+ }
108
+ catch (error) {
109
+ if (determineIfIsFetcherError(error) === true &&
110
+ error.status === 404) {
111
+ return false;
112
+ }
113
+ await handleApiCall(async () => {
114
+ throw error;
115
+ }, { spinner: false });
116
+ return false;
117
+ }
118
+ }
119
+ /**
120
+ * Ctrl-C during a turn cancels the turn, not the CLI: the harness interrupts
121
+ * Claude Code, the tree says so, and the ◆ prompt comes back. A second Ctrl-C
122
+ * while the first is still settling exits for real.
123
+ *
124
+ * Except once the turn has reached teardown, where exiting is the one thing
125
+ * that must not happen: `process.exit` skips the `finally` that shuts Claude
126
+ * Code down, so an impatient second press — the natural reaction to a few
127
+ * seconds of apparently nothing — is exactly what strands the process this
128
+ * teardown exists to stop. Presses are ignored for the length of it, which is
129
+ * bounded: `close` gives up after `SESSION_DESTROY_TIMEOUT_MS` either way.
130
+ */
131
+ function beginCancellableTurn(onCancel) {
132
+ const controller = new AbortController();
133
+ const state = { cancelled: false, tearingDown: false };
134
+ const handleInterrupt = () => {
135
+ if (state.tearingDown === true) {
136
+ return;
137
+ }
138
+ if (state.cancelled === true) {
139
+ // 130 is the conventional "terminated by SIGINT" status.
140
+ process.exit(130);
141
+ }
142
+ state.cancelled = true;
143
+ onCancel();
144
+ controller.abort();
145
+ };
146
+ process.on("SIGINT", handleInterrupt);
147
+ return {
148
+ signal: controller.signal,
149
+ wasCancelled: () => state.cancelled,
150
+ abort: () => {
151
+ controller.abort();
152
+ },
153
+ beginTeardown: () => {
154
+ state.tearingDown = true;
155
+ },
156
+ interrupt: handleInterrupt,
157
+ release: () => {
158
+ process.removeListener("SIGINT", handleInterrupt);
159
+ },
160
+ };
161
+ }
162
+ /**
163
+ * Let the next question be typed while this one is still being answered.
164
+ *
165
+ * The collector owns the terminal for the length of the turn; the spinner
166
+ * shows what has been typed so far, because raw mode means the terminal is no
167
+ * longer echoing it.
168
+ *
169
+ * Degrades to no type-ahead rather than failing when there is no terminal to
170
+ * read — `--print` and the non-interactive paths never get here, but a session
171
+ * whose stdin went away should still finish the turn it is running.
172
+ */
173
+ function beginTurnTyping(input) {
174
+ const terminal = openPromptInput();
175
+ if (terminal === undefined) {
176
+ return {
177
+ pause: async () => undefined,
178
+ resume: () => undefined,
179
+ finish: () => EMPTY_TYPE_AHEAD,
180
+ };
181
+ }
182
+ const collector = collectTypeAhead({
183
+ stream: terminal.stream,
184
+ initialLine: input.carriedLine,
185
+ onInterrupt: input.onInterrupt,
186
+ onChange: input.onTyped,
187
+ });
188
+ input.onTyped(input.carriedLine);
189
+ return {
190
+ pause: collector.pause,
191
+ resume: () => {
192
+ collector.resume();
193
+ // The prompt that borrowed the terminal drew over the line; put it back.
194
+ input.onTyped(collector.current());
195
+ },
196
+ finish: () => {
197
+ const typed = collector.stop();
198
+ input.onTyped("");
199
+ terminal.close();
200
+ return typed;
201
+ },
202
+ };
203
+ }
204
+ /**
205
+ * Live prompt: `◆ ` with an as-you-type suggestion menu for `/…` commands
206
+ * and path-like tokens. Empty Enter leaves. A first message from the palette
207
+ * is written as a finished user turn, then sent.
208
+ */
209
+ async function readQuestion(firstMessage, dir,
210
+ /** Typed while the last turn was still answering, and not sent as its own. */
211
+ typedAhead = "") {
212
+ if (firstMessage !== undefined) {
213
+ showUserTurn(firstMessage);
214
+ return firstMessage;
215
+ }
216
+ const input = openPromptInput();
217
+ if (input === undefined) {
218
+ failWith("Ask needs a terminal. Re-run with --print for one JSON turn.", {
219
+ code: ExitCodes.InvalidUsage,
220
+ });
221
+ }
222
+ try {
223
+ const answer = await readAskLine(input.stream, dir, typedAhead);
224
+ if (answer === undefined) {
225
+ return undefined;
226
+ }
227
+ showUserTurn(answer);
228
+ return answer;
229
+ }
230
+ finally {
231
+ input.close();
232
+ }
233
+ }
234
+ /**
235
+ * Where the next question comes from, once type-ahead is in the picture.
236
+ *
237
+ * A line the user finished with Enter during the last reply is already their
238
+ * next question, so it is sent rather than parked at the prompt — the way it
239
+ * would have been had they waited. Anything typed after that Enter, finished
240
+ * or not, is carried into the turn it kicks off so it survives to the prompt
241
+ * after that: one question can be queued behind a reply, not a series of them.
242
+ * An unfinished line with no Enter simply opens the prompt with itself in
243
+ * place.
244
+ */
245
+ async function nextQuestion(input) {
246
+ const { queued } = input;
247
+ const pending = `${input.carried}${queued.pending}`;
248
+ if (queued.submitted !== undefined) {
249
+ return {
250
+ question: await readQuestion(queued.submitted, input.dir),
251
+ carriedLine: pending,
252
+ };
253
+ }
254
+ return {
255
+ // Consumed by the prompt, so nothing is carried past it.
256
+ question: await readQuestion(input.firstMessage, input.dir, pending),
257
+ carriedLine: "",
258
+ };
259
+ }
260
+ /**
261
+ * Same tree grammar as the palette:
262
+ * ◇ <your message>
263
+ * ■ reply…
264
+ * ◆
265
+ * Empty Enter or /exit leaves to the parent palette.
266
+ */
267
+ export async function runAskSession(options) {
268
+ requireInteractive();
269
+ const config = getConfig();
270
+ // An API token from CARGO_API_TOKEN is workspace-bound on the server, so
271
+ // config.workspaceUuid can be unset — same as whoami / every other command.
272
+ if (config.getAccessToken === undefined) {
273
+ failWith("Not authenticated. Run 'cargo-ai login --email <email>' or 'cargo-ai login --oauth'.", { code: ExitCodes.NotAuthenticated });
274
+ }
275
+ let agent;
276
+ if (options.agentUuid !== undefined) {
277
+ agent = await loadAgent(options.api, options.agentUuid);
278
+ if (agent.deployedRelease === undefined) {
279
+ failWith(`${agent.name} has no deployed release, so Ask cannot load its prompt and tools.`, { code: ExitCodes.GenericError });
280
+ }
281
+ }
282
+ let chatUuid = options.chatUuid !== undefined ? options.chatUuid : uuidv4();
283
+ let chatCreated = false;
284
+ let pendingFirst = options.message;
285
+ let harness;
286
+ // The proxy token is fixed in the Claude Code process env at spawn, so the
287
+ // only way to refresh it is to rebuild the harness. These two say when that
288
+ // becomes necessary; see `proxyTokenDeadlineMs`.
289
+ let proxyTokenMintedAtMs = 0;
290
+ let proxyTokenTtlMs = 0;
291
+ // Identifies this process to the server's per-chat turn claim, so our own
292
+ // next turn renews it rather than colliding with it.
293
+ const sessionId = uuidv4();
294
+ // "Yes to everything" lasts until the chat is reset, never on disk.
295
+ let approveEveryTool = false;
296
+ // Typed while the last turn was still answering: a finished line goes
297
+ // straight through as the next question, an unfinished one opens the prompt
298
+ // with it already in place.
299
+ let typedAhead = EMPTY_TYPE_AHEAD;
300
+ // The tail of a type-ahead whose first line was submitted, waiting for the
301
+ // next turn's collector to pick up where it left off.
302
+ let carriedLine = "";
303
+ if (options.continueChat === true && options.chatUuid !== undefined) {
304
+ chatCreated = await doesChatExist(options.api, options.chatUuid);
305
+ }
306
+ try {
307
+ for (;;) {
308
+ const next = await nextQuestion({
309
+ queued: typedAhead,
310
+ carried: carriedLine,
311
+ firstMessage: pendingFirst,
312
+ dir: options.dir,
313
+ });
314
+ typedAhead = EMPTY_TYPE_AHEAD;
315
+ carriedLine = next.carriedLine;
316
+ pendingFirst = undefined;
317
+ const { question } = next;
318
+ if (question === undefined) {
319
+ return "palette";
320
+ }
321
+ const slash = parseSlash(question);
322
+ if (slash !== undefined) {
323
+ if (slash.kind === "card") {
324
+ showCard(slash.text);
325
+ continue;
326
+ }
327
+ if (slash.action === "exit") {
328
+ return "palette";
329
+ }
330
+ if (slash.action === "cancel") {
331
+ showCard("Nothing is running — this prompt only appears between turns. Press Ctrl-C while a turn is in flight to stop it.");
332
+ continue;
333
+ }
334
+ if (slash.action === "new" || slash.action === "clear") {
335
+ if (harness !== undefined) {
336
+ await harness.close();
337
+ harness = undefined;
338
+ }
339
+ // Fresh session returns to the local default (no agent).
340
+ agent = undefined;
341
+ chatUuid = uuidv4();
342
+ chatCreated = false;
343
+ approveEveryTool = false;
344
+ showCard("Started a fresh chat.");
345
+ continue;
346
+ }
347
+ if (slash.action === "pick-agent") {
348
+ const nextUuid = await pickAgent(options.api, agent === undefined ? undefined : agent.uuid);
349
+ if (nextUuid === undefined) {
350
+ if (harness !== undefined) {
351
+ await harness.close();
352
+ harness = undefined;
353
+ }
354
+ agent = undefined;
355
+ approveEveryTool = false;
356
+ continue;
357
+ }
358
+ const nextAgent = await loadAgent(options.api, nextUuid);
359
+ if (nextAgent.deployedRelease === undefined) {
360
+ showCard(`${nextAgent.name} has no deployed release, so Ask cannot load its prompt and tools.`);
361
+ continue;
362
+ }
363
+ if (harness !== undefined) {
364
+ await harness.close();
365
+ harness = undefined;
366
+ }
367
+ agent = nextAgent;
368
+ // A blanket "yes" was given to one agent's local session, not to
369
+ // whatever the next one wants to run.
370
+ approveEveryTool = false;
371
+ continue;
372
+ }
373
+ }
374
+ if (chatCreated === false) {
375
+ await ensureChat({
376
+ api: options.api,
377
+ chatUuid,
378
+ agentUuid: agent === undefined ? undefined : agent.uuid,
379
+ created: false,
380
+ });
381
+ chatCreated = true;
382
+ }
383
+ // A session left at the prompt long enough for its token to lapse
384
+ // would send the next turn with a dead key, which surfaces as an
385
+ // authentication failure from the proxy rather than anything the user
386
+ // can act on. Rebuilding costs a cold start and a history replay, so
387
+ // it happens only once the token really is past saving.
388
+ if (harness !== undefined) {
389
+ const deadlineMs = proxyTokenDeadlineMs({
390
+ mintedAtMs: proxyTokenMintedAtMs,
391
+ lastUsedAtMs: harness.proxyLastUsedAtMs(),
392
+ ttlMs: proxyTokenTtlMs,
393
+ });
394
+ if (Date.now() >= deadlineMs) {
395
+ await harness.close();
396
+ harness = undefined;
397
+ }
398
+ }
399
+ if (harness === undefined) {
400
+ // Nothing has been drawn since the question was echoed, and the
401
+ // Claude Code that has to answer it is not running yet: on a cold
402
+ // config directory that is ten to fifteen seconds of a blank tree,
403
+ // which reads as the CLI having hung on the first thing it was ever
404
+ // asked. Warm, this comes and goes inside a fifth of a second.
405
+ //
406
+ // Retired by `startSpinner` when the turn starts its own, and by the
407
+ // `finally` on the path where the setup throws instead.
408
+ const starting = startSpinner("Starting the coding session…");
409
+ try {
410
+ const minted = await handleApiCall(() => options.api.ai.chat.proxy.mintToken(chatUuid), { spinner: false });
411
+ proxyTokenMintedAtMs = Date.now();
412
+ proxyTokenTtlMs = minted.expiresInSeconds * 1000;
413
+ // A new Claude Code session knows nothing of this chat, whether it
414
+ // is being continued from an earlier run or was reset by /agent.
415
+ const history = await loadChatHistoryPreamble({
416
+ api: options.api,
417
+ chatUuid,
418
+ });
419
+ harness = await createLocalHarness({
420
+ api: options.api,
421
+ apiBaseUrl: config.baseUrl,
422
+ dir: options.dir,
423
+ chatUuid,
424
+ agentUuid: agent === undefined ? undefined : agent.uuid,
425
+ release: await loadDeployedRelease(options.api, agent),
426
+ proxyToken: minted.token,
427
+ connectorUuid: minted.connectorUuid,
428
+ integrationSlug: minted.integrationSlug,
429
+ languageModelSlug: minted.languageModelSlug,
430
+ history,
431
+ turnOwnerId: sessionId,
432
+ allowWorkspaceMutations: true,
433
+ });
434
+ }
435
+ finally {
436
+ starting.stop();
437
+ }
438
+ }
439
+ // Nothing about a local turn is visible to the server until it is
440
+ // ingested, so the chat reads as idle while Claude Code works and a
441
+ // second session resuming it would spend alongside this one.
442
+ //
443
+ // Taken here rather than before the setup above: the claim is only
444
+ // released by a finished turn, so holding it across a mint or a
445
+ // harness build that then fails would block the chat for the whole
446
+ // TTL. Nothing in the setup needs protecting — mint hands back the
447
+ // token already in play rather than replacing it.
448
+ const claim = await claimChatTurn(options.api, chatUuid, sessionId);
449
+ if (claim.outcome === "busy") {
450
+ showCard("Another session is already running a turn in this chat. Wait for it to finish, or /new to start a fresh one.");
451
+ continue;
452
+ }
453
+ if (claim.outcome === "unavailable") {
454
+ // Reporting this ends the process, and the harness is already
455
+ // running — shut it down here, because `process.exit` skips the
456
+ // `finally` below that would otherwise have done it. Left assigned
457
+ // so the `finally` still closes it on the one path that unwinds
458
+ // instead of exiting; closing twice is harmless.
459
+ await harness.close();
460
+ await reportApiFailure(claim.error);
461
+ }
462
+ let spinner;
463
+ let live;
464
+ // A tool interrupting the narration closes the live block, so `live`
465
+ // alone cannot tell whether the turn ever said anything.
466
+ let saidSomething = false;
467
+ const touchedFiles = new Set();
468
+ const notices = [];
469
+ // Reads and Cargo MCP tools are batched; the first one to finish must
470
+ // not wipe the spinner back to a thinking word while siblings run.
471
+ const activeTools = [];
472
+ // What the user has typed into this turn so far, drawn under the
473
+ // spinner. It lives here rather than on the spinner because a turn
474
+ // stops and restarts several of those — for an approval, for each tool
475
+ // label — and the line has to outlive all of them.
476
+ let typedLine = "";
477
+ // Plain text: it is clipped to the terminal width, and slicing a
478
+ // styled string would cut an escape sequence in half.
479
+ //
480
+ // Drawn by whichever of the two owns the screen. A reply streaming in
481
+ // has replaced the spinner with a live block, and that is exactly when
482
+ // someone is most likely to be typing over it — routing this to the
483
+ // spinner alone leaves the keyboard working and nothing showing for it.
484
+ const paintTyped = () => {
485
+ const footer = typedLine === "" ? "" : `◆ ${typedLine}`;
486
+ if (live !== undefined) {
487
+ setAssistantFooter(live, footer);
488
+ return;
489
+ }
490
+ if (spinner !== undefined) {
491
+ spinner.footer(footer);
492
+ }
493
+ };
494
+ const stopSpinner = () => {
495
+ if (spinner !== undefined) {
496
+ spinner.stop();
497
+ spinner = undefined;
498
+ }
499
+ };
500
+ const showSpinner = (label) => {
501
+ if (spinner === undefined) {
502
+ spinner = startSpinner(label);
503
+ }
504
+ else {
505
+ spinner.update(label);
506
+ }
507
+ paintTyped();
508
+ };
509
+ // A fresh word each time the turn goes back to thinking, so a long
510
+ // turn visibly moves through its steps instead of sitting on one label
511
+ // from the first second to the last. Held between draws — the spinner
512
+ // is repainted on every tool start and finish, and re-rolling there
513
+ // would make the word flicker rather than mark progress.
514
+ let thinkingWord = pickThinkingWord();
515
+ let wasRunningTool = false;
516
+ // The most recent tool labels the spinner. Nothing running means the
517
+ // turn is thinking rather than working.
518
+ const showActiveToolSpinner = () => {
519
+ const current = activeTools[activeTools.length - 1];
520
+ if (current === undefined) {
521
+ if (wasRunningTool === true) {
522
+ thinkingWord = pickThinkingWord(thinkingWord);
523
+ wasRunningTool = false;
524
+ }
525
+ showSpinner(thinkingWord);
526
+ return;
527
+ }
528
+ wasRunningTool = true;
529
+ showSpinner(describeToolActivity(current.toolName, current.summary));
530
+ };
531
+ // A tool is announced twice — once when its name arrives, again with a
532
+ // summary once its input is known — so the second has to overwrite the
533
+ // first rather than stack a duplicate label.
534
+ const rememberTool = (tool) => {
535
+ const at = activeTools.findIndex((active) => active.toolCallId === tool.toolCallId);
536
+ if (at === -1) {
537
+ activeTools.push(tool);
538
+ }
539
+ else {
540
+ activeTools[at] = tool;
541
+ }
542
+ showActiveToolSpinner();
543
+ };
544
+ const forgetTool = (toolCallId) => {
545
+ const at = activeTools.findIndex((active) => active.toolCallId === toolCallId);
546
+ if (at !== -1) {
547
+ activeTools.splice(at, 1);
548
+ }
549
+ showActiveToolSpinner();
550
+ };
551
+ // Registered before the spinner so our SIGINT handler runs first and
552
+ // clack's spinner is already stopped when its own handler fires —
553
+ // otherwise Ctrl-C prints its cancel line under ours.
554
+ const cancellation = beginCancellableTurn(stopSpinner);
555
+ spinner = startSpinner(thinkingWord);
556
+ const typing = beginTurnTyping({
557
+ carriedLine,
558
+ onInterrupt: () => cancellation.interrupt(),
559
+ onTyped: (text) => {
560
+ typedLine = text;
561
+ paintTyped();
562
+ },
563
+ });
564
+ carriedLine = "";
565
+ try {
566
+ await harness.streamPrompt(question, {
567
+ abortSignal: cancellation.signal,
568
+ onStall: () => {
569
+ cancellation.abort();
570
+ },
571
+ onTextDelta: (delta) => {
572
+ if (live === undefined) {
573
+ stopSpinner();
574
+ live = beginAssistantTurn();
575
+ // The spinner was carrying the typed line; it goes with it.
576
+ paintTyped();
577
+ }
578
+ saidSomething = true;
579
+ appendAssistantDelta(live, delta);
580
+ },
581
+ onActivity: (activity) => {
582
+ // Tool work replaces the spinner text so a long Bash step is
583
+ // not an unexplained pause. Claude Code narrates before it acts,
584
+ // so a tool usually starts with live text on screen: close that
585
+ // block first, and the next delta opens a new one.
586
+ //
587
+ // "dropped" is a tool the harness announced and then abandoned.
588
+ // It has no result coming and it never ran, so it is forgotten
589
+ // without a line — the transcript should hold what happened, not
590
+ // what was briefly considered.
591
+ if (activity.kind === "dropped") {
592
+ forgetTool(activity.toolCallId);
593
+ return;
594
+ }
595
+ if (activity.kind === "end") {
596
+ // Read back before forgetting: only the remembered call knows
597
+ // the input, and `end` carries the name and the outcome alone.
598
+ const finished = activeTools.find((active) => active.toolCallId === activity.toolCallId);
599
+ showToolRun({
600
+ spinner,
601
+ toolName: activity.toolName,
602
+ summary: finished === undefined ? "" : finished.summary,
603
+ isError: activity.isError,
604
+ });
605
+ forgetTool(activity.toolCallId);
606
+ return;
607
+ }
608
+ if (live !== undefined) {
609
+ endAssistantTurn(live);
610
+ live = undefined;
611
+ }
612
+ rememberTool({
613
+ toolCallId: activity.toolCallId,
614
+ toolName: activity.toolName,
615
+ summary: activity.summary,
616
+ });
617
+ },
618
+ onFileTouched: (path) => {
619
+ touchedFiles.add(path);
620
+ },
621
+ onNotice: (message) => {
622
+ notices.push(message);
623
+ },
624
+ onApproval: async (approval) => {
625
+ // Close whatever is on screen: the prompt owns the line next.
626
+ if (live !== undefined) {
627
+ endAssistantTurn(live);
628
+ live = undefined;
629
+ }
630
+ stopSpinner();
631
+ let decision;
632
+ if (approveEveryTool === true) {
633
+ decision = "always";
634
+ }
635
+ else {
636
+ // The picker reads the keyboard itself, so the type-ahead
637
+ // collector has to let go of it — otherwise it swallows the
638
+ // arrow keys and the approval can never be answered. Awaited,
639
+ // because letting go settles first: the picker appears under
640
+ // whatever the user is already typing and defaults to Yes, so
641
+ // a keystroke in flight must not be able to answer it.
642
+ await typing.pause();
643
+ try {
644
+ decision = await askToolApproval(approval);
645
+ }
646
+ finally {
647
+ typing.resume();
648
+ }
649
+ }
650
+ if (decision === "always") {
651
+ approveEveryTool = true;
652
+ }
653
+ if (decision === "no") {
654
+ forgetTool(approval.toolCallId);
655
+ return false;
656
+ }
657
+ // The call was announced before the prompt; keep its label up
658
+ // while it runs instead of falling back to a thinking word.
659
+ rememberTool({
660
+ toolCallId: approval.toolCallId,
661
+ toolName: approval.toolName,
662
+ summary: approval.summary,
663
+ });
664
+ return true;
665
+ },
666
+ onUserQuestion: async (input) => {
667
+ if (live !== undefined) {
668
+ endAssistantTurn(live);
669
+ live = undefined;
670
+ }
671
+ stopSpinner();
672
+ await typing.pause();
673
+ try {
674
+ return await askUserQuestions(input);
675
+ }
676
+ finally {
677
+ typing.resume();
678
+ }
679
+ },
680
+ });
681
+ stopSpinner();
682
+ if (live !== undefined) {
683
+ endAssistantTurn(live);
684
+ }
685
+ else if (saidSomething === false) {
686
+ showAssistantTurn("");
687
+ }
688
+ showTouchedFiles(touchedFiles, options.dir);
689
+ showNotices(notices);
690
+ }
691
+ catch (error) {
692
+ stopSpinner();
693
+ if (live !== undefined) {
694
+ endAssistantTurn(live);
695
+ }
696
+ // The turn is over, whichever way it went: every branch below either
697
+ // replaces the Claude Code session or unwinds the command. So a
698
+ // Ctrl-C from here cannot stop anything the user would want stopped
699
+ // — all it can do is skip the teardown, since `process.exit` runs no
700
+ // `finally` and the `finally` blocks are what stop Claude Code.
701
+ //
702
+ // Marked for the whole unwind rather than the cancel branch alone. A
703
+ // stall waits on the same `close` with the collector still holding
704
+ // the terminal, and it is the likelier press of the two: minutes of
705
+ // spinner is what makes someone reach for Ctrl-C in the first place.
706
+ //
707
+ // It also freezes the flag the branch below reads, so a press
708
+ // landing here cannot make a stall look like a user cancel.
709
+ cancellation.beginTeardown();
710
+ if (cancellation.wasCancelled() === true) {
711
+ // Ctrl-C aborts the harness stream, and what it leaves behind is a
712
+ // session that answers nothing: the next question opens a stream
713
+ // that yields its `start` part and then goes silent until the
714
+ // stall watchdog gives up on it two minutes later. The harness
715
+ // does not report it as an unfinished turn, so there is nothing to
716
+ // drain and no way to tell from the outside — the only cure is a
717
+ // new Claude Code process.
718
+ //
719
+ // Paid here rather than on the next question so the wait belongs
720
+ // to the interrupt the user just pressed. The chat, its history
721
+ // and the working copy all survive; the next turn replays the
722
+ // history into a fresh session.
723
+ await harness.close();
724
+ harness = undefined;
725
+ showTouchedFiles(touchedFiles, options.dir);
726
+ showNotices(notices);
727
+ showCard("Cancelled.");
728
+ persistAskState({
729
+ chatUuid,
730
+ agentUuid: agent === undefined ? undefined : agent.uuid,
731
+ });
732
+ continue;
733
+ }
734
+ showNotices(notices);
735
+ // The runtime could not be wound down, went silent, or the bridge
736
+ // dropped — this session will refuse every question from here.
737
+ // Replace it rather than exiting Ask: the chat, its history and the
738
+ // working copy all survive; only the Claude Code process is new.
739
+ if (error instanceof HarnessTurnStuck ||
740
+ error instanceof HarnessTurnStalled ||
741
+ isBridgeClosedError(error) === true) {
742
+ await harness.close();
743
+ harness = undefined;
744
+ showCard(error instanceof HarnessTurnStalled
745
+ ? `${error.message} The coding session has been replaced — ask again, or try smaller steps.`
746
+ : "That coding session stopped responding, so it has been replaced. Ask again — the chat is unchanged.");
747
+ continue;
748
+ }
749
+ throw error;
750
+ }
751
+ finally {
752
+ // Before `cancellation.release()`: while the collector still owns
753
+ // the terminal, Ctrl-C is a keypress it turns into an interrupt, and
754
+ // dropping the handler first would leave a window with neither.
755
+ typedAhead = typing.finish();
756
+ cancellation.release();
757
+ await releaseChatTurn(options.api, chatUuid, sessionId);
758
+ }
759
+ persistAskState({
760
+ chatUuid,
761
+ agentUuid: agent === undefined ? undefined : agent.uuid,
762
+ });
763
+ continue;
764
+ }
765
+ }
766
+ finally {
767
+ if (harness !== undefined) {
768
+ await harness.close();
769
+ }
770
+ }
771
+ }
772
+ export async function runAskPrint(input) {
773
+ const config = getConfig();
774
+ if (config.getAccessToken === undefined) {
775
+ failWith("Not authenticated. Run 'cargo-ai login --email <email>' or 'cargo-ai login --oauth'.", { code: ExitCodes.NotAuthenticated });
776
+ }
777
+ const agent = input.agentUuid !== undefined
778
+ ? await loadAgent(input.api, input.agentUuid)
779
+ : undefined;
780
+ if (agent !== undefined && agent.deployedRelease === undefined) {
781
+ failWith(`${agent.name} has no deployed release, so Ask cannot load its prompt and tools.`, { code: ExitCodes.GenericError });
782
+ }
783
+ const chatUuid = input.chatUuid !== undefined ? input.chatUuid : uuidv4();
784
+ // Same lifecycle as the interactive session: a caller-supplied uuid may
785
+ // already be on the server (--chat-uuid, or --continue reopening the last
786
+ // thread), in which case creating it again would fail; a fresh one has to
787
+ // be created before the first message.
788
+ const chatCreated = input.chatUuid !== undefined
789
+ ? await doesChatExist(input.api, input.chatUuid)
790
+ : false;
791
+ await ensureChat({
792
+ api: input.api,
793
+ chatUuid,
794
+ agentUuid: agent === undefined ? undefined : agent.uuid,
795
+ created: chatCreated,
796
+ });
797
+ const minted = await handleApiCall(() => input.api.ai.chat.proxy.mintToken(chatUuid), { spinner: false });
798
+ const history = await loadChatHistoryPreamble({
799
+ api: input.api,
800
+ chatUuid,
801
+ });
802
+ const ownerId = uuidv4();
803
+ // Before the harness is built, because both exits below end the process
804
+ // and `process.exit` does not run `finally` blocks — either one below a
805
+ // live harness would leave Claude Code and its bridge running.
806
+ const claim = await claimChatTurn(input.api, chatUuid, ownerId);
807
+ if (claim.outcome === "busy") {
808
+ 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 });
809
+ }
810
+ if (claim.outcome === "unavailable") {
811
+ await reportApiFailure(claim.error);
812
+ }
813
+ let harness;
814
+ try {
815
+ harness = await createLocalHarness({
816
+ api: input.api,
817
+ apiBaseUrl: config.baseUrl,
818
+ dir: input.dir,
819
+ chatUuid,
820
+ agentUuid: agent === undefined ? undefined : agent.uuid,
821
+ release: await loadDeployedRelease(input.api, agent),
822
+ proxyToken: minted.token,
823
+ connectorUuid: minted.connectorUuid,
824
+ integrationSlug: minted.integrationSlug,
825
+ languageModelSlug: minted.languageModelSlug,
826
+ history,
827
+ turnOwnerId: ownerId,
828
+ allowWorkspaceMutations: false,
829
+ });
830
+ const text = await harness.streamPrompt(input.message);
831
+ persistAskState({
832
+ chatUuid,
833
+ agentUuid: agent === undefined ? undefined : agent.uuid,
834
+ });
835
+ outputJson({ chatUuid, text });
836
+ }
837
+ finally {
838
+ // Covers the build as well as the turn: a turn that errored spent
839
+ // nothing and so recorded nothing, and a build that failed never got as
840
+ // far as a turn. Either would leave the chat claimed until the TTL.
841
+ await releaseChatTurn(input.api, chatUuid, ownerId);
842
+ if (harness !== undefined) {
843
+ await harness.close();
844
+ }
845
+ }
846
+ }