@cargo-ai/cli 1.0.77 → 1.0.80
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -0
- package/build/commands/ai/chat.d.ts.map +1 -1
- package/build/commands/ai/chat.js +6 -0
- package/build/commands/ai/message.d.ts.map +1 -1
- package/build/commands/ai/message.js +10 -0
- package/build/commands/ai/release.d.ts.map +1 -1
- package/build/commands/ai/release.js +29 -2
- package/build/commands/ask/argvFlags.d.ts +18 -0
- package/build/commands/ask/argvFlags.d.ts.map +1 -0
- package/build/commands/ask/argvFlags.js +46 -0
- package/build/commands/ask/artifacts.d.ts +2 -0
- package/build/commands/ask/artifacts.d.ts.map +1 -0
- package/build/commands/ask/artifacts.js +100 -0
- package/build/commands/ask/binding.d.ts +13 -0
- package/build/commands/ask/binding.d.ts.map +1 -0
- package/build/commands/ask/binding.js +19 -0
- package/build/commands/ask/claudeConfig.d.ts +23 -0
- package/build/commands/ask/claudeConfig.d.ts.map +1 -0
- package/build/commands/ask/claudeConfig.js +33 -0
- package/build/commands/ask/completer.d.ts +48 -0
- package/build/commands/ask/completer.d.ts.map +1 -0
- package/build/commands/ask/completer.js +217 -0
- package/build/commands/ask/history.d.ts +32 -0
- package/build/commands/ask/history.d.ts.map +1 -0
- package/build/commands/ask/history.js +122 -0
- package/build/commands/ask/index.d.ts +14 -0
- package/build/commands/ask/index.d.ts.map +1 -0
- package/build/commands/ask/index.js +111 -0
- package/build/commands/ask/localHarness.d.ts +225 -0
- package/build/commands/ask/localHarness.d.ts.map +1 -0
- package/build/commands/ask/localHarness.js +1074 -0
- package/build/commands/ask/messageStream.d.ts +33 -0
- package/build/commands/ask/messageStream.d.ts.map +1 -0
- package/build/commands/ask/messageStream.js +114 -0
- package/build/commands/ask/persist.d.ts +5 -0
- package/build/commands/ask/persist.d.ts.map +1 -0
- package/build/commands/ask/persist.js +31 -0
- package/build/commands/ask/pnpmShim.d.ts +20 -0
- package/build/commands/ask/pnpmShim.d.ts.map +1 -0
- package/build/commands/ask/pnpmShim.js +39 -0
- package/build/commands/ask/prompt.d.ts +20 -0
- package/build/commands/ask/prompt.d.ts.map +1 -0
- package/build/commands/ask/prompt.js +228 -0
- package/build/commands/ask/rewriteChatFlag.d.ts +9 -0
- package/build/commands/ask/rewriteChatFlag.d.ts.map +1 -0
- package/build/commands/ask/rewriteChatFlag.js +26 -0
- package/build/commands/ask/session.d.ts +94 -0
- package/build/commands/ask/session.d.ts.map +1 -0
- package/build/commands/ask/session.js +965 -0
- package/build/commands/ask/slash.d.ts +20 -0
- package/build/commands/ask/slash.d.ts.map +1 -0
- package/build/commands/ask/slash.js +73 -0
- package/build/commands/ask/state.d.ts +9 -0
- package/build/commands/ask/state.d.ts.map +1 -0
- package/build/commands/ask/state.js +18 -0
- package/build/commands/bookDemo.d.ts +14 -0
- package/build/commands/bookDemo.d.ts.map +1 -0
- package/build/commands/bookDemo.js +100 -0
- package/build/commands/runHandler.d.ts +9 -1
- package/build/commands/runHandler.d.ts.map +1 -1
- package/build/commands/runHandler.js +16 -1
- package/build/index.js +56 -18
- package/build/paletteLayout.d.ts +6 -1
- package/build/paletteLayout.d.ts.map +1 -1
- package/build/paletteLayout.js +35 -9
- package/build/utils/cdkState.d.ts +17 -4
- package/build/utils/cdkState.d.ts.map +1 -1
- package/build/utils/cdkState.js +26 -4
- package/build/utils/loadCompiledAgent.d.ts.map +1 -1
- package/build/utils/loadCompiledAgent.js +51 -11
- package/build/utils/prompt.d.ts.map +1 -1
- package/build/utils/prompt.js +4 -1
- 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
|
+
}
|