@cargo-ai/cli 1.0.78 → 1.0.81

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 (67) hide show
  1. package/README.md +12 -0
  2. package/build/commands/ask/argvFlags.d.ts +18 -0
  3. package/build/commands/ask/argvFlags.d.ts.map +1 -0
  4. package/build/commands/ask/argvFlags.js +46 -0
  5. package/build/commands/ask/artifacts.d.ts +2 -0
  6. package/build/commands/ask/artifacts.d.ts.map +1 -0
  7. package/build/commands/ask/artifacts.js +100 -0
  8. package/build/commands/ask/binding.d.ts +13 -0
  9. package/build/commands/ask/binding.d.ts.map +1 -0
  10. package/build/commands/ask/binding.js +19 -0
  11. package/build/commands/ask/claudeConfig.d.ts +23 -0
  12. package/build/commands/ask/claudeConfig.d.ts.map +1 -0
  13. package/build/commands/ask/claudeConfig.js +33 -0
  14. package/build/commands/ask/completer.d.ts +48 -0
  15. package/build/commands/ask/completer.d.ts.map +1 -0
  16. package/build/commands/ask/completer.js +217 -0
  17. package/build/commands/ask/history.d.ts +32 -0
  18. package/build/commands/ask/history.d.ts.map +1 -0
  19. package/build/commands/ask/history.js +122 -0
  20. package/build/commands/ask/index.d.ts +14 -0
  21. package/build/commands/ask/index.d.ts.map +1 -0
  22. package/build/commands/ask/index.js +111 -0
  23. package/build/commands/ask/localHarness.d.ts +225 -0
  24. package/build/commands/ask/localHarness.d.ts.map +1 -0
  25. package/build/commands/ask/localHarness.js +1074 -0
  26. package/build/commands/ask/messageStream.d.ts +33 -0
  27. package/build/commands/ask/messageStream.d.ts.map +1 -0
  28. package/build/commands/ask/messageStream.js +114 -0
  29. package/build/commands/ask/persist.d.ts +5 -0
  30. package/build/commands/ask/persist.d.ts.map +1 -0
  31. package/build/commands/ask/persist.js +31 -0
  32. package/build/commands/ask/pnpmShim.d.ts +20 -0
  33. package/build/commands/ask/pnpmShim.d.ts.map +1 -0
  34. package/build/commands/ask/pnpmShim.js +39 -0
  35. package/build/commands/ask/prompt.d.ts +20 -0
  36. package/build/commands/ask/prompt.d.ts.map +1 -0
  37. package/build/commands/ask/prompt.js +228 -0
  38. package/build/commands/ask/rewriteChatFlag.d.ts +9 -0
  39. package/build/commands/ask/rewriteChatFlag.d.ts.map +1 -0
  40. package/build/commands/ask/rewriteChatFlag.js +26 -0
  41. package/build/commands/ask/session.d.ts +94 -0
  42. package/build/commands/ask/session.d.ts.map +1 -0
  43. package/build/commands/ask/session.js +965 -0
  44. package/build/commands/ask/slash.d.ts +20 -0
  45. package/build/commands/ask/slash.d.ts.map +1 -0
  46. package/build/commands/ask/slash.js +73 -0
  47. package/build/commands/ask/state.d.ts +9 -0
  48. package/build/commands/ask/state.d.ts.map +1 -0
  49. package/build/commands/ask/state.js +18 -0
  50. package/build/commands/bookDemo.d.ts +14 -0
  51. package/build/commands/bookDemo.d.ts.map +1 -0
  52. package/build/commands/bookDemo.js +100 -0
  53. package/build/commands/runHandler.d.ts +9 -1
  54. package/build/commands/runHandler.d.ts.map +1 -1
  55. package/build/commands/runHandler.js +16 -1
  56. package/build/index.js +56 -18
  57. package/build/paletteLayout.d.ts +6 -1
  58. package/build/paletteLayout.d.ts.map +1 -1
  59. package/build/paletteLayout.js +35 -9
  60. package/build/utils/cdkState.d.ts +17 -4
  61. package/build/utils/cdkState.d.ts.map +1 -1
  62. package/build/utils/cdkState.js +26 -4
  63. package/build/utils/loadCompiledAgent.d.ts.map +1 -1
  64. package/build/utils/loadCompiledAgent.js +51 -11
  65. package/build/utils/prompt.d.ts.map +1 -1
  66. package/build/utils/prompt.js +4 -1
  67. package/package.json +8 -4
@@ -0,0 +1,965 @@
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 { colors, ExitCodes, failWith, handleApiCall, outputJson, pollUntilFinished, startSpinner, } from "../runHandler.js";
8
+ import { loadChatHistoryPreamble } from "./history.js";
9
+ import { createLocalHarness, HarnessTurnStuck, } 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
+ function requireInteractive() {
16
+ if (isInteractive() === false || process.stdout.isTTY !== true) {
17
+ failWith("Ask needs a terminal. Re-run with --print for one JSON turn.", {
18
+ code: ExitCodes.InvalidUsage,
19
+ });
20
+ }
21
+ }
22
+ /** Sentinel for `/agent` → Local workspace (no agentUuid). */
23
+ const LOCAL_WORKSPACE = "__local_workspace__";
24
+ /**
25
+ * How long before a proxy token's stated expiry to stop trusting it. Covers
26
+ * the gap between deciding to reuse the harness and the first proxy call the
27
+ * turn makes.
28
+ */
29
+ const TOKEN_REFRESH_MARGIN_MS = 5 * 60_000;
30
+ /**
31
+ * When the proxy token baked into the running harness stops being safe to
32
+ * reuse.
33
+ *
34
+ * The server re-arms the token on every proxy call, so the clock runs from
35
+ * the last turn that reached Anthropic rather than from when it was minted.
36
+ * Anchoring it to mint time instead would tear down a conversation that is
37
+ * still going — a cold start, a replayed history preamble and the loss of
38
+ * Claude Code's in-session memory, for a token that had not expired.
39
+ *
40
+ * Exported for tests.
41
+ */
42
+ export function proxyTokenDeadlineMs(input) {
43
+ const armedAtMs = input.lastUsedAtMs === undefined ? input.mintedAtMs : input.lastUsedAtMs;
44
+ return armedAtMs + input.ttlMs - TOKEN_REFRESH_MARGIN_MS;
45
+ }
46
+ const PRINT_POLL_INTERVAL_MS = 5_000;
47
+ /**
48
+ * How long `--print` waits for a remote turn. Generous, because a multi-step
49
+ * agent legitimately runs for a while — but bounded, because `--print` is the
50
+ * path used by pipes and CI, where nobody is there to notice a wait that
51
+ * never ends.
52
+ */
53
+ const PRINT_POLL_TIMEOUT_MS = 30 * 60_000;
54
+ async function loadAgent(api, agentUuid) {
55
+ const result = await handleApiCall(() => api.ai.agent.get(agentUuid));
56
+ return result.agent;
57
+ }
58
+ /**
59
+ * No agent → local Claude Code on the host. Any agent streams remotely, like
60
+ * the webapp chat: what it runs — a tool loop or a harness against its bound
61
+ * repo — follows from its deployed release, which the API resolves itself.
62
+ *
63
+ * Exported for tests.
64
+ */
65
+ export function modeForAgent(agent) {
66
+ return agent === undefined ? { kind: "local" } : { kind: "remote" };
67
+ }
68
+ /**
69
+ * Pick a workspace agent, or Local workspace (clears the agent).
70
+ * Returns undefined when the user chooses the local default.
71
+ */
72
+ async function pickAgent(api, currentUuid) {
73
+ const { agents } = await handleApiCall(() => api.ai.agent.all());
74
+ const chosen = await selectOne({
75
+ message: "Agent for the next question",
76
+ recommended: currentUuid !== undefined ? currentUuid : LOCAL_WORKSPACE,
77
+ choices: [
78
+ {
79
+ value: LOCAL_WORKSPACE,
80
+ label: "Local workspace",
81
+ hint: "Claude Code on this machine (default)",
82
+ fallback: true,
83
+ },
84
+ ...agents.map((agent) => ({
85
+ value: agent.uuid,
86
+ label: agent.name,
87
+ hint: agent.uuid,
88
+ })),
89
+ ],
90
+ });
91
+ if (chosen === LOCAL_WORKSPACE) {
92
+ return undefined;
93
+ }
94
+ return chosen;
95
+ }
96
+ /**
97
+ * `harnessSlug` is deliberately not sent. On the API it is a chat-level
98
+ * override that outranks the agent's release for the life of the chat, so a
99
+ * chat stamped here would keep routing to a harness even after `/agent` picks
100
+ * something else — and a local chat's harness runs on this machine, where the
101
+ * API could not reach it anyway. Letting the release speak for itself keeps the
102
+ * chat reusable by any agent.
103
+ */
104
+ async function ensureChat(input) {
105
+ if (input.created === true) {
106
+ return;
107
+ }
108
+ await handleApiCall(() => input.api.ai.chat.create({
109
+ uuid: input.chatUuid,
110
+ trigger: { type: "app" },
111
+ agentUuid: input.agentUuid,
112
+ }), { spinner: false });
113
+ }
114
+ function persistAskState(input) {
115
+ saveAskState({
116
+ version: ASK_STATE_VERSION,
117
+ chatUuid: input.chatUuid,
118
+ agentUuid: input.agentUuid,
119
+ updatedAt: new Date().toISOString(),
120
+ });
121
+ }
122
+ /**
123
+ * Take the chat for one local turn.
124
+ *
125
+ * Exported for tests.
126
+ */
127
+ export async function claimChatTurn(api, chatUuid, ownerId) {
128
+ try {
129
+ await api.ai.chat.claimTurn(chatUuid, { ownerId });
130
+ return { outcome: "claimed" };
131
+ }
132
+ catch (error) {
133
+ if (determineIfIsFetcherError(error) === true &&
134
+ error.status === 409) {
135
+ return { outcome: "busy" };
136
+ }
137
+ // Handed back rather than reported from here. Reporting an API failure
138
+ // ends the process, and the interactive session has a Claude Code process
139
+ // to shut down first — `process.exit` does not run the `finally` that
140
+ // would have done it.
141
+ return { outcome: "unavailable", error };
142
+ }
143
+ }
144
+ /**
145
+ * Report an API failure the way every other call in the CLI does, and exit.
146
+ * Never returns: `handleApiCall` exits on a recognised API error, and anything
147
+ * else is rethrown for the top-level handler.
148
+ */
149
+ async function reportApiFailure(error) {
150
+ await handleApiCall(async () => {
151
+ throw error;
152
+ }, { spinner: false });
153
+ throw error;
154
+ }
155
+ /**
156
+ * Hand the chat back after a turn.
157
+ *
158
+ * Recording the turn already does this, but a turn that spent nothing has
159
+ * nothing to record — a stall, an expired token, a workspace out of credits —
160
+ * and the claim would sit there until it expired, telling the next process the
161
+ * chat was busy.
162
+ *
163
+ * Best effort: the TTL is the backstop, and a reply the user has already read
164
+ * must not fail on the way out.
165
+ */
166
+ async function releaseChatTurn(api, chatUuid, ownerId) {
167
+ try {
168
+ await api.ai.chat.releaseTurn(chatUuid, { ownerId });
169
+ }
170
+ catch {
171
+ // Nothing useful to say to the user about this one.
172
+ }
173
+ }
174
+ export async function doesChatExist(api, chatUuid) {
175
+ try {
176
+ await api.ai.chat.get(chatUuid);
177
+ return true;
178
+ }
179
+ catch (error) {
180
+ if (determineIfIsFetcherError(error) === true &&
181
+ error.status === 404) {
182
+ return false;
183
+ }
184
+ await handleApiCall(async () => {
185
+ throw error;
186
+ }, { spinner: false });
187
+ return false;
188
+ }
189
+ }
190
+ /**
191
+ * Body width under `◇ ` / `│ ` / `◆ `. Soft wrap without this drops the
192
+ * rail mid-line and the tree looks broken.
193
+ */
194
+ function treeBodyWidth() {
195
+ const columns = process.stderr.columns === undefined ? 80 : process.stderr.columns;
196
+ // Marker + two spaces (`◇ `, `│ `, `◆ `).
197
+ return Math.max(24, columns - 3);
198
+ }
199
+ /**
200
+ * Greedy wrap on spaces. A word longer than the width keeps its own line
201
+ * rather than being cut mid-token.
202
+ */
203
+ function wrapLine(text, width) {
204
+ const lines = [];
205
+ for (const word of text.split(/\s+/).filter((part) => part.length > 0)) {
206
+ const current = lines[lines.length - 1];
207
+ if (current === undefined || `${current} ${word}`.length > width) {
208
+ lines.push(word);
209
+ continue;
210
+ }
211
+ lines[lines.length - 1] = `${current} ${word}`;
212
+ }
213
+ return lines;
214
+ }
215
+ /** One line under the current ◆/◇, with the │ rail (same indent as clack). */
216
+ function writeRail(body) {
217
+ process.stderr.write(`${colors.dim("│")} ${body}\n`);
218
+ }
219
+ /** Word-wrap onto the │ rail so the terminal never soft-wraps mid-line. */
220
+ function writeRailWrapped(body) {
221
+ // Keep the rail on blank paragraphs so the tree does not break mid-reply.
222
+ if (body === "") {
223
+ writeRail("");
224
+ return;
225
+ }
226
+ const lines = wrapLine(body, treeBodyWidth());
227
+ if (lines.length === 0) {
228
+ writeRail("");
229
+ return;
230
+ }
231
+ for (const line of lines) {
232
+ writeRail(line);
233
+ }
234
+ }
235
+ /**
236
+ * Light cleanup so model markdown does not fight the tree. Keeps the words;
237
+ * drops heading marks, bold markers, and horizontal rules.
238
+ */
239
+ function forTerminal(text) {
240
+ return text
241
+ .replace(/^#{1,6}\s+/gm, "")
242
+ .replace(/\*\*([^*]+)\*\*/g, "$1")
243
+ .replace(/^---+$/gm, "");
244
+ }
245
+ /**
246
+ * One finished step: label on the diamond, body on the rail.
247
+ *
248
+ * ◇ Agent
249
+ * │ reply…
250
+ */
251
+ function showStep(label, body) {
252
+ process.stderr.write(`${colors.dim("◇")} ${label}\n`);
253
+ for (const paragraph of body.split("\n")) {
254
+ writeRailWrapped(paragraph);
255
+ }
256
+ }
257
+ /**
258
+ * Slash help / notes — finished step like the palette:
259
+ * ◇ /help
260
+ * │ …
261
+ */
262
+ function showCard(body) {
263
+ const paragraphs = body.split("\n");
264
+ const title = paragraphs[0] === undefined ? "" : paragraphs[0];
265
+ const rest = paragraphs.slice(1).join("\n");
266
+ showStep(title, rest);
267
+ }
268
+ /**
269
+ * Finished user turn — message on the diamond, like the palette's
270
+ * `◇ ask hey --continue`. No "You" label.
271
+ */
272
+ function showUserTurn(body) {
273
+ const lines = body === "" ? [""] : wrapLine(body, treeBodyWidth());
274
+ const first = lines.length === 0 ? "" : lines[0] === undefined ? "" : lines[0];
275
+ process.stderr.write(`${colors.dim("◇")} ${first}\n`);
276
+ for (let index = 1; index < lines.length; index += 1) {
277
+ const line = lines[index];
278
+ if (line !== undefined) {
279
+ writeRail(line);
280
+ }
281
+ }
282
+ }
283
+ /**
284
+ * Display lines for an assistant body. First line is the brick header body;
285
+ * later lines sit on the │ rail. Blank paragraphs keep an empty rail.
286
+ */
287
+ function assistantDisplayLines(body) {
288
+ const paragraphs = forTerminal(body).split("\n");
289
+ const lines = [];
290
+ for (let index = 0; index < paragraphs.length; index += 1) {
291
+ const paragraph = paragraphs[index];
292
+ if (paragraph === undefined) {
293
+ continue;
294
+ }
295
+ if (paragraph === "") {
296
+ lines.push("");
297
+ continue;
298
+ }
299
+ const wrapped = wrapLine(paragraph, treeBodyWidth());
300
+ if (wrapped.length === 0) {
301
+ lines.push("");
302
+ continue;
303
+ }
304
+ for (const line of wrapped) {
305
+ lines.push(line);
306
+ }
307
+ }
308
+ if (lines.length === 0) {
309
+ return [""];
310
+ }
311
+ return lines;
312
+ }
313
+ function writeAssistantLines(lines) {
314
+ const first = lines[0] === undefined ? "" : lines[0];
315
+ // One space after the brick: the emoji is already two columns, so `🧱 `
316
+ // lines up with `◇ ` / `│ `.
317
+ process.stderr.write(`🧱 ${first}\n`);
318
+ for (let index = 1; index < lines.length; index += 1) {
319
+ const line = lines[index];
320
+ if (line !== undefined) {
321
+ writeRail(line);
322
+ }
323
+ }
324
+ }
325
+ function clearPaintedLines(count) {
326
+ if (count <= 0 || process.stderr.isTTY !== true) {
327
+ return;
328
+ }
329
+ process.stderr.write(`\x1B[${String(count)}A\r\x1B[J`);
330
+ }
331
+ /** Rail gap, then an empty brick line ready for deltas. */
332
+ function beginAssistantTurn() {
333
+ writeRail("");
334
+ process.stderr.write(`🧱 \n`);
335
+ return { buffer: "", paintedLines: 1 };
336
+ }
337
+ function appendAssistantDelta(live, delta) {
338
+ live.buffer += delta;
339
+ const lines = assistantDisplayLines(live.buffer);
340
+ clearPaintedLines(live.paintedLines);
341
+ writeAssistantLines(lines);
342
+ live.paintedLines = lines.length;
343
+ }
344
+ /** Trailing rail gap before the next user turn / ◆ prompt. */
345
+ function endAssistantTurn(_live) {
346
+ writeRail("");
347
+ }
348
+ /**
349
+ * Finished assistant turn — same tree as a user step, but `🧱` instead of `◇`.
350
+ */
351
+ function showAssistantTurn(body) {
352
+ const live = beginAssistantTurn();
353
+ appendAssistantDelta(live, body);
354
+ endAssistantTurn(live);
355
+ }
356
+ /**
357
+ * Ctrl-C during a turn cancels the turn, not the CLI: the harness interrupts
358
+ * Claude Code, the tree says so, and the ◆ prompt comes back. A second Ctrl-C
359
+ * while the first is still settling exits for real.
360
+ */
361
+ function beginCancellableTurn(onCancel) {
362
+ const controller = new AbortController();
363
+ const state = { cancelled: false };
364
+ const handleInterrupt = () => {
365
+ if (state.cancelled === true) {
366
+ // 130 is the conventional "terminated by SIGINT" status.
367
+ process.exit(130);
368
+ }
369
+ state.cancelled = true;
370
+ onCancel();
371
+ controller.abort();
372
+ };
373
+ process.on("SIGINT", handleInterrupt);
374
+ return {
375
+ signal: controller.signal,
376
+ wasCancelled: () => state.cancelled,
377
+ release: () => {
378
+ process.removeListener("SIGINT", handleInterrupt);
379
+ },
380
+ };
381
+ }
382
+ /**
383
+ * Stop a remote turn the user walked away from.
384
+ *
385
+ * Best effort: the uuid is only known once the server has announced the
386
+ * message, and a failed cancel must not take the chat session down with it —
387
+ * the next turn reports `lastMessageNotFinished` if this did not land.
388
+ */
389
+ async function cancelRemoteMessage(api, messageUuid) {
390
+ if (messageUuid === undefined) {
391
+ return;
392
+ }
393
+ try {
394
+ await api.ai.message.cancel({ uuids: [messageUuid] });
395
+ }
396
+ catch {
397
+ // Nothing useful to do here; surfacing it would bury the "Cancelled." card.
398
+ }
399
+ }
400
+ /** Common tool names arrive lowercase (`bash`); natives keep their case. */
401
+ function toolLabel(toolName) {
402
+ if (toolName.length === 0) {
403
+ return "tool";
404
+ }
405
+ return `${toolName.slice(0, 1).toUpperCase()}${toolName.slice(1)}`;
406
+ }
407
+ const ACTIVITY_MAX_LENGTH = 72;
408
+ function describeToolActivity(toolName, summary) {
409
+ const label = toolLabel(toolName);
410
+ if (summary === "") {
411
+ return label;
412
+ }
413
+ const line = `${label}: ${summary}`;
414
+ return line.length > ACTIVITY_MAX_LENGTH
415
+ ? `${line.slice(0, ACTIVITY_MAX_LENGTH - 1)}…`
416
+ : line;
417
+ }
418
+ /**
419
+ * A path inside the session directory reads better relative to it; anything
420
+ * outside stays absolute so it is obvious the turn left the project.
421
+ *
422
+ * Exported for tests.
423
+ */
424
+ export function displayPath(path, dir) {
425
+ const relativePath = relative(dir, path);
426
+ return relativePath === "" ||
427
+ relativePath.startsWith("..") === true ||
428
+ isAbsolute(relativePath) === true
429
+ ? path
430
+ : relativePath;
431
+ }
432
+ /**
433
+ * Closing step for a turn that edited the working copy, so a reply that says
434
+ * "done" is backed by the list of files it touched.
435
+ */
436
+ function showTouchedFiles(paths, dir) {
437
+ if (paths.size === 0) {
438
+ return;
439
+ }
440
+ const listed = [...paths].map((path) => displayPath(path, dir)).sort();
441
+ showStep(`${String(listed.length)} file${listed.length === 1 ? "" : "s"} changed`, listed.join("\n"));
442
+ }
443
+ /**
444
+ * Side notes from a turn — a chat-history save that failed, say. They are not
445
+ * part of the reply, so they land after it rather than inside it.
446
+ */
447
+ function showNotices(notices) {
448
+ while (notices.length > 0) {
449
+ const notice = notices.shift();
450
+ if (notice !== undefined) {
451
+ showCard(notice);
452
+ }
453
+ }
454
+ }
455
+ /**
456
+ * Local Claude Code runs against the user's own working copy, so anything
457
+ * beyond a read is confirmed before it happens:
458
+ *
459
+ * ◆ Run Bash: npm test?
460
+ * │ ● Yes …
461
+ */
462
+ async function askToolApproval(approval) {
463
+ const decision = await selectOne({
464
+ message: approval.summary === ""
465
+ ? `Run ${approval.toolName}?`
466
+ : `Run ${approval.toolName}: ${approval.summary}?`,
467
+ recommended: "yes",
468
+ // Escape here means "not that tool", not "leave the chat" — the turn
469
+ // continues and the model reports what it could not do.
470
+ cancelValue: "no",
471
+ choices: [
472
+ { value: "yes", label: "Yes", hint: "just this once" },
473
+ { value: "always", label: "Yes to everything", hint: "for this chat" },
474
+ { value: "no", label: "No", hint: "tell the model to skip it" },
475
+ ],
476
+ });
477
+ return decision === "always" ? "always" : decision === "no" ? "no" : "yes";
478
+ }
479
+ /**
480
+ * Live prompt: `◆ ` with an as-you-type suggestion menu for `/…` commands
481
+ * and path-like tokens. Empty Enter leaves. A first message from the palette
482
+ * is written as a finished user turn, then sent.
483
+ */
484
+ async function readQuestion(firstMessage, dir) {
485
+ if (firstMessage !== undefined) {
486
+ showUserTurn(firstMessage);
487
+ return firstMessage;
488
+ }
489
+ const input = openPromptInput();
490
+ if (input === undefined) {
491
+ failWith("Ask needs a terminal. Re-run with --print for one JSON turn.", {
492
+ code: ExitCodes.InvalidUsage,
493
+ });
494
+ }
495
+ try {
496
+ const answer = await readAskLine(input.stream, dir);
497
+ if (answer === undefined) {
498
+ return undefined;
499
+ }
500
+ showUserTurn(answer);
501
+ return answer;
502
+ }
503
+ finally {
504
+ input.close();
505
+ }
506
+ }
507
+ /**
508
+ * Same tree grammar as the palette:
509
+ * ◇ <your message>
510
+ * 🧱 reply…
511
+ * ◆
512
+ * Empty Enter or /exit leaves to the parent palette.
513
+ */
514
+ export async function runAskSession(options) {
515
+ requireInteractive();
516
+ const config = getConfig();
517
+ // An API token from CARGO_API_TOKEN is workspace-bound on the server, so
518
+ // config.workspaceUuid can be unset — same as whoami / every other command.
519
+ if (config.getAccessToken === undefined) {
520
+ failWith("Not authenticated. Run 'cargo-ai login --email <email>' or 'cargo-ai login --oauth'.", { code: ExitCodes.NotAuthenticated });
521
+ }
522
+ let agent = options.agentUuid !== undefined
523
+ ? await loadAgent(options.api, options.agentUuid)
524
+ : undefined;
525
+ let chatUuid = options.chatUuid !== undefined ? options.chatUuid : randomUUID();
526
+ let chatCreated = false;
527
+ let pendingFirst = options.message;
528
+ let local;
529
+ // The proxy token is fixed in the Claude Code process env at spawn, so the
530
+ // only way to refresh it is to rebuild the harness. These two say when that
531
+ // becomes necessary; see `proxyTokenDeadlineMs`.
532
+ let localTokenMintedAtMs = 0;
533
+ let localTokenTtlMs = 0;
534
+ // Identifies this process to the server's per-chat turn claim, so our own
535
+ // next turn renews it rather than colliding with it.
536
+ const sessionId = randomUUID();
537
+ // "Yes to everything" lasts until the chat is reset, never on disk.
538
+ let approveEveryTool = false;
539
+ if (options.continueChat === true && options.chatUuid !== undefined) {
540
+ chatCreated = await doesChatExist(options.api, options.chatUuid);
541
+ }
542
+ try {
543
+ for (;;) {
544
+ const mode = modeForAgent(agent);
545
+ const question = await readQuestion(pendingFirst, options.dir);
546
+ pendingFirst = undefined;
547
+ if (question === undefined) {
548
+ return "palette";
549
+ }
550
+ const slash = parseSlash(question);
551
+ if (slash !== undefined) {
552
+ if (slash.kind === "card") {
553
+ showCard(slash.text);
554
+ continue;
555
+ }
556
+ if (slash.action === "exit") {
557
+ return "palette";
558
+ }
559
+ if (slash.action === "cancel") {
560
+ showCard("Nothing is running — this prompt only appears between turns. Press Ctrl-C while a turn is in flight to stop it.");
561
+ continue;
562
+ }
563
+ if (slash.action === "new" || slash.action === "clear") {
564
+ if (local !== undefined) {
565
+ await local.close();
566
+ local = undefined;
567
+ }
568
+ // Fresh session returns to the local default (no agent).
569
+ agent = undefined;
570
+ chatUuid = randomUUID();
571
+ chatCreated = false;
572
+ approveEveryTool = false;
573
+ showCard("Started a fresh chat.");
574
+ continue;
575
+ }
576
+ if (slash.action === "pick-agent") {
577
+ if (local !== undefined) {
578
+ await local.close();
579
+ local = undefined;
580
+ }
581
+ const nextUuid = await pickAgent(options.api, agent === undefined ? undefined : agent.uuid);
582
+ agent =
583
+ nextUuid === undefined
584
+ ? undefined
585
+ : await loadAgent(options.api, nextUuid);
586
+ // A blanket "yes" was given to one agent's local session, not to
587
+ // whatever the next one wants to run.
588
+ approveEveryTool = false;
589
+ continue;
590
+ }
591
+ }
592
+ if (mode.kind === "local") {
593
+ if (chatCreated === false) {
594
+ await ensureChat({
595
+ api: options.api,
596
+ chatUuid,
597
+ agentUuid: agent === undefined ? undefined : agent.uuid,
598
+ created: false,
599
+ });
600
+ chatCreated = true;
601
+ }
602
+ // A session left at the prompt long enough for its token to lapse
603
+ // would send the next turn with a dead key, which surfaces as an
604
+ // authentication failure from the proxy rather than anything the user
605
+ // can act on. Rebuilding costs a cold start and a history replay, so
606
+ // it happens only once the token really is past saving.
607
+ if (local !== undefined &&
608
+ Date.now() >=
609
+ proxyTokenDeadlineMs({
610
+ mintedAtMs: localTokenMintedAtMs,
611
+ lastUsedAtMs: local.proxyLastUsedAtMs(),
612
+ ttlMs: localTokenTtlMs,
613
+ })) {
614
+ await local.close();
615
+ local = undefined;
616
+ }
617
+ if (local === undefined) {
618
+ const minted = await handleApiCall(() => options.api.ai.chat.mintHarnessProxyToken(chatUuid), { spinner: false });
619
+ localTokenMintedAtMs = Date.now();
620
+ localTokenTtlMs = minted.expiresInSeconds * 1000;
621
+ // A new Claude Code session knows nothing of this chat, whether it
622
+ // is being continued from an earlier run or was reset by /agent.
623
+ const history = await loadChatHistoryPreamble({
624
+ api: options.api,
625
+ chatUuid,
626
+ });
627
+ local = await createLocalHarness({
628
+ api: options.api,
629
+ apiBaseUrl: config.baseUrl,
630
+ dir: options.dir,
631
+ chatUuid,
632
+ agentUuid: agent === undefined ? undefined : agent.uuid,
633
+ proxyToken: minted.token,
634
+ history,
635
+ turnOwnerId: sessionId,
636
+ });
637
+ }
638
+ // Nothing about a local turn is visible to the server until it is
639
+ // ingested, so the chat reads as idle while Claude Code works and a
640
+ // second session resuming it would spend alongside this one.
641
+ //
642
+ // Taken here rather than before the setup above: the claim is only
643
+ // released by a finished turn, so holding it across a mint or a
644
+ // harness build that then fails would block the chat for the whole
645
+ // TTL. Nothing in the setup needs protecting — mint hands back the
646
+ // token already in play rather than replacing it.
647
+ const claim = await claimChatTurn(options.api, chatUuid, sessionId);
648
+ if (claim.outcome === "busy") {
649
+ showCard("Another session is already running a turn in this chat. Wait for it to finish, or /new to start a fresh one.");
650
+ continue;
651
+ }
652
+ if (claim.outcome === "unavailable") {
653
+ // Reporting this ends the process, and the harness is already
654
+ // running — shut it down here, because `process.exit` skips the
655
+ // `finally` below that would otherwise have done it. Left assigned
656
+ // so the `finally` still closes it on the one path that unwinds
657
+ // instead of exiting; closing twice is harmless.
658
+ await local.close();
659
+ await reportApiFailure(claim.error);
660
+ }
661
+ let spinner;
662
+ let live;
663
+ // A tool interrupting the narration closes the live block, so `live`
664
+ // alone cannot tell whether the turn ever said anything.
665
+ let saidSomething = false;
666
+ const touchedFiles = new Set();
667
+ const notices = [];
668
+ const stopSpinner = () => {
669
+ if (spinner !== undefined) {
670
+ spinner.stop();
671
+ spinner = undefined;
672
+ }
673
+ };
674
+ const showSpinner = (label) => {
675
+ if (spinner === undefined) {
676
+ spinner = startSpinner(label);
677
+ return;
678
+ }
679
+ spinner.update(label);
680
+ };
681
+ // Registered before the spinner so our SIGINT handler runs first and
682
+ // clack's spinner is already stopped when its own handler fires —
683
+ // otherwise Ctrl-C prints its cancel line under ours.
684
+ const cancellation = beginCancellableTurn(stopSpinner);
685
+ spinner = startSpinner("Asking…");
686
+ try {
687
+ await local.streamPrompt(question, {
688
+ abortSignal: cancellation.signal,
689
+ onTextDelta: (delta) => {
690
+ if (live === undefined) {
691
+ stopSpinner();
692
+ live = beginAssistantTurn();
693
+ }
694
+ saidSomething = true;
695
+ appendAssistantDelta(live, delta);
696
+ },
697
+ onActivity: (activity) => {
698
+ // Tool work replaces the spinner text so a long Bash step is
699
+ // not an unexplained pause. Claude Code narrates before it acts,
700
+ // so a tool usually starts with live text on screen: close that
701
+ // block first, and the next delta opens a new one.
702
+ if (activity.kind === "end") {
703
+ showSpinner("Asking…");
704
+ return;
705
+ }
706
+ if (live !== undefined) {
707
+ endAssistantTurn(live);
708
+ live = undefined;
709
+ }
710
+ showSpinner(describeToolActivity(activity.toolName, activity.summary));
711
+ },
712
+ onFileTouched: (path) => {
713
+ touchedFiles.add(path);
714
+ },
715
+ onNotice: (message) => {
716
+ notices.push(message);
717
+ },
718
+ onApproval: async (approval) => {
719
+ // Close whatever is on screen: the prompt owns the line next.
720
+ if (live !== undefined) {
721
+ endAssistantTurn(live);
722
+ live = undefined;
723
+ }
724
+ stopSpinner();
725
+ const decision = approveEveryTool === true
726
+ ? "always"
727
+ : await askToolApproval(approval);
728
+ if (decision === "always") {
729
+ approveEveryTool = true;
730
+ }
731
+ showSpinner("Asking…");
732
+ return decision !== "no";
733
+ },
734
+ });
735
+ stopSpinner();
736
+ if (live !== undefined) {
737
+ endAssistantTurn(live);
738
+ }
739
+ else if (saidSomething === false) {
740
+ showAssistantTurn("");
741
+ }
742
+ showTouchedFiles(touchedFiles, options.dir);
743
+ showNotices(notices);
744
+ }
745
+ catch (error) {
746
+ stopSpinner();
747
+ if (live !== undefined) {
748
+ endAssistantTurn(live);
749
+ }
750
+ if (cancellation.wasCancelled() === true) {
751
+ showTouchedFiles(touchedFiles, options.dir);
752
+ showNotices(notices);
753
+ showCard("Cancelled.");
754
+ persistAskState({
755
+ chatUuid,
756
+ agentUuid: agent === undefined ? undefined : agent.uuid,
757
+ });
758
+ continue;
759
+ }
760
+ showNotices(notices);
761
+ // The runtime could not be wound down, so this session will refuse
762
+ // every question from here. Replace it rather than leaving the user
763
+ // to guess that /new is the way out — the chat, its history and the
764
+ // working copy all survive; only the Claude Code process is new.
765
+ if (error instanceof HarnessTurnStuck) {
766
+ await local.close();
767
+ local = undefined;
768
+ showCard("That coding session stopped responding, so it has been replaced. Ask again — the chat is unchanged.");
769
+ continue;
770
+ }
771
+ throw error;
772
+ }
773
+ finally {
774
+ cancellation.release();
775
+ await releaseChatTurn(options.api, chatUuid, sessionId);
776
+ }
777
+ persistAskState({
778
+ chatUuid,
779
+ agentUuid: agent === undefined ? undefined : agent.uuid,
780
+ });
781
+ continue;
782
+ }
783
+ if (agent === undefined) {
784
+ failWith("Ask remote stream needs an agent.", {
785
+ code: ExitCodes.GenericError,
786
+ });
787
+ }
788
+ if (chatCreated === false) {
789
+ await ensureChat({
790
+ api: options.api,
791
+ chatUuid,
792
+ agentUuid: agent.uuid,
793
+ created: false,
794
+ });
795
+ chatCreated = true;
796
+ }
797
+ if (config.getAccessToken === undefined) {
798
+ failWith("Not authenticated. Run 'cargo-ai login --email <email>' or 'cargo-ai login --oauth'.", { code: ExitCodes.NotAuthenticated });
799
+ }
800
+ let live;
801
+ let spinner;
802
+ const stopRemoteSpinner = () => {
803
+ if (spinner !== undefined) {
804
+ spinner.stop();
805
+ spinner = undefined;
806
+ }
807
+ };
808
+ // Registered before the spinner so our handler runs before clack's own
809
+ // — same ordering as the local turn.
810
+ const cancellation = beginCancellableTurn(stopRemoteSpinner);
811
+ spinner = startSpinner("Asking…");
812
+ let assistantMessageUuid;
813
+ try {
814
+ const deltas = streamMessageTextDeltas({
815
+ baseUrl: config.baseUrl,
816
+ getAccessToken: config.getAccessToken,
817
+ workspaceUuid: config.workspaceUuid,
818
+ chatUuid,
819
+ messageUuid: randomUUID(),
820
+ parts: [{ type: "text", text: question }],
821
+ agentUuid: agent.uuid,
822
+ onAssistantMessageUuid: (uuid) => {
823
+ assistantMessageUuid = uuid;
824
+ },
825
+ abortSignal: cancellation.signal,
826
+ });
827
+ for await (const delta of deltas) {
828
+ if (live === undefined) {
829
+ stopRemoteSpinner();
830
+ live = beginAssistantTurn();
831
+ }
832
+ appendAssistantDelta(live, delta);
833
+ }
834
+ if (live === undefined) {
835
+ stopRemoteSpinner();
836
+ showAssistantTurn("");
837
+ }
838
+ else {
839
+ endAssistantTurn(live);
840
+ }
841
+ }
842
+ catch (error) {
843
+ stopRemoteSpinner();
844
+ if (live !== undefined) {
845
+ endAssistantTurn(live);
846
+ }
847
+ // Ctrl-C only stops the CLI reading the stream, so the message has to
848
+ // be cancelled server-side too — an unfinished one makes the next
849
+ // question in this chat fail with lastMessageNotFinished.
850
+ if (cancellation.wasCancelled() === true) {
851
+ await cancelRemoteMessage(options.api, assistantMessageUuid);
852
+ showCard("Cancelled.");
853
+ persistAskState({ chatUuid, agentUuid: agent.uuid });
854
+ continue;
855
+ }
856
+ if (error instanceof Error) {
857
+ failWith(error.message, { code: ExitCodes.GenericError });
858
+ }
859
+ throw error;
860
+ }
861
+ finally {
862
+ cancellation.release();
863
+ }
864
+ persistAskState({
865
+ chatUuid,
866
+ agentUuid: agent.uuid,
867
+ });
868
+ }
869
+ }
870
+ finally {
871
+ if (local !== undefined) {
872
+ await local.close();
873
+ }
874
+ }
875
+ }
876
+ export async function runAskPrint(input) {
877
+ const config = getConfig();
878
+ if (config.getAccessToken === undefined) {
879
+ failWith("Not authenticated. Run 'cargo-ai login --email <email>' or 'cargo-ai login --oauth'.", { code: ExitCodes.NotAuthenticated });
880
+ }
881
+ const agent = input.agentUuid !== undefined
882
+ ? await loadAgent(input.api, input.agentUuid)
883
+ : undefined;
884
+ const mode = modeForAgent(agent);
885
+ const chatUuid = input.chatUuid !== undefined ? input.chatUuid : randomUUID();
886
+ // Same lifecycle as the interactive session: a caller-supplied uuid may
887
+ // already be on the server (--chat-uuid, or --continue reopening the last
888
+ // thread), in which case creating it again would fail; a fresh one has to
889
+ // be created before the first message.
890
+ const chatCreated = input.chatUuid !== undefined
891
+ ? await doesChatExist(input.api, input.chatUuid)
892
+ : false;
893
+ if (mode.kind === "local") {
894
+ await ensureChat({
895
+ api: input.api,
896
+ chatUuid,
897
+ agentUuid: agent === undefined ? undefined : agent.uuid,
898
+ created: chatCreated,
899
+ });
900
+ const minted = await handleApiCall(() => input.api.ai.chat.mintHarnessProxyToken(chatUuid), { spinner: false });
901
+ const history = await loadChatHistoryPreamble({
902
+ api: input.api,
903
+ chatUuid,
904
+ });
905
+ const ownerId = randomUUID();
906
+ // Before the harness is built, because both exits below end the process
907
+ // and `process.exit` does not run `finally` blocks — either one below a
908
+ // live harness would leave Claude Code and its bridge running.
909
+ const claim = await claimChatTurn(input.api, chatUuid, ownerId);
910
+ if (claim.outcome === "busy") {
911
+ 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 });
912
+ }
913
+ if (claim.outcome === "unavailable") {
914
+ await reportApiFailure(claim.error);
915
+ }
916
+ let local;
917
+ try {
918
+ local = await createLocalHarness({
919
+ api: input.api,
920
+ apiBaseUrl: config.baseUrl,
921
+ dir: input.dir,
922
+ chatUuid,
923
+ agentUuid: agent === undefined ? undefined : agent.uuid,
924
+ proxyToken: minted.token,
925
+ history,
926
+ turnOwnerId: ownerId,
927
+ });
928
+ const text = await local.streamPrompt(input.message);
929
+ persistAskState({
930
+ chatUuid,
931
+ agentUuid: agent === undefined ? undefined : agent.uuid,
932
+ });
933
+ outputJson({ chatUuid, text });
934
+ }
935
+ finally {
936
+ // Covers the build as well as the turn: a turn that errored spent
937
+ // nothing and so recorded nothing, and a build that failed never got as
938
+ // far as a turn. Either would leave the chat claimed until the TTL.
939
+ await releaseChatTurn(input.api, chatUuid, ownerId);
940
+ if (local !== undefined) {
941
+ await local.close();
942
+ }
943
+ }
944
+ return;
945
+ }
946
+ if (agent === undefined) {
947
+ failWith("Ask remote print needs an agent.", {
948
+ code: ExitCodes.GenericError,
949
+ });
950
+ }
951
+ await ensureChat({
952
+ api: input.api,
953
+ chatUuid,
954
+ agentUuid: agent.uuid,
955
+ created: chatCreated,
956
+ });
957
+ const result = await handleApiCall(() => input.api.ai.message.create({
958
+ chatUuid,
959
+ agentUuid: agent.uuid,
960
+ parts: [{ type: "text", text: input.message }],
961
+ }));
962
+ const finalResult = await pollUntilFinished(() => input.api.ai.message.get(result.assistantMessage.uuid), (row) => row.message.finishedAt, PRINT_POLL_INTERVAL_MS, PRINT_POLL_TIMEOUT_MS);
963
+ persistAskState({ chatUuid, agentUuid: agent.uuid });
964
+ outputJson(finalResult);
965
+ }