@cargo-ai/cli 1.0.94 → 1.0.95
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/commands/ai/chat.d.ts.map +1 -1
- package/build/commands/ai/chat.js +2 -1
- package/build/commands/ai/message.d.ts.map +1 -1
- package/build/commands/ai/message.js +2 -1
- package/build/commands/ai/release.d.ts.map +1 -1
- package/build/commands/ai/release.js +7 -6
- package/build/commands/ask/harness/approvals.d.ts +7 -0
- package/build/commands/ask/harness/approvals.d.ts.map +1 -0
- package/build/commands/ask/harness/approvals.js +82 -0
- package/build/commands/ask/harness/artifacts.d.ts.map +1 -0
- package/build/commands/ask/harness/claudeConfig.d.ts.map +1 -0
- package/build/commands/ask/harness/create.d.ts +59 -0
- package/build/commands/ask/harness/create.d.ts.map +1 -0
- package/build/commands/ask/harness/create.js +292 -0
- package/build/commands/ask/harness/index.d.ts +8 -0
- package/build/commands/ask/harness/index.d.ts.map +1 -0
- package/build/commands/ask/harness/index.js +7 -0
- package/build/commands/ask/harness/ingest.d.ts +25 -0
- package/build/commands/ask/harness/ingest.d.ts.map +1 -0
- package/build/commands/ask/harness/ingest.js +35 -0
- package/build/commands/ask/harness/instructions.d.ts +13 -0
- package/build/commands/ask/harness/instructions.d.ts.map +1 -0
- package/build/commands/ask/harness/instructions.js +41 -0
- package/build/commands/ask/harness/layout.d.ts +23 -0
- package/build/commands/ask/harness/layout.d.ts.map +1 -0
- package/build/commands/ask/harness/layout.js +32 -0
- package/build/commands/ask/harness/mcp.d.ts +25 -0
- package/build/commands/ask/harness/mcp.d.ts.map +1 -0
- package/build/commands/ask/harness/mcp.js +72 -0
- package/build/commands/ask/harness/pnpmShim.d.ts.map +1 -0
- package/build/commands/ask/harness/release.d.ts +18 -0
- package/build/commands/ask/harness/release.d.ts.map +1 -0
- package/build/commands/ask/harness/release.js +117 -0
- package/build/commands/ask/harness/sandbox.d.ts +9 -0
- package/build/commands/ask/harness/sandbox.d.ts.map +1 -0
- package/build/commands/ask/harness/sandbox.js +27 -0
- package/build/commands/ask/harness/timing.d.ts +16 -0
- package/build/commands/ask/harness/timing.d.ts.map +1 -0
- package/build/commands/ask/harness/timing.js +72 -0
- package/build/commands/ask/harness/turn.d.ts +145 -0
- package/build/commands/ask/harness/turn.d.ts.map +1 -0
- package/build/commands/ask/harness/turn.js +626 -0
- package/build/commands/ask/harness/usage.d.ts +50 -0
- package/build/commands/ask/harness/usage.d.ts.map +1 -0
- package/build/commands/ask/harness/usage.js +87 -0
- package/build/commands/ask/index.d.ts +9 -0
- package/build/commands/ask/index.d.ts.map +1 -1
- package/build/commands/ask/index.js +7 -7
- package/build/commands/ask/input/completer.d.ts.map +1 -0
- package/build/commands/ask/input/index.d.ts +5 -0
- package/build/commands/ask/input/index.d.ts.map +1 -0
- package/build/commands/ask/input/index.js +4 -0
- package/build/commands/ask/input/prompt.d.ts.map +1 -0
- package/build/commands/ask/{prompt.js → input/prompt.js} +1 -1
- package/build/commands/ask/input/slash.d.ts.map +1 -0
- package/build/commands/ask/{slash.js → input/slash.js} +1 -1
- package/build/commands/ask/input/typeAhead.d.ts.map +1 -0
- package/build/commands/ask/{history.d.ts → persist/history.d.ts} +1 -1
- package/build/commands/ask/persist/history.d.ts.map +1 -0
- package/build/commands/ask/persist/index.d.ts +4 -0
- package/build/commands/ask/persist/index.d.ts.map +1 -0
- package/build/commands/ask/persist/index.js +3 -0
- package/build/commands/ask/persist/persist.d.ts.map +1 -0
- package/build/commands/ask/persist/state.d.ts.map +1 -0
- package/build/commands/ask/session/claim.d.ts +56 -0
- package/build/commands/ask/session/claim.d.ts.map +1 -0
- package/build/commands/ask/session/claim.js +76 -0
- package/build/commands/ask/session/index.d.ts +5 -0
- package/build/commands/ask/session/index.d.ts.map +1 -0
- package/build/commands/ask/session/index.js +4 -0
- package/build/commands/ask/session/run.d.ts +36 -0
- package/build/commands/ask/session/run.d.ts.map +1 -0
- package/build/commands/ask/session/run.js +832 -0
- package/build/commands/ask/session/thinking.d.ts.map +1 -0
- package/build/commands/ask/session/tree.d.ts +90 -0
- package/build/commands/ask/session/tree.d.ts.map +1 -0
- package/build/commands/ask/session/tree.js +318 -0
- package/package.json +6 -5
- package/build/commands/ask/artifacts.d.ts.map +0 -1
- package/build/commands/ask/binding.d.ts +0 -13
- package/build/commands/ask/binding.d.ts.map +0 -1
- package/build/commands/ask/binding.js +0 -19
- package/build/commands/ask/claudeConfig.d.ts.map +0 -1
- package/build/commands/ask/completer.d.ts.map +0 -1
- package/build/commands/ask/history.d.ts.map +0 -1
- package/build/commands/ask/localHarness.d.ts +0 -278
- package/build/commands/ask/localHarness.d.ts.map +0 -1
- package/build/commands/ask/localHarness.js +0 -1300
- package/build/commands/ask/messageStream.d.ts +0 -33
- package/build/commands/ask/messageStream.d.ts.map +0 -1
- package/build/commands/ask/messageStream.js +0 -114
- package/build/commands/ask/persist.d.ts.map +0 -1
- package/build/commands/ask/pnpmShim.d.ts.map +0 -1
- package/build/commands/ask/prompt.d.ts.map +0 -1
- package/build/commands/ask/session.d.ts +0 -101
- package/build/commands/ask/session.d.ts.map +0 -1
- package/build/commands/ask/session.js +0 -1328
- package/build/commands/ask/slash.d.ts.map +0 -1
- package/build/commands/ask/state.d.ts.map +0 -1
- package/build/commands/ask/thinking.d.ts.map +0 -1
- package/build/commands/ask/typeAhead.d.ts.map +0 -1
- /package/build/commands/ask/{artifacts.d.ts → harness/artifacts.d.ts} +0 -0
- /package/build/commands/ask/{artifacts.js → harness/artifacts.js} +0 -0
- /package/build/commands/ask/{claudeConfig.d.ts → harness/claudeConfig.d.ts} +0 -0
- /package/build/commands/ask/{claudeConfig.js → harness/claudeConfig.js} +0 -0
- /package/build/commands/ask/{pnpmShim.d.ts → harness/pnpmShim.d.ts} +0 -0
- /package/build/commands/ask/{pnpmShim.js → harness/pnpmShim.js} +0 -0
- /package/build/commands/ask/{completer.d.ts → input/completer.d.ts} +0 -0
- /package/build/commands/ask/{completer.js → input/completer.js} +0 -0
- /package/build/commands/ask/{prompt.d.ts → input/prompt.d.ts} +0 -0
- /package/build/commands/ask/{slash.d.ts → input/slash.d.ts} +0 -0
- /package/build/commands/ask/{typeAhead.d.ts → input/typeAhead.d.ts} +0 -0
- /package/build/commands/ask/{typeAhead.js → input/typeAhead.js} +0 -0
- /package/build/commands/ask/{history.js → persist/history.js} +0 -0
- /package/build/commands/ask/{persist.d.ts → persist/persist.d.ts} +0 -0
- /package/build/commands/ask/{persist.js → persist/persist.js} +0 -0
- /package/build/commands/ask/{state.d.ts → persist/state.d.ts} +0 -0
- /package/build/commands/ask/{state.js → persist/state.js} +0 -0
- /package/build/commands/ask/{thinking.d.ts → session/thinking.d.ts} +0 -0
- /package/build/commands/ask/{thinking.js → session/thinking.js} +0 -0
|
@@ -1,1328 +0,0 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { isAbsolute, relative } from "node:path";
|
|
3
|
-
import { determineIfIsFetcherError } from "@cargo-ai/api";
|
|
4
|
-
import { isInteractive, selectOne } from "@cargo-ai/cdk/cli";
|
|
5
|
-
import { getConfig } from "../../config.js";
|
|
6
|
-
import { openPromptInput } from "../../utils/prompt.js";
|
|
7
|
-
import { clipToWidth, colors, ExitCodes, failWith, handleApiCall, outputJson, pollUntilFinished, startSpinner, } from "../runHandler.js";
|
|
8
|
-
import { loadChatHistoryPreamble } from "./history.js";
|
|
9
|
-
import { createLocalHarness, HarnessTurnStalled, HarnessTurnStuck, isBridgeClosedError, } from "./localHarness.js";
|
|
10
|
-
import { streamMessageTextDeltas } from "./messageStream.js";
|
|
11
|
-
import { saveAskState } from "./persist.js";
|
|
12
|
-
import { readAskLine } from "./prompt.js";
|
|
13
|
-
import { parseSlash } from "./slash.js";
|
|
14
|
-
import { ASK_STATE_VERSION } from "./state.js";
|
|
15
|
-
import { pickThinkingWord } from "./thinking.js";
|
|
16
|
-
import { collectTypeAhead, EMPTY_TYPE_AHEAD, } from "./typeAhead.js";
|
|
17
|
-
function requireInteractive() {
|
|
18
|
-
if (isInteractive() === false || process.stdout.isTTY !== true) {
|
|
19
|
-
failWith("Ask needs a terminal. Re-run with --print for one JSON turn.", {
|
|
20
|
-
code: ExitCodes.InvalidUsage,
|
|
21
|
-
});
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
/** Sentinel for `/agent` → Local workspace (no agentUuid). */
|
|
25
|
-
const LOCAL_WORKSPACE = "__local_workspace__";
|
|
26
|
-
/**
|
|
27
|
-
* What an assistant turn is marked with, alongside `◇` for a step and `│` for
|
|
28
|
-
* the rail.
|
|
29
|
-
*
|
|
30
|
-
* A text glyph rather than an emoji, so it takes the same `marker + two
|
|
31
|
-
* spaces` as every other row and the whole tree stays on one left edge. An
|
|
32
|
-
* emoji is two columns wide and needs its own spacing rule to line up, which
|
|
33
|
-
* is one more thing to get wrong every time a line is drawn.
|
|
34
|
-
*/
|
|
35
|
-
const ASSISTANT_MARKER = "■";
|
|
36
|
-
/** Completed tool call, so the transcript keeps what was actually run. */
|
|
37
|
-
const TOOL_MARKER = "⏺";
|
|
38
|
-
/**
|
|
39
|
-
* How long before a proxy token's stated expiry to stop trusting it. Covers
|
|
40
|
-
* the gap between deciding to reuse the harness and the first proxy call the
|
|
41
|
-
* turn makes.
|
|
42
|
-
*/
|
|
43
|
-
const TOKEN_REFRESH_MARGIN_MS = 5 * 60_000;
|
|
44
|
-
/**
|
|
45
|
-
* When the proxy token baked into the running harness stops being safe to
|
|
46
|
-
* reuse.
|
|
47
|
-
*
|
|
48
|
-
* The server re-arms the token on every proxy call, so the clock runs from
|
|
49
|
-
* the last turn that reached Anthropic rather than from when it was minted.
|
|
50
|
-
* Anchoring it to mint time instead would tear down a conversation that is
|
|
51
|
-
* still going — a cold start, a replayed history preamble and the loss of
|
|
52
|
-
* Claude Code's in-session memory, for a token that had not expired.
|
|
53
|
-
*
|
|
54
|
-
* Exported for tests.
|
|
55
|
-
*/
|
|
56
|
-
export function proxyTokenDeadlineMs(input) {
|
|
57
|
-
const armedAtMs = input.lastUsedAtMs === undefined ? input.mintedAtMs : input.lastUsedAtMs;
|
|
58
|
-
return armedAtMs + input.ttlMs - TOKEN_REFRESH_MARGIN_MS;
|
|
59
|
-
}
|
|
60
|
-
const PRINT_POLL_INTERVAL_MS = 5_000;
|
|
61
|
-
/**
|
|
62
|
-
* How long `--print` waits for a remote turn. Generous, because a multi-step
|
|
63
|
-
* agent legitimately runs for a while — but bounded, because `--print` is the
|
|
64
|
-
* path used by pipes and CI, where nobody is there to notice a wait that
|
|
65
|
-
* never ends.
|
|
66
|
-
*/
|
|
67
|
-
const PRINT_POLL_TIMEOUT_MS = 30 * 60_000;
|
|
68
|
-
async function loadAgent(api, agentUuid) {
|
|
69
|
-
const result = await handleApiCall(() => api.ai.agent.get(agentUuid));
|
|
70
|
-
return result.agent;
|
|
71
|
-
}
|
|
72
|
-
/**
|
|
73
|
-
* No agent → local Claude Code on the host. Any agent streams remotely, like
|
|
74
|
-
* the webapp chat: what it runs — a tool loop or a harness against its bound
|
|
75
|
-
* repo — follows from its deployed release, which the API resolves itself.
|
|
76
|
-
*
|
|
77
|
-
* Exported for tests.
|
|
78
|
-
*/
|
|
79
|
-
export function modeForAgent(agent) {
|
|
80
|
-
return agent === undefined ? { kind: "local" } : { kind: "remote" };
|
|
81
|
-
}
|
|
82
|
-
/**
|
|
83
|
-
* Pick a workspace agent, or Local workspace (clears the agent).
|
|
84
|
-
* Returns undefined when the user chooses the local default.
|
|
85
|
-
*/
|
|
86
|
-
async function pickAgent(api, currentUuid) {
|
|
87
|
-
const { agents } = await handleApiCall(() => api.ai.agent.all());
|
|
88
|
-
const chosen = await selectOne({
|
|
89
|
-
message: "Agent for the next question",
|
|
90
|
-
recommended: currentUuid !== undefined ? currentUuid : LOCAL_WORKSPACE,
|
|
91
|
-
choices: [
|
|
92
|
-
{
|
|
93
|
-
value: LOCAL_WORKSPACE,
|
|
94
|
-
label: "Local workspace",
|
|
95
|
-
hint: "Claude Code on this machine (default)",
|
|
96
|
-
fallback: true,
|
|
97
|
-
},
|
|
98
|
-
...agents.map((agent) => ({
|
|
99
|
-
value: agent.uuid,
|
|
100
|
-
label: agent.name,
|
|
101
|
-
hint: agent.uuid,
|
|
102
|
-
})),
|
|
103
|
-
],
|
|
104
|
-
});
|
|
105
|
-
if (chosen === LOCAL_WORKSPACE) {
|
|
106
|
-
return undefined;
|
|
107
|
-
}
|
|
108
|
-
return chosen;
|
|
109
|
-
}
|
|
110
|
-
/**
|
|
111
|
-
* `harnessSlug` is deliberately not sent. On the API it is a chat-level
|
|
112
|
-
* override that outranks the agent's release for the life of the chat, so a
|
|
113
|
-
* chat stamped here would keep routing to a harness even after `/agent` picks
|
|
114
|
-
* something else — and a local chat's harness runs on this machine, where the
|
|
115
|
-
* API could not reach it anyway. Letting the release speak for itself keeps the
|
|
116
|
-
* chat reusable by any agent.
|
|
117
|
-
*/
|
|
118
|
-
async function ensureChat(input) {
|
|
119
|
-
if (input.created === true) {
|
|
120
|
-
return;
|
|
121
|
-
}
|
|
122
|
-
await handleApiCall(() => input.api.ai.chat.create({
|
|
123
|
-
uuid: input.chatUuid,
|
|
124
|
-
trigger: { type: "cli" },
|
|
125
|
-
agentUuid: input.agentUuid,
|
|
126
|
-
}), { spinner: false });
|
|
127
|
-
}
|
|
128
|
-
function persistAskState(input) {
|
|
129
|
-
saveAskState({
|
|
130
|
-
version: ASK_STATE_VERSION,
|
|
131
|
-
chatUuid: input.chatUuid,
|
|
132
|
-
agentUuid: input.agentUuid,
|
|
133
|
-
updatedAt: new Date().toISOString(),
|
|
134
|
-
});
|
|
135
|
-
}
|
|
136
|
-
/**
|
|
137
|
-
* Take the chat for one local turn.
|
|
138
|
-
*
|
|
139
|
-
* Exported for tests.
|
|
140
|
-
*/
|
|
141
|
-
export async function claimChatTurn(api, chatUuid, ownerId) {
|
|
142
|
-
try {
|
|
143
|
-
await api.ai.chat.claimTurn(chatUuid, { ownerId });
|
|
144
|
-
return { outcome: "claimed" };
|
|
145
|
-
}
|
|
146
|
-
catch (error) {
|
|
147
|
-
if (determineIfIsFetcherError(error) === true &&
|
|
148
|
-
error.status === 409) {
|
|
149
|
-
return { outcome: "busy" };
|
|
150
|
-
}
|
|
151
|
-
// Handed back rather than reported from here. Reporting an API failure
|
|
152
|
-
// ends the process, and the interactive session has a Claude Code process
|
|
153
|
-
// to shut down first — `process.exit` does not run the `finally` that
|
|
154
|
-
// would have done it.
|
|
155
|
-
return { outcome: "unavailable", error };
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
/**
|
|
159
|
-
* Report an API failure the way every other call in the CLI does, and exit.
|
|
160
|
-
* Never returns: `handleApiCall` exits on a recognised API error, and anything
|
|
161
|
-
* else is rethrown for the top-level handler.
|
|
162
|
-
*/
|
|
163
|
-
async function reportApiFailure(error) {
|
|
164
|
-
await handleApiCall(async () => {
|
|
165
|
-
throw error;
|
|
166
|
-
}, { spinner: false });
|
|
167
|
-
throw error;
|
|
168
|
-
}
|
|
169
|
-
/**
|
|
170
|
-
* Hand the chat back after a turn.
|
|
171
|
-
*
|
|
172
|
-
* Recording the turn already does this, but a turn that spent nothing has
|
|
173
|
-
* nothing to record — a stall, an expired token, a workspace out of credits —
|
|
174
|
-
* and the claim would sit there until it expired, telling the next process the
|
|
175
|
-
* chat was busy.
|
|
176
|
-
*
|
|
177
|
-
* Best effort: the TTL is the backstop, and a reply the user has already read
|
|
178
|
-
* must not fail on the way out.
|
|
179
|
-
*/
|
|
180
|
-
async function releaseChatTurn(api, chatUuid, ownerId) {
|
|
181
|
-
try {
|
|
182
|
-
await api.ai.chat.releaseTurn(chatUuid, { ownerId });
|
|
183
|
-
}
|
|
184
|
-
catch {
|
|
185
|
-
// Nothing useful to say to the user about this one.
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
export async function doesChatExist(api, chatUuid) {
|
|
189
|
-
try {
|
|
190
|
-
await api.ai.chat.get(chatUuid);
|
|
191
|
-
return true;
|
|
192
|
-
}
|
|
193
|
-
catch (error) {
|
|
194
|
-
if (determineIfIsFetcherError(error) === true &&
|
|
195
|
-
error.status === 404) {
|
|
196
|
-
return false;
|
|
197
|
-
}
|
|
198
|
-
await handleApiCall(async () => {
|
|
199
|
-
throw error;
|
|
200
|
-
}, { spinner: false });
|
|
201
|
-
return false;
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
/**
|
|
205
|
-
* Body width under `◇ ` / `│ ` / `◆ `. Soft wrap without this drops the
|
|
206
|
-
* rail mid-line and the tree looks broken.
|
|
207
|
-
*/
|
|
208
|
-
function treeBodyWidth() {
|
|
209
|
-
const columns = process.stderr.columns === undefined ? 80 : process.stderr.columns;
|
|
210
|
-
// Marker + two spaces (`◇ `, `│ `, `◆ `), and one column of margin: a
|
|
211
|
-
// line filling the last cell makes some terminals wrap on their own, and the
|
|
212
|
-
// `\n` after it then lands a row further down than the tree expects.
|
|
213
|
-
return Math.max(24, columns - 4);
|
|
214
|
-
}
|
|
215
|
-
/**
|
|
216
|
-
* Greedy wrap on spaces. A word longer than the width keeps its own line
|
|
217
|
-
* rather than being cut mid-token.
|
|
218
|
-
*
|
|
219
|
-
* Exported for tests.
|
|
220
|
-
*/
|
|
221
|
-
export function wrapLine(text, width) {
|
|
222
|
-
const lines = [];
|
|
223
|
-
for (const word of text.split(/\s+/).filter((part) => part.length > 0)) {
|
|
224
|
-
const current = lines[lines.length - 1];
|
|
225
|
-
if (current === undefined || `${current} ${word}`.length > width) {
|
|
226
|
-
lines.push(word);
|
|
227
|
-
continue;
|
|
228
|
-
}
|
|
229
|
-
lines[lines.length - 1] = `${current} ${word}`;
|
|
230
|
-
}
|
|
231
|
-
return lines;
|
|
232
|
-
}
|
|
233
|
-
/** One line under the current ◆/◇, with the │ rail (same indent as clack). */
|
|
234
|
-
function writeRail(body) {
|
|
235
|
-
process.stderr.write(`${colors.dim("│")} ${body}\n`);
|
|
236
|
-
}
|
|
237
|
-
/** Word-wrap onto the │ rail so the terminal never soft-wraps mid-line. */
|
|
238
|
-
function writeRailWrapped(body) {
|
|
239
|
-
// Keep the rail on blank paragraphs so the tree does not break mid-reply.
|
|
240
|
-
if (body === "") {
|
|
241
|
-
writeRail("");
|
|
242
|
-
return;
|
|
243
|
-
}
|
|
244
|
-
const lines = wrapLine(body, treeBodyWidth());
|
|
245
|
-
if (lines.length === 0) {
|
|
246
|
-
writeRail("");
|
|
247
|
-
return;
|
|
248
|
-
}
|
|
249
|
-
for (const line of lines) {
|
|
250
|
-
writeRail(line);
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
/**
|
|
254
|
-
* Light cleanup so model markdown does not fight the tree. Keeps the words;
|
|
255
|
-
* drops heading marks, bold markers, and horizontal rules.
|
|
256
|
-
*/
|
|
257
|
-
function forTerminal(text) {
|
|
258
|
-
return text
|
|
259
|
-
.replace(/^#{1,6}\s+/gm, "")
|
|
260
|
-
.replace(/\*\*([^*]+)\*\*/g, "$1")
|
|
261
|
-
.replace(/^---+$/gm, "");
|
|
262
|
-
}
|
|
263
|
-
/**
|
|
264
|
-
* One finished step: label on the diamond, body on the rail.
|
|
265
|
-
*
|
|
266
|
-
* ◇ Agent
|
|
267
|
-
* │ reply…
|
|
268
|
-
*/
|
|
269
|
-
/**
|
|
270
|
-
* A `◇` step: the first line beside the marker, the rest continuing on the
|
|
271
|
-
* rail under it.
|
|
272
|
-
*
|
|
273
|
-
* Wrapped here rather than left to the terminal. A label long enough to reach
|
|
274
|
-
* the edge — a stall notice, an API error — soft-wraps mid-word and starts its
|
|
275
|
-
* next line at column zero, outside the tree, which is how a one-sentence
|
|
276
|
-
* notice ends up looking like the output is corrupted.
|
|
277
|
-
*/
|
|
278
|
-
function writeDiamondBlock(body) {
|
|
279
|
-
const lines = body === "" ? [""] : wrapLine(body, treeBodyWidth());
|
|
280
|
-
const first = lines[0] === undefined ? "" : lines[0];
|
|
281
|
-
process.stderr.write(`${colors.dim("◇")} ${first}\n`);
|
|
282
|
-
for (const line of lines.slice(1)) {
|
|
283
|
-
writeRail(line);
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
function showStep(label, body) {
|
|
287
|
-
writeDiamondBlock(label);
|
|
288
|
-
for (const paragraph of body.split("\n")) {
|
|
289
|
-
writeRailWrapped(paragraph);
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
/**
|
|
293
|
-
* Slash help / notes — finished step like the palette:
|
|
294
|
-
* ◇ /help
|
|
295
|
-
* │ …
|
|
296
|
-
*/
|
|
297
|
-
function showCard(body) {
|
|
298
|
-
const paragraphs = body.split("\n");
|
|
299
|
-
const title = paragraphs[0] === undefined ? "" : paragraphs[0];
|
|
300
|
-
const rest = paragraphs.slice(1).join("\n");
|
|
301
|
-
showStep(title, rest);
|
|
302
|
-
}
|
|
303
|
-
/**
|
|
304
|
-
* Finished user turn — message on the diamond, like the palette's
|
|
305
|
-
* `◇ ask hey --continue`. No "You" label.
|
|
306
|
-
*/
|
|
307
|
-
function showUserTurn(body) {
|
|
308
|
-
writeDiamondBlock(body);
|
|
309
|
-
}
|
|
310
|
-
/**
|
|
311
|
-
* Display lines for an assistant body. First line is the brick header body;
|
|
312
|
-
* later lines sit on the │ rail. Blank paragraphs keep an empty rail.
|
|
313
|
-
*/
|
|
314
|
-
function assistantDisplayLines(body) {
|
|
315
|
-
const paragraphs = forTerminal(body).split("\n");
|
|
316
|
-
const lines = [];
|
|
317
|
-
for (let index = 0; index < paragraphs.length; index += 1) {
|
|
318
|
-
const paragraph = paragraphs[index];
|
|
319
|
-
if (paragraph === undefined) {
|
|
320
|
-
continue;
|
|
321
|
-
}
|
|
322
|
-
if (paragraph === "") {
|
|
323
|
-
lines.push("");
|
|
324
|
-
continue;
|
|
325
|
-
}
|
|
326
|
-
const wrapped = wrapLine(paragraph, treeBodyWidth());
|
|
327
|
-
if (wrapped.length === 0) {
|
|
328
|
-
lines.push("");
|
|
329
|
-
continue;
|
|
330
|
-
}
|
|
331
|
-
for (const line of wrapped) {
|
|
332
|
-
lines.push(line);
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
|
-
if (lines.length === 0) {
|
|
336
|
-
return [""];
|
|
337
|
-
}
|
|
338
|
-
return lines;
|
|
339
|
-
}
|
|
340
|
-
function writeAssistantLines(lines) {
|
|
341
|
-
const first = lines[0] === undefined ? "" : lines[0];
|
|
342
|
-
process.stderr.write(`${ASSISTANT_MARKER} ${first}\n`);
|
|
343
|
-
for (let index = 1; index < lines.length; index += 1) {
|
|
344
|
-
const line = lines[index];
|
|
345
|
-
if (line !== undefined) {
|
|
346
|
-
writeRail(line);
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
}
|
|
350
|
-
function clearPaintedLines(count) {
|
|
351
|
-
if (count <= 0 || process.stderr.isTTY !== true) {
|
|
352
|
-
return;
|
|
353
|
-
}
|
|
354
|
-
process.stderr.write(`\x1B[${String(count)}A\r\x1B[J`);
|
|
355
|
-
}
|
|
356
|
-
/** Rail gap, then an empty marker line ready for deltas. */
|
|
357
|
-
function beginAssistantTurn() {
|
|
358
|
-
writeRail("");
|
|
359
|
-
process.stderr.write(`${ASSISTANT_MARKER} \n`);
|
|
360
|
-
return { buffer: "", paintedLines: 1, footer: "" };
|
|
361
|
-
}
|
|
362
|
-
function appendAssistantDelta(live, delta) {
|
|
363
|
-
// A tool between two pieces of narration inserts a paragraph break, so that
|
|
364
|
-
// "Let me look." and what it found do not run together. When the tool also
|
|
365
|
-
// closed the block on screen, that break arrives as the first thing in the
|
|
366
|
-
// *next* block — and renders as an empty rail line under the marker before a
|
|
367
|
-
// word has been said. Kept in the stored answer, where the paragraphs still
|
|
368
|
-
// belong; dropped here, where the block itself is already the separation.
|
|
369
|
-
const text = live.buffer === "" ? delta.replace(/^\n+/, "") : delta;
|
|
370
|
-
if (text === "") {
|
|
371
|
-
return;
|
|
372
|
-
}
|
|
373
|
-
live.buffer += text;
|
|
374
|
-
repaintAssistantTurn(live);
|
|
375
|
-
}
|
|
376
|
-
/** Draw the block again from its buffer, with the typed line under it. */
|
|
377
|
-
function repaintAssistantTurn(live) {
|
|
378
|
-
const lines = assistantDisplayLines(live.buffer);
|
|
379
|
-
clearPaintedLines(live.paintedLines);
|
|
380
|
-
writeAssistantLines(lines);
|
|
381
|
-
live.paintedLines = lines.length;
|
|
382
|
-
if (live.footer !== "") {
|
|
383
|
-
// Clipped for the same reason the spinner's is: it has to occupy exactly
|
|
384
|
-
// one row, or the next repaint clears from the wrong line.
|
|
385
|
-
process.stderr.write(`${colors.dim(clipToWidth(live.footer))}\n`);
|
|
386
|
-
live.paintedLines += 1;
|
|
387
|
-
}
|
|
388
|
-
}
|
|
389
|
-
/** Show, change or remove the question being typed over this reply. */
|
|
390
|
-
function setAssistantFooter(live, footer) {
|
|
391
|
-
if (live.footer === footer) {
|
|
392
|
-
return;
|
|
393
|
-
}
|
|
394
|
-
live.footer = footer;
|
|
395
|
-
repaintAssistantTurn(live);
|
|
396
|
-
}
|
|
397
|
-
/** Trailing rail gap before the next user turn / ◆ prompt. */
|
|
398
|
-
function endAssistantTurn(live) {
|
|
399
|
-
// The typed line belongs to whatever draws next — the spinner, or the block
|
|
400
|
-
// after this one. Left here it would be printed twice.
|
|
401
|
-
setAssistantFooter(live, "");
|
|
402
|
-
writeRail("");
|
|
403
|
-
}
|
|
404
|
-
/**
|
|
405
|
-
* Finished assistant turn — same tree as a user step, with `■` instead of `◇`.
|
|
406
|
-
*/
|
|
407
|
-
function showAssistantTurn(body) {
|
|
408
|
-
const live = beginAssistantTurn();
|
|
409
|
-
appendAssistantDelta(live, body);
|
|
410
|
-
endAssistantTurn(live);
|
|
411
|
-
}
|
|
412
|
-
/**
|
|
413
|
-
* Ctrl-C during a turn cancels the turn, not the CLI: the harness interrupts
|
|
414
|
-
* Claude Code, the tree says so, and the ◆ prompt comes back. A second Ctrl-C
|
|
415
|
-
* while the first is still settling exits for real.
|
|
416
|
-
*
|
|
417
|
-
* Except once the turn has reached teardown, where exiting is the one thing
|
|
418
|
-
* that must not happen: `process.exit` skips the `finally` that shuts Claude
|
|
419
|
-
* Code down, so an impatient second press — the natural reaction to a few
|
|
420
|
-
* seconds of apparently nothing — is exactly what strands the process this
|
|
421
|
-
* teardown exists to stop. Presses are ignored for the length of it, which is
|
|
422
|
-
* bounded: `close` gives up after `SESSION_DESTROY_TIMEOUT_MS` either way.
|
|
423
|
-
*/
|
|
424
|
-
function beginCancellableTurn(onCancel) {
|
|
425
|
-
const controller = new AbortController();
|
|
426
|
-
const state = { cancelled: false, tearingDown: false };
|
|
427
|
-
const handleInterrupt = () => {
|
|
428
|
-
if (state.tearingDown === true) {
|
|
429
|
-
return;
|
|
430
|
-
}
|
|
431
|
-
if (state.cancelled === true) {
|
|
432
|
-
// 130 is the conventional "terminated by SIGINT" status.
|
|
433
|
-
process.exit(130);
|
|
434
|
-
}
|
|
435
|
-
state.cancelled = true;
|
|
436
|
-
onCancel();
|
|
437
|
-
controller.abort();
|
|
438
|
-
};
|
|
439
|
-
process.on("SIGINT", handleInterrupt);
|
|
440
|
-
return {
|
|
441
|
-
signal: controller.signal,
|
|
442
|
-
wasCancelled: () => state.cancelled,
|
|
443
|
-
abort: () => {
|
|
444
|
-
controller.abort();
|
|
445
|
-
},
|
|
446
|
-
beginTeardown: () => {
|
|
447
|
-
state.tearingDown = true;
|
|
448
|
-
},
|
|
449
|
-
interrupt: handleInterrupt,
|
|
450
|
-
release: () => {
|
|
451
|
-
process.removeListener("SIGINT", handleInterrupt);
|
|
452
|
-
},
|
|
453
|
-
};
|
|
454
|
-
}
|
|
455
|
-
/**
|
|
456
|
-
* Let the next question be typed while this one is still being answered.
|
|
457
|
-
*
|
|
458
|
-
* The collector owns the terminal for the length of the turn; the spinner
|
|
459
|
-
* shows what has been typed so far, because raw mode means the terminal is no
|
|
460
|
-
* longer echoing it.
|
|
461
|
-
*
|
|
462
|
-
* Degrades to no type-ahead rather than failing when there is no terminal to
|
|
463
|
-
* read — `--print` and the non-interactive paths never get here, but a session
|
|
464
|
-
* whose stdin went away should still finish the turn it is running.
|
|
465
|
-
*/
|
|
466
|
-
function beginTurnTyping(input) {
|
|
467
|
-
const terminal = openPromptInput();
|
|
468
|
-
if (terminal === undefined) {
|
|
469
|
-
return {
|
|
470
|
-
pause: async () => undefined,
|
|
471
|
-
resume: () => undefined,
|
|
472
|
-
finish: () => EMPTY_TYPE_AHEAD,
|
|
473
|
-
};
|
|
474
|
-
}
|
|
475
|
-
const collector = collectTypeAhead({
|
|
476
|
-
stream: terminal.stream,
|
|
477
|
-
initialLine: input.carriedLine,
|
|
478
|
-
onInterrupt: input.onInterrupt,
|
|
479
|
-
onChange: input.onTyped,
|
|
480
|
-
});
|
|
481
|
-
input.onTyped(input.carriedLine);
|
|
482
|
-
return {
|
|
483
|
-
pause: collector.pause,
|
|
484
|
-
resume: () => {
|
|
485
|
-
collector.resume();
|
|
486
|
-
// The prompt that borrowed the terminal drew over the line; put it back.
|
|
487
|
-
input.onTyped(collector.current());
|
|
488
|
-
},
|
|
489
|
-
finish: () => {
|
|
490
|
-
const typed = collector.stop();
|
|
491
|
-
input.onTyped("");
|
|
492
|
-
terminal.close();
|
|
493
|
-
return typed;
|
|
494
|
-
},
|
|
495
|
-
};
|
|
496
|
-
}
|
|
497
|
-
/**
|
|
498
|
-
* Stop a remote turn the user walked away from.
|
|
499
|
-
*
|
|
500
|
-
* Best effort: the uuid is only known once the server has announced the
|
|
501
|
-
* message, and a failed cancel must not take the chat session down with it —
|
|
502
|
-
* the next turn reports `lastMessageNotFinished` if this did not land.
|
|
503
|
-
*/
|
|
504
|
-
async function cancelRemoteMessage(api, messageUuid) {
|
|
505
|
-
if (messageUuid === undefined) {
|
|
506
|
-
return;
|
|
507
|
-
}
|
|
508
|
-
try {
|
|
509
|
-
await api.ai.message.cancel({ uuids: [messageUuid] });
|
|
510
|
-
}
|
|
511
|
-
catch {
|
|
512
|
-
// Nothing useful to do here; surfacing it would bury the "Cancelled." card.
|
|
513
|
-
}
|
|
514
|
-
}
|
|
515
|
-
/** Common tool names arrive lowercase (`bash`); natives keep their case. */
|
|
516
|
-
function toolLabel(toolName) {
|
|
517
|
-
if (toolName.length === 0) {
|
|
518
|
-
return "tool";
|
|
519
|
-
}
|
|
520
|
-
return `${toolName.slice(0, 1).toUpperCase()}${toolName.slice(1)}`;
|
|
521
|
-
}
|
|
522
|
-
const ACTIVITY_MAX_LENGTH = 72;
|
|
523
|
-
function describeToolActivity(toolName, summary) {
|
|
524
|
-
const label = toolLabel(toolName);
|
|
525
|
-
if (summary === "") {
|
|
526
|
-
return label;
|
|
527
|
-
}
|
|
528
|
-
const line = `${label}: ${summary}`;
|
|
529
|
-
return line.length > ACTIVITY_MAX_LENGTH
|
|
530
|
-
? `${line.slice(0, ACTIVITY_MAX_LENGTH - 1)}…`
|
|
531
|
-
: line;
|
|
532
|
-
}
|
|
533
|
-
/**
|
|
534
|
-
* A tool that has finished, left in the transcript.
|
|
535
|
-
*
|
|
536
|
-
* The spinner shows what is running, but it is one line that is overwritten
|
|
537
|
-
* and then cleared, so once a turn is over there is no record of what it did —
|
|
538
|
-
* only the prose describing it. A turn that read four files and ran two
|
|
539
|
-
* commands should say so afterwards, the way the approval prompt does for the
|
|
540
|
-
* tools it happened to ask about.
|
|
541
|
-
*
|
|
542
|
-
* Written through the spinner's `log` when one is up, which wipes the spinner
|
|
543
|
-
* line, leaves this behind, and lets the next frame redraw underneath.
|
|
544
|
-
*/
|
|
545
|
-
function showToolRun(input) {
|
|
546
|
-
const marker = input.isError === true ? colors.red("✗") : colors.dim(TOOL_MARKER);
|
|
547
|
-
const label = describeToolActivity(input.toolName, input.summary);
|
|
548
|
-
const line = `${marker} ${colors.dim(label)}`;
|
|
549
|
-
if (input.spinner !== undefined) {
|
|
550
|
-
input.spinner.log(line);
|
|
551
|
-
return;
|
|
552
|
-
}
|
|
553
|
-
process.stderr.write(`${line}\n`);
|
|
554
|
-
}
|
|
555
|
-
/**
|
|
556
|
-
* A path inside the session directory reads better relative to it; anything
|
|
557
|
-
* outside stays absolute so it is obvious the turn left the project.
|
|
558
|
-
*
|
|
559
|
-
* Exported for tests.
|
|
560
|
-
*/
|
|
561
|
-
export function displayPath(path, dir) {
|
|
562
|
-
const relativePath = relative(dir, path);
|
|
563
|
-
return relativePath === "" ||
|
|
564
|
-
relativePath.startsWith("..") === true ||
|
|
565
|
-
isAbsolute(relativePath) === true
|
|
566
|
-
? path
|
|
567
|
-
: relativePath;
|
|
568
|
-
}
|
|
569
|
-
/**
|
|
570
|
-
* Closing step for a turn that edited the working copy, so a reply that says
|
|
571
|
-
* "done" is backed by the list of files it touched.
|
|
572
|
-
*/
|
|
573
|
-
function showTouchedFiles(paths, dir) {
|
|
574
|
-
if (paths.size === 0) {
|
|
575
|
-
return;
|
|
576
|
-
}
|
|
577
|
-
const listed = [...paths].map((path) => displayPath(path, dir)).sort();
|
|
578
|
-
showStep(`${String(listed.length)} file${listed.length === 1 ? "" : "s"} changed`, listed.join("\n"));
|
|
579
|
-
}
|
|
580
|
-
/**
|
|
581
|
-
* Side notes from a turn — a chat-history save that failed, say. They are not
|
|
582
|
-
* part of the reply, so they land after it rather than inside it.
|
|
583
|
-
*/
|
|
584
|
-
function showNotices(notices) {
|
|
585
|
-
while (notices.length > 0) {
|
|
586
|
-
const notice = notices.shift();
|
|
587
|
-
if (notice !== undefined) {
|
|
588
|
-
showCard(notice);
|
|
589
|
-
}
|
|
590
|
-
}
|
|
591
|
-
}
|
|
592
|
-
/**
|
|
593
|
-
* Local Claude Code runs against the user's own working copy, so anything
|
|
594
|
-
* beyond a read is confirmed before it happens:
|
|
595
|
-
*
|
|
596
|
-
* ◆ Run Bash: npm test?
|
|
597
|
-
* │ ● Yes …
|
|
598
|
-
*/
|
|
599
|
-
async function askToolApproval(approval) {
|
|
600
|
-
const decision = await selectOne({
|
|
601
|
-
message: approval.summary === ""
|
|
602
|
-
? `Run ${approval.toolName}?`
|
|
603
|
-
: `Run ${approval.toolName}: ${approval.summary}?`,
|
|
604
|
-
recommended: "yes",
|
|
605
|
-
// Escape here means "not that tool", not "leave the chat" — the turn
|
|
606
|
-
// continues and the model reports what it could not do.
|
|
607
|
-
cancelValue: "no",
|
|
608
|
-
choices: [
|
|
609
|
-
{ value: "yes", label: "Yes", hint: "just this once" },
|
|
610
|
-
{ value: "always", label: "Yes to everything", hint: "for this chat" },
|
|
611
|
-
{ value: "no", label: "No", hint: "tell the model to skip it" },
|
|
612
|
-
],
|
|
613
|
-
});
|
|
614
|
-
return decision === "always" ? "always" : decision === "no" ? "no" : "yes";
|
|
615
|
-
}
|
|
616
|
-
/**
|
|
617
|
-
* Live prompt: `◆ ` with an as-you-type suggestion menu for `/…` commands
|
|
618
|
-
* and path-like tokens. Empty Enter leaves. A first message from the palette
|
|
619
|
-
* is written as a finished user turn, then sent.
|
|
620
|
-
*/
|
|
621
|
-
async function readQuestion(firstMessage, dir,
|
|
622
|
-
/** Typed while the last turn was still answering, and not sent as its own. */
|
|
623
|
-
typedAhead = "") {
|
|
624
|
-
if (firstMessage !== undefined) {
|
|
625
|
-
showUserTurn(firstMessage);
|
|
626
|
-
return firstMessage;
|
|
627
|
-
}
|
|
628
|
-
const input = openPromptInput();
|
|
629
|
-
if (input === undefined) {
|
|
630
|
-
failWith("Ask needs a terminal. Re-run with --print for one JSON turn.", {
|
|
631
|
-
code: ExitCodes.InvalidUsage,
|
|
632
|
-
});
|
|
633
|
-
}
|
|
634
|
-
try {
|
|
635
|
-
const answer = await readAskLine(input.stream, dir, typedAhead);
|
|
636
|
-
if (answer === undefined) {
|
|
637
|
-
return undefined;
|
|
638
|
-
}
|
|
639
|
-
showUserTurn(answer);
|
|
640
|
-
return answer;
|
|
641
|
-
}
|
|
642
|
-
finally {
|
|
643
|
-
input.close();
|
|
644
|
-
}
|
|
645
|
-
}
|
|
646
|
-
/**
|
|
647
|
-
* Where the next question comes from, once type-ahead is in the picture.
|
|
648
|
-
*
|
|
649
|
-
* A line the user finished with Enter during the last reply is already their
|
|
650
|
-
* next question, so it is sent rather than parked at the prompt — the way it
|
|
651
|
-
* would have been had they waited. Anything typed after that Enter, finished
|
|
652
|
-
* or not, is carried into the turn it kicks off so it survives to the prompt
|
|
653
|
-
* after that: one question can be queued behind a reply, not a series of them.
|
|
654
|
-
* An unfinished line with no Enter simply opens the prompt with itself in
|
|
655
|
-
* place.
|
|
656
|
-
*/
|
|
657
|
-
async function nextQuestion(input) {
|
|
658
|
-
const { queued } = input;
|
|
659
|
-
const pending = `${input.carried}${queued.pending}`;
|
|
660
|
-
if (queued.submitted !== undefined) {
|
|
661
|
-
return {
|
|
662
|
-
question: await readQuestion(queued.submitted, input.dir),
|
|
663
|
-
carriedLine: pending,
|
|
664
|
-
};
|
|
665
|
-
}
|
|
666
|
-
return {
|
|
667
|
-
// Consumed by the prompt, so nothing is carried past it.
|
|
668
|
-
question: await readQuestion(input.firstMessage, input.dir, pending),
|
|
669
|
-
carriedLine: "",
|
|
670
|
-
};
|
|
671
|
-
}
|
|
672
|
-
/**
|
|
673
|
-
* Same tree grammar as the palette:
|
|
674
|
-
* ◇ <your message>
|
|
675
|
-
* ■ reply…
|
|
676
|
-
* ◆
|
|
677
|
-
* Empty Enter or /exit leaves to the parent palette.
|
|
678
|
-
*/
|
|
679
|
-
export async function runAskSession(options) {
|
|
680
|
-
requireInteractive();
|
|
681
|
-
const config = getConfig();
|
|
682
|
-
// An API token from CARGO_API_TOKEN is workspace-bound on the server, so
|
|
683
|
-
// config.workspaceUuid can be unset — same as whoami / every other command.
|
|
684
|
-
if (config.getAccessToken === undefined) {
|
|
685
|
-
failWith("Not authenticated. Run 'cargo-ai login --email <email>' or 'cargo-ai login --oauth'.", { code: ExitCodes.NotAuthenticated });
|
|
686
|
-
}
|
|
687
|
-
let agent = options.agentUuid !== undefined
|
|
688
|
-
? await loadAgent(options.api, options.agentUuid)
|
|
689
|
-
: undefined;
|
|
690
|
-
let chatUuid = options.chatUuid !== undefined ? options.chatUuid : randomUUID();
|
|
691
|
-
let chatCreated = false;
|
|
692
|
-
let pendingFirst = options.message;
|
|
693
|
-
let local;
|
|
694
|
-
// The proxy token is fixed in the Claude Code process env at spawn, so the
|
|
695
|
-
// only way to refresh it is to rebuild the harness. These two say when that
|
|
696
|
-
// becomes necessary; see `proxyTokenDeadlineMs`.
|
|
697
|
-
let localTokenMintedAtMs = 0;
|
|
698
|
-
let localTokenTtlMs = 0;
|
|
699
|
-
// Identifies this process to the server's per-chat turn claim, so our own
|
|
700
|
-
// next turn renews it rather than colliding with it.
|
|
701
|
-
const sessionId = randomUUID();
|
|
702
|
-
// "Yes to everything" lasts until the chat is reset, never on disk.
|
|
703
|
-
let approveEveryTool = false;
|
|
704
|
-
// Typed while the last turn was still answering: a finished line goes
|
|
705
|
-
// straight through as the next question, an unfinished one opens the prompt
|
|
706
|
-
// with it already in place.
|
|
707
|
-
let typedAhead = EMPTY_TYPE_AHEAD;
|
|
708
|
-
// The tail of a type-ahead whose first line was submitted, waiting for the
|
|
709
|
-
// next turn's collector to pick up where it left off.
|
|
710
|
-
let carriedLine = "";
|
|
711
|
-
if (options.continueChat === true && options.chatUuid !== undefined) {
|
|
712
|
-
chatCreated = await doesChatExist(options.api, options.chatUuid);
|
|
713
|
-
}
|
|
714
|
-
try {
|
|
715
|
-
for (;;) {
|
|
716
|
-
const mode = modeForAgent(agent);
|
|
717
|
-
const next = await nextQuestion({
|
|
718
|
-
queued: typedAhead,
|
|
719
|
-
carried: carriedLine,
|
|
720
|
-
firstMessage: pendingFirst,
|
|
721
|
-
dir: options.dir,
|
|
722
|
-
});
|
|
723
|
-
typedAhead = EMPTY_TYPE_AHEAD;
|
|
724
|
-
carriedLine = next.carriedLine;
|
|
725
|
-
pendingFirst = undefined;
|
|
726
|
-
const { question } = next;
|
|
727
|
-
if (question === undefined) {
|
|
728
|
-
return "palette";
|
|
729
|
-
}
|
|
730
|
-
const slash = parseSlash(question);
|
|
731
|
-
if (slash !== undefined) {
|
|
732
|
-
if (slash.kind === "card") {
|
|
733
|
-
showCard(slash.text);
|
|
734
|
-
continue;
|
|
735
|
-
}
|
|
736
|
-
if (slash.action === "exit") {
|
|
737
|
-
return "palette";
|
|
738
|
-
}
|
|
739
|
-
if (slash.action === "cancel") {
|
|
740
|
-
showCard("Nothing is running — this prompt only appears between turns. Press Ctrl-C while a turn is in flight to stop it.");
|
|
741
|
-
continue;
|
|
742
|
-
}
|
|
743
|
-
if (slash.action === "new" || slash.action === "clear") {
|
|
744
|
-
if (local !== undefined) {
|
|
745
|
-
await local.close();
|
|
746
|
-
local = undefined;
|
|
747
|
-
}
|
|
748
|
-
// Fresh session returns to the local default (no agent).
|
|
749
|
-
agent = undefined;
|
|
750
|
-
chatUuid = randomUUID();
|
|
751
|
-
chatCreated = false;
|
|
752
|
-
approveEveryTool = false;
|
|
753
|
-
showCard("Started a fresh chat.");
|
|
754
|
-
continue;
|
|
755
|
-
}
|
|
756
|
-
if (slash.action === "pick-agent") {
|
|
757
|
-
if (local !== undefined) {
|
|
758
|
-
await local.close();
|
|
759
|
-
local = undefined;
|
|
760
|
-
}
|
|
761
|
-
const nextUuid = await pickAgent(options.api, agent === undefined ? undefined : agent.uuid);
|
|
762
|
-
agent =
|
|
763
|
-
nextUuid === undefined
|
|
764
|
-
? undefined
|
|
765
|
-
: await loadAgent(options.api, nextUuid);
|
|
766
|
-
// A blanket "yes" was given to one agent's local session, not to
|
|
767
|
-
// whatever the next one wants to run.
|
|
768
|
-
approveEveryTool = false;
|
|
769
|
-
continue;
|
|
770
|
-
}
|
|
771
|
-
}
|
|
772
|
-
if (mode.kind === "local") {
|
|
773
|
-
if (chatCreated === false) {
|
|
774
|
-
await ensureChat({
|
|
775
|
-
api: options.api,
|
|
776
|
-
chatUuid,
|
|
777
|
-
agentUuid: agent === undefined ? undefined : agent.uuid,
|
|
778
|
-
created: false,
|
|
779
|
-
});
|
|
780
|
-
chatCreated = true;
|
|
781
|
-
}
|
|
782
|
-
// A session left at the prompt long enough for its token to lapse
|
|
783
|
-
// would send the next turn with a dead key, which surfaces as an
|
|
784
|
-
// authentication failure from the proxy rather than anything the user
|
|
785
|
-
// can act on. Rebuilding costs a cold start and a history replay, so
|
|
786
|
-
// it happens only once the token really is past saving.
|
|
787
|
-
if (local !== undefined &&
|
|
788
|
-
Date.now() >=
|
|
789
|
-
proxyTokenDeadlineMs({
|
|
790
|
-
mintedAtMs: localTokenMintedAtMs,
|
|
791
|
-
lastUsedAtMs: local.proxyLastUsedAtMs(),
|
|
792
|
-
ttlMs: localTokenTtlMs,
|
|
793
|
-
})) {
|
|
794
|
-
await local.close();
|
|
795
|
-
local = undefined;
|
|
796
|
-
}
|
|
797
|
-
if (local === undefined) {
|
|
798
|
-
// Nothing has been drawn since the question was echoed, and the
|
|
799
|
-
// Claude Code that has to answer it is not running yet: on a cold
|
|
800
|
-
// config directory that is ten to fifteen seconds of a blank tree,
|
|
801
|
-
// which reads as the CLI having hung on the first thing it was ever
|
|
802
|
-
// asked. Warm, this comes and goes inside a fifth of a second.
|
|
803
|
-
//
|
|
804
|
-
// Retired by `startSpinner` when the turn starts its own, and by the
|
|
805
|
-
// `finally` on the path where the setup throws instead.
|
|
806
|
-
const starting = startSpinner("Starting the coding session…");
|
|
807
|
-
try {
|
|
808
|
-
const minted = await handleApiCall(() => options.api.ai.chat.mintHarnessProxyToken(chatUuid), { spinner: false });
|
|
809
|
-
localTokenMintedAtMs = Date.now();
|
|
810
|
-
localTokenTtlMs = minted.expiresInSeconds * 1000;
|
|
811
|
-
// A new Claude Code session knows nothing of this chat, whether it
|
|
812
|
-
// is being continued from an earlier run or was reset by /agent.
|
|
813
|
-
const history = await loadChatHistoryPreamble({
|
|
814
|
-
api: options.api,
|
|
815
|
-
chatUuid,
|
|
816
|
-
});
|
|
817
|
-
local = await createLocalHarness({
|
|
818
|
-
api: options.api,
|
|
819
|
-
apiBaseUrl: config.baseUrl,
|
|
820
|
-
dir: options.dir,
|
|
821
|
-
chatUuid,
|
|
822
|
-
agentUuid: agent === undefined ? undefined : agent.uuid,
|
|
823
|
-
proxyToken: minted.token,
|
|
824
|
-
history,
|
|
825
|
-
turnOwnerId: sessionId,
|
|
826
|
-
});
|
|
827
|
-
}
|
|
828
|
-
finally {
|
|
829
|
-
starting.stop();
|
|
830
|
-
}
|
|
831
|
-
}
|
|
832
|
-
// Nothing about a local turn is visible to the server until it is
|
|
833
|
-
// ingested, so the chat reads as idle while Claude Code works and a
|
|
834
|
-
// second session resuming it would spend alongside this one.
|
|
835
|
-
//
|
|
836
|
-
// Taken here rather than before the setup above: the claim is only
|
|
837
|
-
// released by a finished turn, so holding it across a mint or a
|
|
838
|
-
// harness build that then fails would block the chat for the whole
|
|
839
|
-
// TTL. Nothing in the setup needs protecting — mint hands back the
|
|
840
|
-
// token already in play rather than replacing it.
|
|
841
|
-
const claim = await claimChatTurn(options.api, chatUuid, sessionId);
|
|
842
|
-
if (claim.outcome === "busy") {
|
|
843
|
-
showCard("Another session is already running a turn in this chat. Wait for it to finish, or /new to start a fresh one.");
|
|
844
|
-
continue;
|
|
845
|
-
}
|
|
846
|
-
if (claim.outcome === "unavailable") {
|
|
847
|
-
// Reporting this ends the process, and the harness is already
|
|
848
|
-
// running — shut it down here, because `process.exit` skips the
|
|
849
|
-
// `finally` below that would otherwise have done it. Left assigned
|
|
850
|
-
// so the `finally` still closes it on the one path that unwinds
|
|
851
|
-
// instead of exiting; closing twice is harmless.
|
|
852
|
-
await local.close();
|
|
853
|
-
await reportApiFailure(claim.error);
|
|
854
|
-
}
|
|
855
|
-
let spinner;
|
|
856
|
-
let live;
|
|
857
|
-
// A tool interrupting the narration closes the live block, so `live`
|
|
858
|
-
// alone cannot tell whether the turn ever said anything.
|
|
859
|
-
let saidSomething = false;
|
|
860
|
-
const touchedFiles = new Set();
|
|
861
|
-
const notices = [];
|
|
862
|
-
// Reads and Cargo MCP tools are batched; the first one to finish must
|
|
863
|
-
// not wipe the spinner back to a thinking word while siblings run.
|
|
864
|
-
const activeTools = [];
|
|
865
|
-
// What the user has typed into this turn so far, drawn under the
|
|
866
|
-
// spinner. It lives here rather than on the spinner because a turn
|
|
867
|
-
// stops and restarts several of those — for an approval, for each tool
|
|
868
|
-
// label — and the line has to outlive all of them.
|
|
869
|
-
let typedLine = "";
|
|
870
|
-
// Plain text: it is clipped to the terminal width, and slicing a
|
|
871
|
-
// styled string would cut an escape sequence in half.
|
|
872
|
-
//
|
|
873
|
-
// Drawn by whichever of the two owns the screen. A reply streaming in
|
|
874
|
-
// has replaced the spinner with a live block, and that is exactly when
|
|
875
|
-
// someone is most likely to be typing over it — routing this to the
|
|
876
|
-
// spinner alone leaves the keyboard working and nothing showing for it.
|
|
877
|
-
const paintTyped = () => {
|
|
878
|
-
const footer = typedLine === "" ? "" : `◆ ${typedLine}`;
|
|
879
|
-
if (live !== undefined) {
|
|
880
|
-
setAssistantFooter(live, footer);
|
|
881
|
-
return;
|
|
882
|
-
}
|
|
883
|
-
if (spinner !== undefined) {
|
|
884
|
-
spinner.footer(footer);
|
|
885
|
-
}
|
|
886
|
-
};
|
|
887
|
-
const stopSpinner = () => {
|
|
888
|
-
if (spinner !== undefined) {
|
|
889
|
-
spinner.stop();
|
|
890
|
-
spinner = undefined;
|
|
891
|
-
}
|
|
892
|
-
};
|
|
893
|
-
const showSpinner = (label) => {
|
|
894
|
-
if (spinner === undefined) {
|
|
895
|
-
spinner = startSpinner(label);
|
|
896
|
-
}
|
|
897
|
-
else {
|
|
898
|
-
spinner.update(label);
|
|
899
|
-
}
|
|
900
|
-
paintTyped();
|
|
901
|
-
};
|
|
902
|
-
// A fresh word each time the turn goes back to thinking, so a long
|
|
903
|
-
// turn visibly moves through its steps instead of sitting on one label
|
|
904
|
-
// from the first second to the last. Held between draws — the spinner
|
|
905
|
-
// is repainted on every tool start and finish, and re-rolling there
|
|
906
|
-
// would make the word flicker rather than mark progress.
|
|
907
|
-
let thinkingWord = pickThinkingWord();
|
|
908
|
-
let wasRunningTool = false;
|
|
909
|
-
// The most recent tool labels the spinner. Nothing running means the
|
|
910
|
-
// turn is thinking rather than working.
|
|
911
|
-
const showActiveToolSpinner = () => {
|
|
912
|
-
const current = activeTools[activeTools.length - 1];
|
|
913
|
-
if (current === undefined) {
|
|
914
|
-
if (wasRunningTool === true) {
|
|
915
|
-
thinkingWord = pickThinkingWord(thinkingWord);
|
|
916
|
-
wasRunningTool = false;
|
|
917
|
-
}
|
|
918
|
-
showSpinner(thinkingWord);
|
|
919
|
-
return;
|
|
920
|
-
}
|
|
921
|
-
wasRunningTool = true;
|
|
922
|
-
showSpinner(describeToolActivity(current.toolName, current.summary));
|
|
923
|
-
};
|
|
924
|
-
// A tool is announced twice — once when its name arrives, again with a
|
|
925
|
-
// summary once its input is known — so the second has to overwrite the
|
|
926
|
-
// first rather than stack a duplicate label.
|
|
927
|
-
const rememberTool = (tool) => {
|
|
928
|
-
const at = activeTools.findIndex((active) => active.toolCallId === tool.toolCallId);
|
|
929
|
-
if (at === -1) {
|
|
930
|
-
activeTools.push(tool);
|
|
931
|
-
}
|
|
932
|
-
else {
|
|
933
|
-
activeTools[at] = tool;
|
|
934
|
-
}
|
|
935
|
-
showActiveToolSpinner();
|
|
936
|
-
};
|
|
937
|
-
const forgetTool = (toolCallId) => {
|
|
938
|
-
const at = activeTools.findIndex((active) => active.toolCallId === toolCallId);
|
|
939
|
-
if (at !== -1) {
|
|
940
|
-
activeTools.splice(at, 1);
|
|
941
|
-
}
|
|
942
|
-
showActiveToolSpinner();
|
|
943
|
-
};
|
|
944
|
-
// Registered before the spinner so our SIGINT handler runs first and
|
|
945
|
-
// clack's spinner is already stopped when its own handler fires —
|
|
946
|
-
// otherwise Ctrl-C prints its cancel line under ours.
|
|
947
|
-
const cancellation = beginCancellableTurn(stopSpinner);
|
|
948
|
-
spinner = startSpinner(thinkingWord);
|
|
949
|
-
const typing = beginTurnTyping({
|
|
950
|
-
carriedLine,
|
|
951
|
-
onInterrupt: () => cancellation.interrupt(),
|
|
952
|
-
onTyped: (text) => {
|
|
953
|
-
typedLine = text;
|
|
954
|
-
paintTyped();
|
|
955
|
-
},
|
|
956
|
-
});
|
|
957
|
-
carriedLine = "";
|
|
958
|
-
try {
|
|
959
|
-
await local.streamPrompt(question, {
|
|
960
|
-
abortSignal: cancellation.signal,
|
|
961
|
-
onStall: () => {
|
|
962
|
-
cancellation.abort();
|
|
963
|
-
},
|
|
964
|
-
onTextDelta: (delta) => {
|
|
965
|
-
if (live === undefined) {
|
|
966
|
-
stopSpinner();
|
|
967
|
-
live = beginAssistantTurn();
|
|
968
|
-
// The spinner was carrying the typed line; it goes with it.
|
|
969
|
-
paintTyped();
|
|
970
|
-
}
|
|
971
|
-
saidSomething = true;
|
|
972
|
-
appendAssistantDelta(live, delta);
|
|
973
|
-
},
|
|
974
|
-
onActivity: (activity) => {
|
|
975
|
-
// Tool work replaces the spinner text so a long Bash step is
|
|
976
|
-
// not an unexplained pause. Claude Code narrates before it acts,
|
|
977
|
-
// so a tool usually starts with live text on screen: close that
|
|
978
|
-
// block first, and the next delta opens a new one.
|
|
979
|
-
//
|
|
980
|
-
// "dropped" is a tool the harness announced and then abandoned.
|
|
981
|
-
// It has no result coming and it never ran, so it is forgotten
|
|
982
|
-
// without a line — the transcript should hold what happened, not
|
|
983
|
-
// what was briefly considered.
|
|
984
|
-
if (activity.kind === "dropped") {
|
|
985
|
-
forgetTool(activity.toolCallId);
|
|
986
|
-
return;
|
|
987
|
-
}
|
|
988
|
-
if (activity.kind === "end") {
|
|
989
|
-
// Read back before forgetting: only the remembered call knows
|
|
990
|
-
// the input, and `end` carries the name and the outcome alone.
|
|
991
|
-
const finished = activeTools.find((active) => active.toolCallId === activity.toolCallId);
|
|
992
|
-
showToolRun({
|
|
993
|
-
spinner,
|
|
994
|
-
toolName: activity.toolName,
|
|
995
|
-
summary: finished === undefined ? "" : finished.summary,
|
|
996
|
-
isError: activity.isError,
|
|
997
|
-
});
|
|
998
|
-
forgetTool(activity.toolCallId);
|
|
999
|
-
return;
|
|
1000
|
-
}
|
|
1001
|
-
if (live !== undefined) {
|
|
1002
|
-
endAssistantTurn(live);
|
|
1003
|
-
live = undefined;
|
|
1004
|
-
}
|
|
1005
|
-
rememberTool({
|
|
1006
|
-
toolCallId: activity.toolCallId,
|
|
1007
|
-
toolName: activity.toolName,
|
|
1008
|
-
summary: activity.summary,
|
|
1009
|
-
});
|
|
1010
|
-
},
|
|
1011
|
-
onFileTouched: (path) => {
|
|
1012
|
-
touchedFiles.add(path);
|
|
1013
|
-
},
|
|
1014
|
-
onNotice: (message) => {
|
|
1015
|
-
notices.push(message);
|
|
1016
|
-
},
|
|
1017
|
-
onApproval: async (approval) => {
|
|
1018
|
-
// Close whatever is on screen: the prompt owns the line next.
|
|
1019
|
-
if (live !== undefined) {
|
|
1020
|
-
endAssistantTurn(live);
|
|
1021
|
-
live = undefined;
|
|
1022
|
-
}
|
|
1023
|
-
stopSpinner();
|
|
1024
|
-
let decision;
|
|
1025
|
-
if (approveEveryTool === true) {
|
|
1026
|
-
decision = "always";
|
|
1027
|
-
}
|
|
1028
|
-
else {
|
|
1029
|
-
// The picker reads the keyboard itself, so the type-ahead
|
|
1030
|
-
// collector has to let go of it — otherwise it swallows the
|
|
1031
|
-
// arrow keys and the approval can never be answered. Awaited,
|
|
1032
|
-
// because letting go settles first: the picker appears under
|
|
1033
|
-
// whatever the user is already typing and defaults to Yes, so
|
|
1034
|
-
// a keystroke in flight must not be able to answer it.
|
|
1035
|
-
await typing.pause();
|
|
1036
|
-
try {
|
|
1037
|
-
decision = await askToolApproval(approval);
|
|
1038
|
-
}
|
|
1039
|
-
finally {
|
|
1040
|
-
typing.resume();
|
|
1041
|
-
}
|
|
1042
|
-
}
|
|
1043
|
-
if (decision === "always") {
|
|
1044
|
-
approveEveryTool = true;
|
|
1045
|
-
}
|
|
1046
|
-
if (decision === "no") {
|
|
1047
|
-
forgetTool(approval.toolCallId);
|
|
1048
|
-
return false;
|
|
1049
|
-
}
|
|
1050
|
-
// The call was announced before the prompt; keep its label up
|
|
1051
|
-
// while it runs instead of falling back to a thinking word.
|
|
1052
|
-
rememberTool({
|
|
1053
|
-
toolCallId: approval.toolCallId,
|
|
1054
|
-
toolName: approval.toolName,
|
|
1055
|
-
summary: approval.summary,
|
|
1056
|
-
});
|
|
1057
|
-
return true;
|
|
1058
|
-
},
|
|
1059
|
-
});
|
|
1060
|
-
stopSpinner();
|
|
1061
|
-
if (live !== undefined) {
|
|
1062
|
-
endAssistantTurn(live);
|
|
1063
|
-
}
|
|
1064
|
-
else if (saidSomething === false) {
|
|
1065
|
-
showAssistantTurn("");
|
|
1066
|
-
}
|
|
1067
|
-
showTouchedFiles(touchedFiles, options.dir);
|
|
1068
|
-
showNotices(notices);
|
|
1069
|
-
}
|
|
1070
|
-
catch (error) {
|
|
1071
|
-
stopSpinner();
|
|
1072
|
-
if (live !== undefined) {
|
|
1073
|
-
endAssistantTurn(live);
|
|
1074
|
-
}
|
|
1075
|
-
// The turn is over, whichever way it went: every branch below either
|
|
1076
|
-
// replaces the Claude Code session or unwinds the command. So a
|
|
1077
|
-
// Ctrl-C from here cannot stop anything the user would want stopped
|
|
1078
|
-
// — all it can do is skip the teardown, since `process.exit` runs no
|
|
1079
|
-
// `finally` and the `finally` blocks are what stop Claude Code.
|
|
1080
|
-
//
|
|
1081
|
-
// Marked for the whole unwind rather than the cancel branch alone. A
|
|
1082
|
-
// stall waits on the same `close` with the collector still holding
|
|
1083
|
-
// the terminal, and it is the likelier press of the two: minutes of
|
|
1084
|
-
// spinner is what makes someone reach for Ctrl-C in the first place.
|
|
1085
|
-
//
|
|
1086
|
-
// It also freezes the flag the branch below reads, so a press
|
|
1087
|
-
// landing here cannot make a stall look like a user cancel.
|
|
1088
|
-
cancellation.beginTeardown();
|
|
1089
|
-
if (cancellation.wasCancelled() === true) {
|
|
1090
|
-
// Ctrl-C aborts the harness stream, and what it leaves behind is a
|
|
1091
|
-
// session that answers nothing: the next question opens a stream
|
|
1092
|
-
// that yields its `start` part and then goes silent until the
|
|
1093
|
-
// stall watchdog gives up on it two minutes later. The harness
|
|
1094
|
-
// does not report it as an unfinished turn, so there is nothing to
|
|
1095
|
-
// drain and no way to tell from the outside — the only cure is a
|
|
1096
|
-
// new Claude Code process.
|
|
1097
|
-
//
|
|
1098
|
-
// Paid here rather than on the next question so the wait belongs
|
|
1099
|
-
// to the interrupt the user just pressed. The chat, its history
|
|
1100
|
-
// and the working copy all survive; the next turn replays the
|
|
1101
|
-
// history into a fresh session.
|
|
1102
|
-
await local.close();
|
|
1103
|
-
local = undefined;
|
|
1104
|
-
showTouchedFiles(touchedFiles, options.dir);
|
|
1105
|
-
showNotices(notices);
|
|
1106
|
-
showCard("Cancelled.");
|
|
1107
|
-
persistAskState({
|
|
1108
|
-
chatUuid,
|
|
1109
|
-
agentUuid: agent === undefined ? undefined : agent.uuid,
|
|
1110
|
-
});
|
|
1111
|
-
continue;
|
|
1112
|
-
}
|
|
1113
|
-
showNotices(notices);
|
|
1114
|
-
// The runtime could not be wound down, went silent, or the bridge
|
|
1115
|
-
// dropped — this session will refuse every question from here.
|
|
1116
|
-
// Replace it rather than exiting Ask: the chat, its history and the
|
|
1117
|
-
// working copy all survive; only the Claude Code process is new.
|
|
1118
|
-
if (error instanceof HarnessTurnStuck ||
|
|
1119
|
-
error instanceof HarnessTurnStalled ||
|
|
1120
|
-
isBridgeClosedError(error) === true) {
|
|
1121
|
-
await local.close();
|
|
1122
|
-
local = undefined;
|
|
1123
|
-
showCard(error instanceof HarnessTurnStalled
|
|
1124
|
-
? `${error.message} The coding session has been replaced — ask again, or try smaller steps.`
|
|
1125
|
-
: "That coding session stopped responding, so it has been replaced. Ask again — the chat is unchanged.");
|
|
1126
|
-
continue;
|
|
1127
|
-
}
|
|
1128
|
-
throw error;
|
|
1129
|
-
}
|
|
1130
|
-
finally {
|
|
1131
|
-
// Before `cancellation.release()`: while the collector still owns
|
|
1132
|
-
// the terminal, Ctrl-C is a keypress it turns into an interrupt, and
|
|
1133
|
-
// dropping the handler first would leave a window with neither.
|
|
1134
|
-
typedAhead = typing.finish();
|
|
1135
|
-
cancellation.release();
|
|
1136
|
-
await releaseChatTurn(options.api, chatUuid, sessionId);
|
|
1137
|
-
}
|
|
1138
|
-
persistAskState({
|
|
1139
|
-
chatUuid,
|
|
1140
|
-
agentUuid: agent === undefined ? undefined : agent.uuid,
|
|
1141
|
-
});
|
|
1142
|
-
continue;
|
|
1143
|
-
}
|
|
1144
|
-
if (agent === undefined) {
|
|
1145
|
-
failWith("Ask remote stream needs an agent.", {
|
|
1146
|
-
code: ExitCodes.GenericError,
|
|
1147
|
-
});
|
|
1148
|
-
}
|
|
1149
|
-
if (chatCreated === false) {
|
|
1150
|
-
await ensureChat({
|
|
1151
|
-
api: options.api,
|
|
1152
|
-
chatUuid,
|
|
1153
|
-
agentUuid: agent.uuid,
|
|
1154
|
-
created: false,
|
|
1155
|
-
});
|
|
1156
|
-
chatCreated = true;
|
|
1157
|
-
}
|
|
1158
|
-
if (config.getAccessToken === undefined) {
|
|
1159
|
-
failWith("Not authenticated. Run 'cargo-ai login --email <email>' or 'cargo-ai login --oauth'.", { code: ExitCodes.NotAuthenticated });
|
|
1160
|
-
}
|
|
1161
|
-
let live;
|
|
1162
|
-
let spinner;
|
|
1163
|
-
const stopRemoteSpinner = () => {
|
|
1164
|
-
if (spinner !== undefined) {
|
|
1165
|
-
spinner.stop();
|
|
1166
|
-
spinner = undefined;
|
|
1167
|
-
}
|
|
1168
|
-
};
|
|
1169
|
-
// Registered before the spinner so our handler runs before clack's own
|
|
1170
|
-
// — same ordering as the local turn.
|
|
1171
|
-
const cancellation = beginCancellableTurn(stopRemoteSpinner);
|
|
1172
|
-
// A remote turn is one stream with no tool steps of its own to mark, so
|
|
1173
|
-
// it gets the one word rather than a sequence of them.
|
|
1174
|
-
spinner = startSpinner(pickThinkingWord());
|
|
1175
|
-
let assistantMessageUuid;
|
|
1176
|
-
try {
|
|
1177
|
-
const deltas = streamMessageTextDeltas({
|
|
1178
|
-
baseUrl: config.baseUrl,
|
|
1179
|
-
getAccessToken: config.getAccessToken,
|
|
1180
|
-
workspaceUuid: config.workspaceUuid,
|
|
1181
|
-
chatUuid,
|
|
1182
|
-
messageUuid: randomUUID(),
|
|
1183
|
-
parts: [{ type: "text", text: question }],
|
|
1184
|
-
agentUuid: agent.uuid,
|
|
1185
|
-
onAssistantMessageUuid: (uuid) => {
|
|
1186
|
-
assistantMessageUuid = uuid;
|
|
1187
|
-
},
|
|
1188
|
-
abortSignal: cancellation.signal,
|
|
1189
|
-
});
|
|
1190
|
-
for await (const delta of deltas) {
|
|
1191
|
-
if (live === undefined) {
|
|
1192
|
-
stopRemoteSpinner();
|
|
1193
|
-
live = beginAssistantTurn();
|
|
1194
|
-
}
|
|
1195
|
-
appendAssistantDelta(live, delta);
|
|
1196
|
-
}
|
|
1197
|
-
if (live === undefined) {
|
|
1198
|
-
stopRemoteSpinner();
|
|
1199
|
-
showAssistantTurn("");
|
|
1200
|
-
}
|
|
1201
|
-
else {
|
|
1202
|
-
endAssistantTurn(live);
|
|
1203
|
-
}
|
|
1204
|
-
}
|
|
1205
|
-
catch (error) {
|
|
1206
|
-
stopRemoteSpinner();
|
|
1207
|
-
if (live !== undefined) {
|
|
1208
|
-
endAssistantTurn(live);
|
|
1209
|
-
}
|
|
1210
|
-
// Ctrl-C only stops the CLI reading the stream, so the message has to
|
|
1211
|
-
// be cancelled server-side too — an unfinished one makes the next
|
|
1212
|
-
// question in this chat fail with lastMessageNotFinished.
|
|
1213
|
-
if (cancellation.wasCancelled() === true) {
|
|
1214
|
-
await cancelRemoteMessage(options.api, assistantMessageUuid);
|
|
1215
|
-
showCard("Cancelled.");
|
|
1216
|
-
persistAskState({ chatUuid, agentUuid: agent.uuid });
|
|
1217
|
-
continue;
|
|
1218
|
-
}
|
|
1219
|
-
if (error instanceof Error) {
|
|
1220
|
-
failWith(error.message, { code: ExitCodes.GenericError });
|
|
1221
|
-
}
|
|
1222
|
-
throw error;
|
|
1223
|
-
}
|
|
1224
|
-
finally {
|
|
1225
|
-
cancellation.release();
|
|
1226
|
-
}
|
|
1227
|
-
persistAskState({
|
|
1228
|
-
chatUuid,
|
|
1229
|
-
agentUuid: agent.uuid,
|
|
1230
|
-
});
|
|
1231
|
-
}
|
|
1232
|
-
}
|
|
1233
|
-
finally {
|
|
1234
|
-
if (local !== undefined) {
|
|
1235
|
-
await local.close();
|
|
1236
|
-
}
|
|
1237
|
-
}
|
|
1238
|
-
}
|
|
1239
|
-
export async function runAskPrint(input) {
|
|
1240
|
-
const config = getConfig();
|
|
1241
|
-
if (config.getAccessToken === undefined) {
|
|
1242
|
-
failWith("Not authenticated. Run 'cargo-ai login --email <email>' or 'cargo-ai login --oauth'.", { code: ExitCodes.NotAuthenticated });
|
|
1243
|
-
}
|
|
1244
|
-
const agent = input.agentUuid !== undefined
|
|
1245
|
-
? await loadAgent(input.api, input.agentUuid)
|
|
1246
|
-
: undefined;
|
|
1247
|
-
const mode = modeForAgent(agent);
|
|
1248
|
-
const chatUuid = input.chatUuid !== undefined ? input.chatUuid : randomUUID();
|
|
1249
|
-
// Same lifecycle as the interactive session: a caller-supplied uuid may
|
|
1250
|
-
// already be on the server (--chat-uuid, or --continue reopening the last
|
|
1251
|
-
// thread), in which case creating it again would fail; a fresh one has to
|
|
1252
|
-
// be created before the first message.
|
|
1253
|
-
const chatCreated = input.chatUuid !== undefined
|
|
1254
|
-
? await doesChatExist(input.api, input.chatUuid)
|
|
1255
|
-
: false;
|
|
1256
|
-
if (mode.kind === "local") {
|
|
1257
|
-
await ensureChat({
|
|
1258
|
-
api: input.api,
|
|
1259
|
-
chatUuid,
|
|
1260
|
-
agentUuid: agent === undefined ? undefined : agent.uuid,
|
|
1261
|
-
created: chatCreated,
|
|
1262
|
-
});
|
|
1263
|
-
const minted = await handleApiCall(() => input.api.ai.chat.mintHarnessProxyToken(chatUuid), { spinner: false });
|
|
1264
|
-
const history = await loadChatHistoryPreamble({
|
|
1265
|
-
api: input.api,
|
|
1266
|
-
chatUuid,
|
|
1267
|
-
});
|
|
1268
|
-
const ownerId = randomUUID();
|
|
1269
|
-
// Before the harness is built, because both exits below end the process
|
|
1270
|
-
// and `process.exit` does not run `finally` blocks — either one below a
|
|
1271
|
-
// live harness would leave Claude Code and its bridge running.
|
|
1272
|
-
const claim = await claimChatTurn(input.api, chatUuid, ownerId);
|
|
1273
|
-
if (claim.outcome === "busy") {
|
|
1274
|
-
failWith("Another session is already running a turn in this chat. Wait for it to finish, or pass --chat-uuid for a different one.", { code: ExitCodes.GenericError });
|
|
1275
|
-
}
|
|
1276
|
-
if (claim.outcome === "unavailable") {
|
|
1277
|
-
await reportApiFailure(claim.error);
|
|
1278
|
-
}
|
|
1279
|
-
let local;
|
|
1280
|
-
try {
|
|
1281
|
-
local = await createLocalHarness({
|
|
1282
|
-
api: input.api,
|
|
1283
|
-
apiBaseUrl: config.baseUrl,
|
|
1284
|
-
dir: input.dir,
|
|
1285
|
-
chatUuid,
|
|
1286
|
-
agentUuid: agent === undefined ? undefined : agent.uuid,
|
|
1287
|
-
proxyToken: minted.token,
|
|
1288
|
-
history,
|
|
1289
|
-
turnOwnerId: ownerId,
|
|
1290
|
-
});
|
|
1291
|
-
const text = await local.streamPrompt(input.message);
|
|
1292
|
-
persistAskState({
|
|
1293
|
-
chatUuid,
|
|
1294
|
-
agentUuid: agent === undefined ? undefined : agent.uuid,
|
|
1295
|
-
});
|
|
1296
|
-
outputJson({ chatUuid, text });
|
|
1297
|
-
}
|
|
1298
|
-
finally {
|
|
1299
|
-
// Covers the build as well as the turn: a turn that errored spent
|
|
1300
|
-
// nothing and so recorded nothing, and a build that failed never got as
|
|
1301
|
-
// far as a turn. Either would leave the chat claimed until the TTL.
|
|
1302
|
-
await releaseChatTurn(input.api, chatUuid, ownerId);
|
|
1303
|
-
if (local !== undefined) {
|
|
1304
|
-
await local.close();
|
|
1305
|
-
}
|
|
1306
|
-
}
|
|
1307
|
-
return;
|
|
1308
|
-
}
|
|
1309
|
-
if (agent === undefined) {
|
|
1310
|
-
failWith("Ask remote print needs an agent.", {
|
|
1311
|
-
code: ExitCodes.GenericError,
|
|
1312
|
-
});
|
|
1313
|
-
}
|
|
1314
|
-
await ensureChat({
|
|
1315
|
-
api: input.api,
|
|
1316
|
-
chatUuid,
|
|
1317
|
-
agentUuid: agent.uuid,
|
|
1318
|
-
created: chatCreated,
|
|
1319
|
-
});
|
|
1320
|
-
const result = await handleApiCall(() => input.api.ai.message.create({
|
|
1321
|
-
chatUuid,
|
|
1322
|
-
agentUuid: agent.uuid,
|
|
1323
|
-
parts: [{ type: "text", text: input.message }],
|
|
1324
|
-
}));
|
|
1325
|
-
const finalResult = await pollUntilFinished(() => input.api.ai.message.get(result.assistantMessage.uuid), (row) => row.message.finishedAt, PRINT_POLL_INTERVAL_MS, PRINT_POLL_TIMEOUT_MS);
|
|
1326
|
-
persistAskState({ chatUuid, agentUuid: agent.uuid });
|
|
1327
|
-
outputJson(finalResult);
|
|
1328
|
-
}
|