@porte/cli 0.1.0
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/LICENSE +202 -0
- package/README.md +25 -0
- package/dist/client-CpzDWkFF.js +6708 -0
- package/dist/main.js +22 -0
- package/dist/run-cli-DB73XVhH.js +4200 -0
- package/package.json +63 -0
|
@@ -0,0 +1,4200 @@
|
|
|
1
|
+
import { $ as INTERNAL_SERVER_ERROR, A as ProblemDetailsSchema, B as PermissionNotFoundError, C as DeviceTokenErrorSchema, D as PORTE_CLI_CLIENT_ID, E as PAIRING_CODE_PATH, F as AgentUnresponsiveError, G as makeConversationSummary, H as ConversationEventSchema, I as ConfigurationNotFoundError, J as IsoDateTimeSchema, K as ConversationIdSchema, L as ConversationBusyError, M as isClassifiedError, N as isDomainError, O as createLogger, P as WorkspaceNotAllowedError, Q as turnIdFor, R as ConversationNotFoundError, S as DeviceCodeResponseSchema, T as DeviceTokenResponseSchema, U as ToolViewSchema, V as TurnNotFoundError, W as ConversationCursorSchema, X as PermissionIdSchema, Y as MessageIdSchema, Z as ToolCallIdSchema, _ as readJsonRpcIncoming, _t as url, a as HOST_APPLICATION_ERROR_CODE, at as _enum, b as DEVICE_CODE_GRANT_TYPE, c as HOST_CONVERSATION_SUBPROTOCOL, ct as email, d as JSON_RPC_ERROR_CODES, dt as literal, et as InternalServerError, f as JsonRpcReadError, ft as number, g as jsonRpcNotification, gt as unknown, h as jsonRpcError, ht as string, i as HostControlMethods, it as h, j as HostPlatformSchema, l as HostRequestIdSchema, lt as httpUrl, m as handleJsonRpcRequest, mt as record, n as sendJsonRpcFrame, nt as CODING_AGENT_UNAVAILABLE_ERROR, o as HOST_APPLICATION_ERROR_MESSAGE, ot as array, p as JsonRpcTextSchema, pt as object, q as ElicitationIdSchema, r as HostConversationMethods, rt as CodingAgentUnavailableError, s as HOST_CONTROL_SUBPROTOCOL, st as discriminatedUnion, t as JsonRpcSendError, tt as ServiceUnavailableError, u as SequenceNumberSchema, ut as json, v as readJsonRpcTextFrame, w as DeviceTokenRequestSchema, x as DeviceCodeRequestSchema, y as formatPairingCode, z as ElicitationNotFoundError } from "./client-CpzDWkFF.js";
|
|
2
|
+
import { homedir, hostname } from "node:os";
|
|
3
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
5
|
+
import { createColors } from "picocolors";
|
|
6
|
+
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
7
|
+
import { createFetch, createSchema } from "@better-fetch/fetch";
|
|
8
|
+
import { parseArgs } from "node:util";
|
|
9
|
+
import { existsSync } from "node:fs";
|
|
10
|
+
import * as acp from "@agentclientprotocol/sdk";
|
|
11
|
+
import { PROTOCOL_VERSION } from "@agentclientprotocol/sdk";
|
|
12
|
+
import { addAbortListener, once } from "node:events";
|
|
13
|
+
import { Readable, Writable } from "node:stream";
|
|
14
|
+
import { WebSocket } from "partysocket";
|
|
15
|
+
import { WebSocket as WebSocket$1 } from "ws";
|
|
16
|
+
//#region src/application/errors/pairing-errors.ts
|
|
17
|
+
/** The Host cannot start until this machine has a stored credential. */
|
|
18
|
+
var HostNotPairedError = class extends h("HostNotPairedError") {
|
|
19
|
+
constructor() {
|
|
20
|
+
super({
|
|
21
|
+
message: "Not paired yet. Run `porte pair` first.",
|
|
22
|
+
classification: "terminal"
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
/** An active pairing attempt did not produce a credential. */
|
|
27
|
+
var PairingError = class extends h("PairingError") {
|
|
28
|
+
constructor(args) {
|
|
29
|
+
super({
|
|
30
|
+
cause: args.cause,
|
|
31
|
+
reason: args.reason,
|
|
32
|
+
message: MESSAGES[args.reason],
|
|
33
|
+
classification: CLASSIFICATIONS[args.reason]
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
const MESSAGES = {
|
|
38
|
+
denied: "Pairing was cancelled in the browser. Run porte pair again to retry.",
|
|
39
|
+
expired: "The code expired before anyone approved it. Run porte pair for a new one.",
|
|
40
|
+
unreachable: "Could not reach Porte. Check the connection, then run porte pair again.",
|
|
41
|
+
unexpected: "Porte returned an unexpected response."
|
|
42
|
+
};
|
|
43
|
+
const CLASSIFICATIONS = {
|
|
44
|
+
denied: "terminal",
|
|
45
|
+
expired: "terminal",
|
|
46
|
+
unreachable: "transient",
|
|
47
|
+
unexpected: "terminal"
|
|
48
|
+
};
|
|
49
|
+
//#endregion
|
|
50
|
+
//#region src/application/ports/credential-store.ts
|
|
51
|
+
/** The credential file could not be read, written, or removed. */
|
|
52
|
+
var CredentialStoreError = class extends h("CredentialStoreError") {
|
|
53
|
+
constructor(args) {
|
|
54
|
+
super({
|
|
55
|
+
...args,
|
|
56
|
+
message: "could not access the stored Porte credential",
|
|
57
|
+
classification: "terminal"
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
//#endregion
|
|
62
|
+
//#region src/infrastructure/config/host-config.ts
|
|
63
|
+
/** The process configuration contains a value the Host cannot use. */
|
|
64
|
+
var ConfigError = class extends h("ConfigError") {
|
|
65
|
+
constructor(args) {
|
|
66
|
+
super({
|
|
67
|
+
...args,
|
|
68
|
+
classification: "terminal"
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
const DEFAULT_BASE_URL = "https://useporte.dev";
|
|
73
|
+
const ConfigSchema = object({
|
|
74
|
+
baseUrl: url({
|
|
75
|
+
protocol: /^https?$/,
|
|
76
|
+
error: "PORTE_URL must be an http or https origin, such as https://useporte.dev"
|
|
77
|
+
}),
|
|
78
|
+
dataDirectory: string().min(1, { error: "PORTE_DATA_DIRECTORY must not be empty" })
|
|
79
|
+
});
|
|
80
|
+
/** Read and validate the Host process configuration. */
|
|
81
|
+
function loadConfig(env) {
|
|
82
|
+
const parsed = ConfigSchema.safeParse({
|
|
83
|
+
baseUrl: env.PORTE_URL ?? DEFAULT_BASE_URL,
|
|
84
|
+
dataDirectory: env.PORTE_DATA_DIRECTORY ?? join(homedir(), ".porte")
|
|
85
|
+
});
|
|
86
|
+
if (parsed.success) return parsed.data;
|
|
87
|
+
throw new ConfigError({ message: parsed.error.issues.map((issue) => ` ${issue.message}`).join("\n") });
|
|
88
|
+
}
|
|
89
|
+
//#endregion
|
|
90
|
+
//#region src/infrastructure/node/machine.ts
|
|
91
|
+
/** The Host does not support the current operating system. */
|
|
92
|
+
var UnsupportedPlatformError = class extends h("UnsupportedPlatformError") {
|
|
93
|
+
constructor(platform) {
|
|
94
|
+
super({
|
|
95
|
+
message: `Porte does not run on ${platform} yet.`,
|
|
96
|
+
classification: "terminal"
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
/**
|
|
101
|
+
* What this Mac calls itself.
|
|
102
|
+
*
|
|
103
|
+
* `platform` stays the runtime's own token, so what is stored is what Node
|
|
104
|
+
* reported. Turning `darwin` into `macOS` is the reader's job, and spelling it
|
|
105
|
+
* both ways would leave two versions of one fact.
|
|
106
|
+
*/
|
|
107
|
+
function describeThisMachine() {
|
|
108
|
+
const platform = HostPlatformSchema.safeParse(process.platform);
|
|
109
|
+
if (!platform.success) throw new UnsupportedPlatformError(process.platform);
|
|
110
|
+
return {
|
|
111
|
+
name: machineName(),
|
|
112
|
+
platform: platform.data
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* The name the person gave this machine, not the one the network uses.
|
|
117
|
+
*
|
|
118
|
+
* macOS keeps both: `Alexander's MacBook Pro` in settings, and a hyphenated
|
|
119
|
+
* `.local` form for mDNS. The settings name is the one they will recognise on
|
|
120
|
+
* the confirmation screen, so it is worth asking for.
|
|
121
|
+
*/
|
|
122
|
+
function machineName() {
|
|
123
|
+
return (process.platform === "darwin" ? computerName() : null) ?? hostname().replace(/\.local$/, "");
|
|
124
|
+
}
|
|
125
|
+
/** Null on any refusal: a machine name is never worth failing pairing over. */
|
|
126
|
+
function computerName() {
|
|
127
|
+
try {
|
|
128
|
+
const name = execFileSync("scutil", ["--get", "ComputerName"], {
|
|
129
|
+
encoding: "utf8",
|
|
130
|
+
timeout: 1e3
|
|
131
|
+
}).trim();
|
|
132
|
+
return name.length > 0 ? name : null;
|
|
133
|
+
} catch {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
//#endregion
|
|
138
|
+
//#region src/infrastructure/websocket/websocket-errors.ts
|
|
139
|
+
/** The HTTP upgrade for this WebSocket was refused. */
|
|
140
|
+
var WebSocketHandshakeRefused = class extends h("WebSocketHandshakeRefused") {
|
|
141
|
+
constructor(args) {
|
|
142
|
+
super({
|
|
143
|
+
...args,
|
|
144
|
+
message: `WebSocket handshake refused (HTTP ${String(args.status)})`,
|
|
145
|
+
classification: "terminal"
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
/** The peer closed with an RFC 6455 terminal close code (1002, 1003, 1007, 1008, 1009). */
|
|
150
|
+
var WebSocketProtocolClose = class extends h("WebSocketProtocolClose") {
|
|
151
|
+
constructor(args) {
|
|
152
|
+
super({
|
|
153
|
+
...args,
|
|
154
|
+
classification: "terminal"
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
/** Inbound frame handling failed, so this connection stopped. */
|
|
159
|
+
var WebSocketHandlerError = class extends h("WebSocketHandlerError") {
|
|
160
|
+
constructor(args) {
|
|
161
|
+
super({
|
|
162
|
+
...args,
|
|
163
|
+
message: "WebSocket frame handler failed",
|
|
164
|
+
classification: "unknown"
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
//#endregion
|
|
169
|
+
//#region src/entrypoints/cli/version.ts
|
|
170
|
+
/** Package version. Source is `package.json`. */
|
|
171
|
+
const VERSION = "0.1.0";
|
|
172
|
+
//#endregion
|
|
173
|
+
//#region src/entrypoints/cli/cli-error.ts
|
|
174
|
+
/** Bad arguments or missing required flags. */
|
|
175
|
+
var UsageError = class extends h("UsageError") {
|
|
176
|
+
constructor(args) {
|
|
177
|
+
super({
|
|
178
|
+
...args,
|
|
179
|
+
classification: "terminal"
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
/** Test whether a caught value is an expected CLI error. */
|
|
184
|
+
function isCliError(cause) {
|
|
185
|
+
return cause instanceof UsageError || cause instanceof ConfigError || cause instanceof UnsupportedPlatformError || cause instanceof HostNotPairedError || cause instanceof WebSocketHandlerError || cause instanceof JsonRpcSendError || cause instanceof WebSocketHandshakeRefused || cause instanceof WebSocketProtocolClose || cause instanceof PairingError || cause instanceof CredentialStoreError;
|
|
186
|
+
}
|
|
187
|
+
/** Map one CLI error to its process exit code. */
|
|
188
|
+
function exitCodeFor(error) {
|
|
189
|
+
if (error instanceof UsageError) return 2;
|
|
190
|
+
if (error instanceof ConfigError) return 2;
|
|
191
|
+
if (error instanceof HostNotPairedError) return 2;
|
|
192
|
+
if (error instanceof WebSocketHandshakeRefused) return error.status === 401 || error.status === 403 ? 2 : 1;
|
|
193
|
+
return 1;
|
|
194
|
+
}
|
|
195
|
+
/** Format one safe error for standard error output. */
|
|
196
|
+
function formatError(error) {
|
|
197
|
+
if (error instanceof UsageError) return error.message;
|
|
198
|
+
let body;
|
|
199
|
+
if (error instanceof ConfigError || error instanceof UnsupportedPlatformError) body = `Error (ECONFIG): Porte configuration is invalid.\n${error.message}`;
|
|
200
|
+
else if (error instanceof HostNotPairedError) body = error.message;
|
|
201
|
+
else if (error instanceof WebSocketHandlerError || error instanceof JsonRpcSendError) body = "Error (ERELAY): host connection stopped. Restart `porte up`.";
|
|
202
|
+
else if (error instanceof WebSocketHandshakeRefused) body = error.status === 401 || error.status === 403 ? `Error (EAUTH): ${error.message} Run \`porte pair\` to pair this Mac again.` : `Error (ERELAY): ${error.message}`;
|
|
203
|
+
else if (error instanceof WebSocketProtocolClose) body = `Error (ERELAY): ${error.message}`;
|
|
204
|
+
else if (error instanceof PairingError) body = `Error (EPAIR): ${error.message} Run \`porte pair\` to try again.`;
|
|
205
|
+
else body = `Error (ECRED): ${error.message}`;
|
|
206
|
+
return `porte v${VERSION} — ${body}`;
|
|
207
|
+
}
|
|
208
|
+
//#endregion
|
|
209
|
+
//#region src/entrypoints/cli/output.ts
|
|
210
|
+
/**
|
|
211
|
+
* Everything the CLI writes for a person to read.
|
|
212
|
+
*
|
|
213
|
+
* Layout lives here rather than at the call sites, so blank lines and indents
|
|
214
|
+
* stay consistent and a handler says what it means instead of counting spaces.
|
|
215
|
+
* Machine-readable output never passes through this: it goes straight to stdout.
|
|
216
|
+
*/
|
|
217
|
+
/** Two spaces. Deep enough to group a line under its heading, shallow enough to scan. */
|
|
218
|
+
const INDENT = " ";
|
|
219
|
+
/** Move the cursor to the start of the previous line. */
|
|
220
|
+
const CURSOR_UP = "\x1B[1A";
|
|
221
|
+
/** Clears everything from the cursor down, however many rows that turns out to be. */
|
|
222
|
+
const CLEAR_BELOW = "\r\x1B[0J";
|
|
223
|
+
/** Assumed width when a stream is not a terminal and reports none. */
|
|
224
|
+
const FALLBACK_COLUMNS = 80;
|
|
225
|
+
/**
|
|
226
|
+
* Marks for one moment each, never decoration.
|
|
227
|
+
*
|
|
228
|
+
* Placement follows width, not habit. A single-width glyph can lead a line
|
|
229
|
+
* because it shifts nothing after it; an emoji is double-width and would push
|
|
230
|
+
* its text out of column, so emoji trail the line they mark instead.
|
|
231
|
+
*/
|
|
232
|
+
const EMOJI = {
|
|
233
|
+
pair: "🚪",
|
|
234
|
+
waiting: "⏳",
|
|
235
|
+
done: "✓",
|
|
236
|
+
failed: "✗",
|
|
237
|
+
warned: "!"
|
|
238
|
+
};
|
|
239
|
+
/**
|
|
240
|
+
* Build the writer for one stream.
|
|
241
|
+
*
|
|
242
|
+
* Colour follows the stream rather than the process, because Porte prints JSON
|
|
243
|
+
* on stdout and prose on stderr. A piped `porte list` must stay clean while the
|
|
244
|
+
* messages beside it keep their colour.
|
|
245
|
+
*
|
|
246
|
+
* @param stream - The stream this writer writes to.
|
|
247
|
+
*/
|
|
248
|
+
function createOutput(stream) {
|
|
249
|
+
const c = createColors(isColorAllowed(stream));
|
|
250
|
+
const line = (text) => stream.write(`${text}\n`);
|
|
251
|
+
const terminal = "isTTY" in stream && stream.isTTY === true;
|
|
252
|
+
let statusRows = 0;
|
|
253
|
+
const endStatus = () => {
|
|
254
|
+
statusRows = 0;
|
|
255
|
+
};
|
|
256
|
+
return {
|
|
257
|
+
blank: () => stream.write("\n"),
|
|
258
|
+
title: (text, mark) => {
|
|
259
|
+
stream.write("\n");
|
|
260
|
+
line(mark === void 0 ? c.bold(text) : `${c.bold(text)} ${mark}`);
|
|
261
|
+
stream.write("\n");
|
|
262
|
+
},
|
|
263
|
+
step: (position, text) => line(`${INDENT}${c.dim(`${String(position)}.`)} ${text}`),
|
|
264
|
+
note: (text) => line(`${INDENT}${c.dim(text)}`),
|
|
265
|
+
done: (text) => {
|
|
266
|
+
endStatus();
|
|
267
|
+
stream.write("\n");
|
|
268
|
+
line(`${c.green(EMOJI.done)} ${text}`);
|
|
269
|
+
},
|
|
270
|
+
failed: (text) => {
|
|
271
|
+
endStatus();
|
|
272
|
+
stream.write("\n");
|
|
273
|
+
line(`${c.red(EMOJI.failed)} ${text}`);
|
|
274
|
+
},
|
|
275
|
+
warned: (text) => {
|
|
276
|
+
endStatus();
|
|
277
|
+
line(`${EMOJI.warned} ${c.yellow(text)}`);
|
|
278
|
+
},
|
|
279
|
+
raw: (text) => line(text),
|
|
280
|
+
status: (text) => {
|
|
281
|
+
if (terminal && statusRows > 0) stream.write(`${CURSOR_UP.repeat(statusRows)}${CLEAR_BELOW}`);
|
|
282
|
+
line(text);
|
|
283
|
+
statusRows = terminal ? rowsUsed(text, terminalColumns(stream)) : 0;
|
|
284
|
+
},
|
|
285
|
+
prompt: (text) => stream.write(text),
|
|
286
|
+
rewrite: (above, prompt) => {
|
|
287
|
+
const columns = terminalColumns(stream);
|
|
288
|
+
const up = rowsUsed(above, columns) + rowsUsed(prompt, columns) - 1;
|
|
289
|
+
stream.write(`${CURSOR_UP.repeat(up)}${CLEAR_BELOW}${above}\n${prompt}`);
|
|
290
|
+
},
|
|
291
|
+
emphasis: {
|
|
292
|
+
code: (text) => c.bold(c.cyan(text)),
|
|
293
|
+
url: (text) => c.underline(c.cyan(text)),
|
|
294
|
+
quiet: (text) => c.dim(text),
|
|
295
|
+
strong: (text) => c.bold(text),
|
|
296
|
+
ok: (text) => c.green(text)
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
/** The pairing prompt, which is the one screen a new person always meets. */
|
|
301
|
+
const PAIR_EMOJI = EMOJI.pair;
|
|
302
|
+
const WAITING_EMOJI = EMOJI.waiting;
|
|
303
|
+
/** Honour NO_COLOR, then fall back to whether a person is actually watching. */
|
|
304
|
+
function isColorAllowed(stream) {
|
|
305
|
+
if (process.env.NO_COLOR !== void 0 && process.env.NO_COLOR !== "") return false;
|
|
306
|
+
if (process.env.FORCE_COLOR !== void 0 && process.env.FORCE_COLOR !== "") return true;
|
|
307
|
+
return "isTTY" in stream && stream.isTTY === true;
|
|
308
|
+
}
|
|
309
|
+
/** How many rows a line takes once the terminal wraps it. */
|
|
310
|
+
function rowsUsed(text, columns) {
|
|
311
|
+
return Math.max(1, Math.ceil(visibleLength(text) / columns));
|
|
312
|
+
}
|
|
313
|
+
/** Colour codes occupy no columns, so they cannot count towards the width. */
|
|
314
|
+
function visibleLength(text) {
|
|
315
|
+
return text.replace(/\u001b\[[0-9;]*m/g, "").length;
|
|
316
|
+
}
|
|
317
|
+
/** A terminal reports its width. A pipe reports none and wraps at nothing. */
|
|
318
|
+
const widthSchema = object({ columns: number().int().positive() });
|
|
319
|
+
function terminalColumns(stream) {
|
|
320
|
+
const parsed = widthSchema.safeParse(stream);
|
|
321
|
+
return parsed.success ? parsed.data.columns : FALLBACK_COLUMNS;
|
|
322
|
+
}
|
|
323
|
+
//#endregion
|
|
324
|
+
//#region src/entrypoints/cli/cli-error-boundary.ts
|
|
325
|
+
/** Write one expected CLI error and return its process exit code. */
|
|
326
|
+
function reportCliError(stderr, error) {
|
|
327
|
+
const output = createOutput(stderr);
|
|
328
|
+
const body = formatError(error);
|
|
329
|
+
if (error._tag === "UsageError") output.raw(body);
|
|
330
|
+
else output.failed(body);
|
|
331
|
+
return exitCodeFor(error);
|
|
332
|
+
}
|
|
333
|
+
/** Write one unexpected CLI defect and return exit code 1. */
|
|
334
|
+
function reportUnexpectedCliError(stderr, cause) {
|
|
335
|
+
const detail = cause instanceof Error ? cause.stack ?? cause.message : String(cause);
|
|
336
|
+
stderr.write(`porte v${VERSION} — unexpected error\n${detail}\n`);
|
|
337
|
+
stderr.write("Report: https://github.com/alexander-zuev/porte/issues/new\n");
|
|
338
|
+
return 1;
|
|
339
|
+
}
|
|
340
|
+
//#endregion
|
|
341
|
+
//#region src/application/commands/pair-host.ts
|
|
342
|
+
/**
|
|
343
|
+
* Pair this Mac with a Porte account.
|
|
344
|
+
*
|
|
345
|
+
* The daemon asks for a code, shows it, and waits. Approval happens on the
|
|
346
|
+
* person's phone, so this machine never handles a password and the credential
|
|
347
|
+
* it ends up with belongs to whoever approved.
|
|
348
|
+
*/
|
|
349
|
+
async function pairHost(input) {
|
|
350
|
+
const grant = await input.authorizer.requestCode(input.host);
|
|
351
|
+
input.onPrompt({
|
|
352
|
+
userCode: grant.userCode,
|
|
353
|
+
verificationUri: grant.verificationUri,
|
|
354
|
+
expiresInSeconds: grant.expiresInSeconds
|
|
355
|
+
});
|
|
356
|
+
const answered = await waitForApproval(input, grant);
|
|
357
|
+
if (answered.status !== "paired") return answered;
|
|
358
|
+
await input.credentials.write({
|
|
359
|
+
baseUrl: input.baseUrl,
|
|
360
|
+
token: answered.token
|
|
361
|
+
});
|
|
362
|
+
return {
|
|
363
|
+
status: "paired",
|
|
364
|
+
account: await input.authorizer.accountOf(answered.token)
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* Poll until the person answers or the code dies.
|
|
369
|
+
*
|
|
370
|
+
* The deadline is enforced here as well as by the server, so a server that
|
|
371
|
+
* stops answering cannot leave the daemon polling forever.
|
|
372
|
+
*/
|
|
373
|
+
async function waitForApproval(input, grant) {
|
|
374
|
+
const deadline = input.now() + grant.expiresInSeconds * 1e3;
|
|
375
|
+
let intervalSeconds = grant.intervalSeconds;
|
|
376
|
+
while (input.now() < deadline) {
|
|
377
|
+
await input.sleep(intervalSeconds * 1e3);
|
|
378
|
+
const answer = await input.authorizer.poll(grant.deviceCode);
|
|
379
|
+
if (answer.status === "granted") return {
|
|
380
|
+
status: "paired",
|
|
381
|
+
token: answer.token
|
|
382
|
+
};
|
|
383
|
+
if (answer.status === "denied") return { status: "denied" };
|
|
384
|
+
if (answer.status === "expired") return { status: "expired" };
|
|
385
|
+
if (answer.status === "slow-down") intervalSeconds += answer.intervalSeconds;
|
|
386
|
+
}
|
|
387
|
+
return { status: "expired" };
|
|
388
|
+
}
|
|
389
|
+
//#endregion
|
|
390
|
+
//#region src/infrastructure/persistence/credential-store.ts
|
|
391
|
+
const StoredCredentialSchema = object({
|
|
392
|
+
baseUrl: url({ protocol: /^https?$/ }),
|
|
393
|
+
token: string().min(1)
|
|
394
|
+
});
|
|
395
|
+
/** Owner read and write only. This file is a bearer credential. */
|
|
396
|
+
const FILE_MODE = 384;
|
|
397
|
+
const DIRECTORY_MODE = 448;
|
|
398
|
+
const FILE_NAME = "credentials.json";
|
|
399
|
+
/**
|
|
400
|
+
* The Porte credential as a file under the user's home directory.
|
|
401
|
+
*
|
|
402
|
+
* A file this process cannot parse is treated as absent rather than fatal, so a
|
|
403
|
+
* corrupted credential sends the person to `porte pair` instead of to a stack
|
|
404
|
+
* trace. Unreadable for any other reason still fails loudly.
|
|
405
|
+
*/
|
|
406
|
+
var FileCredentialStore = class {
|
|
407
|
+
filePath;
|
|
408
|
+
constructor(dataDirectory) {
|
|
409
|
+
this.filePath = join(dataDirectory, FILE_NAME);
|
|
410
|
+
}
|
|
411
|
+
async read() {
|
|
412
|
+
let contents;
|
|
413
|
+
try {
|
|
414
|
+
contents = await readFile(this.filePath, "utf8");
|
|
415
|
+
} catch (cause) {
|
|
416
|
+
if (isMissing(cause)) return null;
|
|
417
|
+
throw new CredentialStoreError({ cause });
|
|
418
|
+
}
|
|
419
|
+
return parseCredential(contents);
|
|
420
|
+
}
|
|
421
|
+
async write(credential) {
|
|
422
|
+
try {
|
|
423
|
+
await mkdir(dirname(this.filePath), {
|
|
424
|
+
recursive: true,
|
|
425
|
+
mode: DIRECTORY_MODE
|
|
426
|
+
});
|
|
427
|
+
await writeFile(this.filePath, JSON.stringify(credential, null, 2), {
|
|
428
|
+
encoding: "utf8",
|
|
429
|
+
mode: FILE_MODE
|
|
430
|
+
});
|
|
431
|
+
} catch (cause) {
|
|
432
|
+
throw new CredentialStoreError({ cause });
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
async clear() {
|
|
436
|
+
try {
|
|
437
|
+
await rm(this.filePath, { force: true });
|
|
438
|
+
} catch (cause) {
|
|
439
|
+
throw new CredentialStoreError({ cause });
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
};
|
|
443
|
+
function isMissing(cause) {
|
|
444
|
+
return cause instanceof Error && "code" in cause && cause.code === "ENOENT";
|
|
445
|
+
}
|
|
446
|
+
/** Unparseable and invalid both mean "no usable credential", so both give null. */
|
|
447
|
+
function parseCredential(contents) {
|
|
448
|
+
try {
|
|
449
|
+
const parsed = StoredCredentialSchema.safeParse(JSON.parse(contents));
|
|
450
|
+
return parsed.success ? parsed.data : null;
|
|
451
|
+
} catch {
|
|
452
|
+
return null;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
//#endregion
|
|
456
|
+
//#region src/infrastructure/porte/device-authorization-client.ts
|
|
457
|
+
/** The token exchange stays the plugin's own endpoint, under its base path. */
|
|
458
|
+
const DEVICE_TOKEN_PATH = "/api/auth/device/token";
|
|
459
|
+
/** Better Auth's own route. The grant never says who approved. */
|
|
460
|
+
const SESSION_PATH = "/api/auth/get-session";
|
|
461
|
+
const sessionAccountSchema = object({ user: object({
|
|
462
|
+
email: email().nullish(),
|
|
463
|
+
name: string().nullish()
|
|
464
|
+
}) });
|
|
465
|
+
/**
|
|
466
|
+
* The two calls the grant is made of, and the shapes each side may send.
|
|
467
|
+
*
|
|
468
|
+
* Declaring them once means a response is validated before it is a value, so
|
|
469
|
+
* nothing below this file has to ask whether a field arrived.
|
|
470
|
+
*/
|
|
471
|
+
const grantSchema = createSchema({
|
|
472
|
+
[`@post${PAIRING_CODE_PATH}`]: {
|
|
473
|
+
input: DeviceCodeRequestSchema,
|
|
474
|
+
output: DeviceCodeResponseSchema
|
|
475
|
+
},
|
|
476
|
+
[`@post${DEVICE_TOKEN_PATH}`]: {
|
|
477
|
+
input: DeviceTokenRequestSchema,
|
|
478
|
+
output: DeviceTokenResponseSchema
|
|
479
|
+
}
|
|
480
|
+
});
|
|
481
|
+
/**
|
|
482
|
+
* The device authorization grant over HTTP.
|
|
483
|
+
*
|
|
484
|
+
* Wire names stay snake_case until they leave this file, so the RFC and the
|
|
485
|
+
* request bodies can be compared line for line.
|
|
486
|
+
*/
|
|
487
|
+
var DeviceAuthorizationClient = class {
|
|
488
|
+
baseUrl;
|
|
489
|
+
fetch;
|
|
490
|
+
constructor(baseUrl) {
|
|
491
|
+
this.baseUrl = baseUrl;
|
|
492
|
+
this.fetch = createFetch({
|
|
493
|
+
baseURL: baseUrl,
|
|
494
|
+
schema: grantSchema,
|
|
495
|
+
throw: false
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
async requestCode(host) {
|
|
499
|
+
const { data, error } = await this.fetch(`@post${PAIRING_CODE_PATH}`, {
|
|
500
|
+
body: {
|
|
501
|
+
client_id: PORTE_CLI_CLIENT_ID,
|
|
502
|
+
host_name: host.name,
|
|
503
|
+
host_platform: host.platform
|
|
504
|
+
},
|
|
505
|
+
errorSchema: ProblemDetailsSchema,
|
|
506
|
+
output: DeviceCodeResponseSchema
|
|
507
|
+
});
|
|
508
|
+
if (error) throw transportError(error);
|
|
509
|
+
return {
|
|
510
|
+
deviceCode: data.device_code,
|
|
511
|
+
userCode: data.user_code,
|
|
512
|
+
verificationUri: data.verification_uri,
|
|
513
|
+
intervalSeconds: data.interval,
|
|
514
|
+
expiresInSeconds: data.expires_in
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
/**
|
|
518
|
+
* Ask once whether the person has approved yet.
|
|
519
|
+
*
|
|
520
|
+
* Pending and slow-down arrive as non-2xx, so the status alone cannot decide
|
|
521
|
+
* this. The refusal code in the body is what separates waiting from failing.
|
|
522
|
+
*/
|
|
523
|
+
async poll(deviceCode) {
|
|
524
|
+
const { data, error } = await this.fetch(`@post${DEVICE_TOKEN_PATH}`, {
|
|
525
|
+
body: {
|
|
526
|
+
grant_type: DEVICE_CODE_GRANT_TYPE,
|
|
527
|
+
device_code: deviceCode,
|
|
528
|
+
client_id: PORTE_CLI_CLIENT_ID
|
|
529
|
+
},
|
|
530
|
+
output: DeviceTokenResponseSchema
|
|
531
|
+
});
|
|
532
|
+
if (data) return {
|
|
533
|
+
status: "granted",
|
|
534
|
+
token: data.access_token
|
|
535
|
+
};
|
|
536
|
+
const refusal = DeviceTokenErrorSchema.safeParse(error);
|
|
537
|
+
if (!refusal.success) throw transportError(error);
|
|
538
|
+
return fromRefusal(refusal.data.error);
|
|
539
|
+
}
|
|
540
|
+
/**
|
|
541
|
+
* End the pairing.
|
|
542
|
+
*
|
|
543
|
+
* Stubbed: the route it needs does not exist yet, so unpairing currently only
|
|
544
|
+
* clears the local credential. Wiring the request is a change to this method.
|
|
545
|
+
*/
|
|
546
|
+
revoke(_token) {
|
|
547
|
+
return Promise.resolve();
|
|
548
|
+
}
|
|
549
|
+
/**
|
|
550
|
+
* Ask who the new token belongs to.
|
|
551
|
+
*
|
|
552
|
+
* Better Auth's own route rather than the grant's, so it stays outside the
|
|
553
|
+
* schema above. A name is a courtesy, and pairing has already succeeded by
|
|
554
|
+
* the time anyone asks for it.
|
|
555
|
+
*/
|
|
556
|
+
async accountOf(token) {
|
|
557
|
+
try {
|
|
558
|
+
const response = await fetch(new URL(SESSION_PATH, this.baseUrl), { headers: { Authorization: `Bearer ${token}` } });
|
|
559
|
+
if (!response.ok) return null;
|
|
560
|
+
const parsed = sessionAccountSchema.safeParse(await response.json());
|
|
561
|
+
if (!parsed.success) return null;
|
|
562
|
+
return parsed.data.user.email ?? parsed.data.user.name ?? null;
|
|
563
|
+
} catch {
|
|
564
|
+
return null;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
};
|
|
568
|
+
/** What the grant's own vocabulary means to the person who ran `porte pair`. */
|
|
569
|
+
function fromRefusal(refusal) {
|
|
570
|
+
switch (refusal) {
|
|
571
|
+
case "authorization_pending": return { status: "pending" };
|
|
572
|
+
case "slow_down": return {
|
|
573
|
+
status: "slow-down",
|
|
574
|
+
intervalSeconds: 5
|
|
575
|
+
};
|
|
576
|
+
case "access_denied": return { status: "denied" };
|
|
577
|
+
case "expired_token": return { status: "expired" };
|
|
578
|
+
default: throw new PairingError({
|
|
579
|
+
reason: "unexpected",
|
|
580
|
+
cause: refusal
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
/** A zero status is better-fetch reporting it never reached the server. */
|
|
585
|
+
function transportError(error) {
|
|
586
|
+
return new PairingError({
|
|
587
|
+
reason: error.status === 0 ? "unreachable" : "unexpected",
|
|
588
|
+
cause: error
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
//#endregion
|
|
592
|
+
//#region src/infrastructure/bootstrap/pairing-resources.ts
|
|
593
|
+
/** Create the resources used by one pairing command. */
|
|
594
|
+
function createPairingResources(config) {
|
|
595
|
+
return {
|
|
596
|
+
credentials: new FileCredentialStore(config.dataDirectory),
|
|
597
|
+
authorizer: new DeviceAuthorizationClient(config.baseUrl)
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
//#endregion
|
|
601
|
+
//#region src/infrastructure/node/clipboard.ts
|
|
602
|
+
/** The clipboard tool for this platform, or null where we know of none. */
|
|
603
|
+
function clipboardCommand() {
|
|
604
|
+
if (process.platform === "darwin") return ["pbcopy", []];
|
|
605
|
+
if (process.platform === "win32") return ["clip", []];
|
|
606
|
+
if (process.platform === "linux") return ["xclip", ["-selection", "clipboard"]];
|
|
607
|
+
return null;
|
|
608
|
+
}
|
|
609
|
+
/**
|
|
610
|
+
* Put text on the system clipboard.
|
|
611
|
+
*
|
|
612
|
+
* Returns false when no clipboard tool is reachable, which includes a Linux box
|
|
613
|
+
* without xclip installed. Copying is a convenience, so a missing tool must
|
|
614
|
+
* leave the command it decorates working exactly as before.
|
|
615
|
+
*/
|
|
616
|
+
async function copyToClipboard(text) {
|
|
617
|
+
const command = clipboardCommand();
|
|
618
|
+
if (command === null) return false;
|
|
619
|
+
const [bin, args] = command;
|
|
620
|
+
return new Promise((resolve) => {
|
|
621
|
+
const child = spawn(bin, [...args]);
|
|
622
|
+
child.on("error", () => {
|
|
623
|
+
resolve(false);
|
|
624
|
+
});
|
|
625
|
+
child.on("close", (code) => {
|
|
626
|
+
resolve(code === 0);
|
|
627
|
+
});
|
|
628
|
+
child.stdin.end(text);
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
//#endregion
|
|
632
|
+
//#region src/infrastructure/node/open-url.ts
|
|
633
|
+
/** The browser launcher for this platform, or null where we know of none. */
|
|
634
|
+
function openCommand() {
|
|
635
|
+
if (process.platform === "darwin") return ["open", []];
|
|
636
|
+
if (process.platform === "win32") return ["cmd", [
|
|
637
|
+
"/c",
|
|
638
|
+
"start",
|
|
639
|
+
""
|
|
640
|
+
]];
|
|
641
|
+
if (process.platform === "linux") return ["xdg-open", []];
|
|
642
|
+
return null;
|
|
643
|
+
}
|
|
644
|
+
/**
|
|
645
|
+
* Hand a URL to the default browser.
|
|
646
|
+
*
|
|
647
|
+
* Detached and with its streams ignored, so the browser never holds the CLI
|
|
648
|
+
* open and never writes over the prompt still on screen. Returns false when no
|
|
649
|
+
* launcher exists, which leaves the printed URL as the way through.
|
|
650
|
+
*/
|
|
651
|
+
async function openUrl(url) {
|
|
652
|
+
const command = openCommand();
|
|
653
|
+
if (command === null) return false;
|
|
654
|
+
const [bin, args] = command;
|
|
655
|
+
return new Promise((resolve) => {
|
|
656
|
+
const child = spawn(bin, [...args, url], {
|
|
657
|
+
detached: true,
|
|
658
|
+
stdio: "ignore"
|
|
659
|
+
});
|
|
660
|
+
child.on("error", () => {
|
|
661
|
+
resolve(false);
|
|
662
|
+
});
|
|
663
|
+
child.unref();
|
|
664
|
+
resolve(true);
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
//#endregion
|
|
668
|
+
//#region src/infrastructure/terminal/key-press.ts
|
|
669
|
+
/** End of text, which the terminal sends for Ctrl+C in raw mode. */
|
|
670
|
+
const CTRL_C = "";
|
|
671
|
+
/** Watch terminal key presses and return a function that stops the watch. */
|
|
672
|
+
function onKey(handler) {
|
|
673
|
+
const input = process.stdin;
|
|
674
|
+
if (!input.isTTY) return () => void 0;
|
|
675
|
+
input.setRawMode(true);
|
|
676
|
+
input.resume();
|
|
677
|
+
input.setEncoding("utf8");
|
|
678
|
+
input.on("data", listener);
|
|
679
|
+
return stop;
|
|
680
|
+
function listener(chunk) {
|
|
681
|
+
if (chunk === CTRL_C) {
|
|
682
|
+
stop();
|
|
683
|
+
process.kill(process.pid, "SIGINT");
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
handler(chunk);
|
|
687
|
+
}
|
|
688
|
+
function stop() {
|
|
689
|
+
input.off("data", listener);
|
|
690
|
+
input.setRawMode(false);
|
|
691
|
+
input.pause();
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
//#endregion
|
|
695
|
+
//#region src/entrypoints/cli/pair-command.ts
|
|
696
|
+
/** Pair this machine with one Porte account. */
|
|
697
|
+
async function runPairCommand(input) {
|
|
698
|
+
const resources = createPairingResources(input.config);
|
|
699
|
+
const output = createOutput(input.stderr);
|
|
700
|
+
const { code, url, quiet, strong, ok } = output.emphasis;
|
|
701
|
+
const interactive = process.stdin.isTTY;
|
|
702
|
+
let stopWatching;
|
|
703
|
+
const paired = await pairHost({
|
|
704
|
+
authorizer: resources.authorizer,
|
|
705
|
+
credentials: resources.credentials,
|
|
706
|
+
baseUrl: input.config.baseUrl,
|
|
707
|
+
host: describeThisMachine(),
|
|
708
|
+
onPrompt: (prompt) => {
|
|
709
|
+
const shown = formatPairingCode(prompt.userCode);
|
|
710
|
+
const waiting = `Waiting for approval — the code expires in ${String(Math.round(prompt.expiresInSeconds / 60))} minutes. ${WAITING_EMOJI}`;
|
|
711
|
+
output.title("Pair this Mac with Porte", PAIR_EMOJI);
|
|
712
|
+
if (!interactive) {
|
|
713
|
+
output.raw(`First copy your pairing code: ${code(shown)}`);
|
|
714
|
+
output.raw(`Then open ${url(prompt.verificationUri)} in your browser.`);
|
|
715
|
+
output.blank();
|
|
716
|
+
output.raw(quiet(waiting));
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
const codeLine = (hint) => `First copy your pairing code: ${code(shown)} ${hint}`;
|
|
720
|
+
const promptLine = `${strong("Press Enter")} to open ${url(prompt.verificationUri)} in your browser...`;
|
|
721
|
+
output.raw(codeLine(quiet("(press c to copy)")));
|
|
722
|
+
output.prompt(promptLine);
|
|
723
|
+
stopWatching = onKey((key) => {
|
|
724
|
+
if (key === "\r") {
|
|
725
|
+
stopWatching?.();
|
|
726
|
+
output.blank();
|
|
727
|
+
output.blank();
|
|
728
|
+
output.raw(quiet(waiting));
|
|
729
|
+
openUrl(prompt.verificationUri);
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
if (key.toLowerCase() !== "c") return;
|
|
733
|
+
copyToClipboard(prompt.userCode).then((copied) => {
|
|
734
|
+
const hint = copied ? `${ok("✓")} ${quiet("copied")}` : quiet("✗ no clipboard");
|
|
735
|
+
output.rewrite(codeLine(hint), promptLine);
|
|
736
|
+
});
|
|
737
|
+
});
|
|
738
|
+
},
|
|
739
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
740
|
+
now: () => Date.now()
|
|
741
|
+
});
|
|
742
|
+
stopWatching?.();
|
|
743
|
+
if (paired.status === "denied") {
|
|
744
|
+
output.warned("Pairing was refused. Nothing was connected.");
|
|
745
|
+
output.note(`Run ${code("porte pair")} again to retry`);
|
|
746
|
+
return 1;
|
|
747
|
+
}
|
|
748
|
+
if (paired.status === "expired") {
|
|
749
|
+
output.warned("The code expired before anyone answered it.");
|
|
750
|
+
output.note(`Run ${code("porte pair")} for a new one`);
|
|
751
|
+
return 1;
|
|
752
|
+
}
|
|
753
|
+
const { account } = paired;
|
|
754
|
+
const machine = strong(describeThisMachine().name);
|
|
755
|
+
output.done(account === null ? `Paired ${machine} with Porte` : `Paired ${machine} with ${strong(account)} on Porte`);
|
|
756
|
+
output.blank();
|
|
757
|
+
output.raw(` Run ${code("porte up")} to control this Mac's Grok conversations from anywhere`);
|
|
758
|
+
output.note("Expires in 7 days if it never connects");
|
|
759
|
+
return 0;
|
|
760
|
+
}
|
|
761
|
+
//#endregion
|
|
762
|
+
//#region src/entrypoints/cli/parse-command.ts
|
|
763
|
+
/** Root help text. */
|
|
764
|
+
const HELP = `Usage:
|
|
765
|
+
porte <command> [options]
|
|
766
|
+
|
|
767
|
+
Pair, unpair, or connect this host to Porte.
|
|
768
|
+
|
|
769
|
+
Options:
|
|
770
|
+
-h, --help Show help
|
|
771
|
+
-V, --version Show version
|
|
772
|
+
-v, --verbose Write debug logs to stderr
|
|
773
|
+
|
|
774
|
+
Commands:
|
|
775
|
+
pair Link this Mac to your Porte account
|
|
776
|
+
unpair End this Mac's pairing
|
|
777
|
+
up Connect this host to Porte
|
|
778
|
+
|
|
779
|
+
Examples:
|
|
780
|
+
porte pair
|
|
781
|
+
porte up
|
|
782
|
+
`;
|
|
783
|
+
/** Help for `porte pair`. */
|
|
784
|
+
const PAIR_HELP = `Usage:
|
|
785
|
+
porte pair
|
|
786
|
+
|
|
787
|
+
Link this Mac to your Porte account. Prints a code to approve in any browser,
|
|
788
|
+
then waits. Run this once; \`porte up\` uses what it stores.
|
|
789
|
+
|
|
790
|
+
Options:
|
|
791
|
+
-h, --help Show this help
|
|
792
|
+
|
|
793
|
+
Environment:
|
|
794
|
+
PORTE_URL Porte origin. Defaults to https://useporte.dev
|
|
795
|
+
PORTE_DATA_DIRECTORY Host data directory. Defaults to ~/.porte
|
|
796
|
+
`;
|
|
797
|
+
/** Help for `porte unpair`. */
|
|
798
|
+
const UNPAIR_HELP = `Usage:
|
|
799
|
+
porte unpair
|
|
800
|
+
|
|
801
|
+
End this Mac's pairing. Porte stops accepting it, and the local credential is
|
|
802
|
+
deleted. Run \`porte pair\` to connect it again.
|
|
803
|
+
|
|
804
|
+
Options:
|
|
805
|
+
-h, --help Show this help
|
|
806
|
+
|
|
807
|
+
Environment:
|
|
808
|
+
PORTE_DATA_DIRECTORY Host data directory. Defaults to ~/.porte
|
|
809
|
+
`;
|
|
810
|
+
/** Help for `porte up`. */
|
|
811
|
+
const UP_HELP = `Usage:
|
|
812
|
+
porte up
|
|
813
|
+
|
|
814
|
+
Connect this host to Porte and stay connected. Run \`porte pair\` first.
|
|
815
|
+
|
|
816
|
+
Options:
|
|
817
|
+
-h, --help Show this help
|
|
818
|
+
-v, --verbose Write debug logs to stderr
|
|
819
|
+
|
|
820
|
+
Environment:
|
|
821
|
+
PORTE_URL Porte origin. Defaults to https://useporte.dev
|
|
822
|
+
PORTE_DATA_DIRECTORY Host data directory. Defaults to ~/.porte
|
|
823
|
+
`;
|
|
824
|
+
/**
|
|
825
|
+
* Parse POSIX flags and one subcommand.
|
|
826
|
+
*
|
|
827
|
+
* @param argv - Args after the binary name.
|
|
828
|
+
*/
|
|
829
|
+
function parseCommand(argv) {
|
|
830
|
+
let parsed;
|
|
831
|
+
try {
|
|
832
|
+
parsed = parseCommandArgs(argv);
|
|
833
|
+
} catch {
|
|
834
|
+
throw new UsageError({ message: HELP.trimEnd() });
|
|
835
|
+
}
|
|
836
|
+
const { values, positionals } = parsed;
|
|
837
|
+
if (values.help) {
|
|
838
|
+
const verb = positionals[0];
|
|
839
|
+
if (verb === "up") return {
|
|
840
|
+
kind: "help",
|
|
841
|
+
text: UP_HELP
|
|
842
|
+
};
|
|
843
|
+
if (verb === "pair") return {
|
|
844
|
+
kind: "help",
|
|
845
|
+
text: PAIR_HELP
|
|
846
|
+
};
|
|
847
|
+
if (verb === "unpair") return {
|
|
848
|
+
kind: "help",
|
|
849
|
+
text: UNPAIR_HELP
|
|
850
|
+
};
|
|
851
|
+
return {
|
|
852
|
+
kind: "help",
|
|
853
|
+
text: HELP
|
|
854
|
+
};
|
|
855
|
+
}
|
|
856
|
+
if (values.version) return { kind: "version" };
|
|
857
|
+
const verb = positionals[0];
|
|
858
|
+
if (verb === void 0) throw new UsageError({ message: HELP.trimEnd() });
|
|
859
|
+
if (verb === "up") {
|
|
860
|
+
if (positionals.length !== 1) throw new UsageError({ message: UP_HELP.trimEnd() });
|
|
861
|
+
return { kind: "up" };
|
|
862
|
+
}
|
|
863
|
+
if (verb === "pair") {
|
|
864
|
+
if (positionals.length !== 1) throw new UsageError({ message: PAIR_HELP.trimEnd() });
|
|
865
|
+
return { kind: "pair" };
|
|
866
|
+
}
|
|
867
|
+
if (verb === "unpair") {
|
|
868
|
+
if (positionals.length !== 1) throw new UsageError({ message: UNPAIR_HELP.trimEnd() });
|
|
869
|
+
return { kind: "unpair" };
|
|
870
|
+
}
|
|
871
|
+
throw new UsageError({ message: HELP.trimEnd() });
|
|
872
|
+
}
|
|
873
|
+
function parseCommandArgs(argv) {
|
|
874
|
+
return parseArgs({
|
|
875
|
+
args: [...argv],
|
|
876
|
+
options: {
|
|
877
|
+
help: {
|
|
878
|
+
type: "boolean",
|
|
879
|
+
short: "h",
|
|
880
|
+
default: false
|
|
881
|
+
},
|
|
882
|
+
version: {
|
|
883
|
+
type: "boolean",
|
|
884
|
+
short: "V",
|
|
885
|
+
default: false
|
|
886
|
+
},
|
|
887
|
+
verbose: {
|
|
888
|
+
type: "boolean",
|
|
889
|
+
short: "v",
|
|
890
|
+
default: false
|
|
891
|
+
}
|
|
892
|
+
},
|
|
893
|
+
allowPositionals: true,
|
|
894
|
+
strict: true
|
|
895
|
+
});
|
|
896
|
+
}
|
|
897
|
+
//#endregion
|
|
898
|
+
//#region src/domain/messages/types.ts
|
|
899
|
+
function createCommand(name, data) {
|
|
900
|
+
return {
|
|
901
|
+
type: "command",
|
|
902
|
+
name,
|
|
903
|
+
...data
|
|
904
|
+
};
|
|
905
|
+
}
|
|
906
|
+
function createEvent(name, data) {
|
|
907
|
+
return {
|
|
908
|
+
type: "event",
|
|
909
|
+
name,
|
|
910
|
+
...data
|
|
911
|
+
};
|
|
912
|
+
}
|
|
913
|
+
function createQuery(name, data) {
|
|
914
|
+
return {
|
|
915
|
+
type: "query",
|
|
916
|
+
name,
|
|
917
|
+
...data
|
|
918
|
+
};
|
|
919
|
+
}
|
|
920
|
+
//#endregion
|
|
921
|
+
//#region src/application/host-runtime.ts
|
|
922
|
+
/** Owns the active resources for one Host process. */
|
|
923
|
+
var HostRuntime = class {
|
|
924
|
+
signal;
|
|
925
|
+
deps;
|
|
926
|
+
constructor(signal, deps) {
|
|
927
|
+
this.signal = signal;
|
|
928
|
+
this.deps = deps;
|
|
929
|
+
}
|
|
930
|
+
/** Open the control connection and wait for shutdown. */
|
|
931
|
+
async run(onStatus) {
|
|
932
|
+
if (this.signal.aborted) return;
|
|
933
|
+
try {
|
|
934
|
+
this.deps.connections.connectControl(onStatus);
|
|
935
|
+
await waitForStop(this.signal, this.deps.connections.controlStopped);
|
|
936
|
+
} finally {
|
|
937
|
+
await this.shutdown();
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
/** Close conversations and the agent, let in-flight turns settle, then drop the sockets. */
|
|
941
|
+
async shutdown() {
|
|
942
|
+
try {
|
|
943
|
+
await this.deps.bus.handle(createCommand("CloseAllConversations", {}));
|
|
944
|
+
await this.deps.background.drain();
|
|
945
|
+
} finally {
|
|
946
|
+
this.deps.connections.closeAll();
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
};
|
|
950
|
+
function waitForStop(signal, controlStopped) {
|
|
951
|
+
if (signal.aborted) return Promise.resolve();
|
|
952
|
+
return new Promise((resolve, reject) => {
|
|
953
|
+
const stop = () => {
|
|
954
|
+
resolve();
|
|
955
|
+
};
|
|
956
|
+
signal.addEventListener("abort", stop, { once: true });
|
|
957
|
+
controlStopped.then(resolve, reject).finally(() => {
|
|
958
|
+
signal.removeEventListener("abort", stop);
|
|
959
|
+
});
|
|
960
|
+
});
|
|
961
|
+
}
|
|
962
|
+
//#endregion
|
|
963
|
+
//#region src/domain/messages/base.ts
|
|
964
|
+
const isCommand = (message) => message.type === "command";
|
|
965
|
+
const isEvent = (message) => message.type === "event";
|
|
966
|
+
const isQuery = (message) => message.type === "query";
|
|
967
|
+
h("HandlerNotImplementedError");
|
|
968
|
+
/** A message reached the bus with no registered handler. Programmer defect. */
|
|
969
|
+
var NoHandlerError = class extends h("NoHandlerError") {
|
|
970
|
+
constructor(args) {
|
|
971
|
+
super({
|
|
972
|
+
...args,
|
|
973
|
+
message: `No handler for ${args.kind} ${args.name}`,
|
|
974
|
+
classification: "terminal"
|
|
975
|
+
});
|
|
976
|
+
}
|
|
977
|
+
};
|
|
978
|
+
//#endregion
|
|
979
|
+
//#region src/application/handlers/answer-elicitation.ts
|
|
980
|
+
/** The `elicitation.resolved` event releases the parked agent request (see the registry). */
|
|
981
|
+
const answerElicitation = async (command, deps) => {
|
|
982
|
+
const conversation = deps.conversations.get(command.conversationId);
|
|
983
|
+
conversation.answerElicitation(command.turnId, command.elicitationId, command.answer);
|
|
984
|
+
deps.conversations.save(conversation);
|
|
985
|
+
};
|
|
986
|
+
//#endregion
|
|
987
|
+
//#region src/application/handlers/answer-permission.ts
|
|
988
|
+
/** The `permission.resolved` event releases the parked agent request (see the registry). */
|
|
989
|
+
const answerPermission = async (command, deps) => {
|
|
990
|
+
const conversation = deps.conversations.get(command.conversationId);
|
|
991
|
+
conversation.answerPermission(command.turnId, command.permissionId, command.optionId);
|
|
992
|
+
deps.conversations.save(conversation);
|
|
993
|
+
};
|
|
994
|
+
//#endregion
|
|
995
|
+
//#region src/application/handlers/apply-agent-update.ts
|
|
996
|
+
const logger$7 = createLogger("apply-agent-update");
|
|
997
|
+
/**
|
|
998
|
+
* Record what the agent streamed. Updates for a conversation already closed are
|
|
999
|
+
* dropped, and so are turn-scoped events for a turn that is not running: after
|
|
1000
|
+
* a cancel deadline the agent may keep talking about a turn the Host finished.
|
|
1001
|
+
*/
|
|
1002
|
+
const applyAgentUpdate = async (command, deps) => {
|
|
1003
|
+
const conversation = deps.conversations.find(command.conversationId);
|
|
1004
|
+
if (conversation === null) return;
|
|
1005
|
+
const turn = conversation.turn;
|
|
1006
|
+
const events = command.events.filter((event) => {
|
|
1007
|
+
if (!("turnId" in event)) return true;
|
|
1008
|
+
return turn.state === "running" && turn.turnId === event.turnId;
|
|
1009
|
+
});
|
|
1010
|
+
if (events.length < command.events.length) logger$7.debug("late_agent_events_dropped", { details: {
|
|
1011
|
+
conversationId: command.conversationId,
|
|
1012
|
+
dropped: command.events.length - events.length
|
|
1013
|
+
} });
|
|
1014
|
+
if (events.length === 0) return;
|
|
1015
|
+
conversation.applyAgentEvents(events);
|
|
1016
|
+
deps.conversations.save(conversation);
|
|
1017
|
+
};
|
|
1018
|
+
//#endregion
|
|
1019
|
+
//#region src/application/turn-policy.ts
|
|
1020
|
+
/**
|
|
1021
|
+
* How long a cancelled prompt may keep running before the Host closes the
|
|
1022
|
+
* agent session and finishes the turn itself.
|
|
1023
|
+
*/
|
|
1024
|
+
const CANCEL_DEADLINE_MS = 15e3;
|
|
1025
|
+
/** How long a conversation may sit with no running turn before it leaves this process. */
|
|
1026
|
+
const IDLE_EVICTION_MS = 18e5;
|
|
1027
|
+
//#endregion
|
|
1028
|
+
//#region src/application/handlers/cancel-turn.ts
|
|
1029
|
+
/**
|
|
1030
|
+
* Cancel the running turn. A turn that already ended is a no-op: cancel and
|
|
1031
|
+
* the natural end race, and both outcomes are final.
|
|
1032
|
+
*
|
|
1033
|
+
* Order matters: ACP wants pending permission requests answered as cancelled,
|
|
1034
|
+
* so the parked agent requests are released before `session/cancel`. The
|
|
1035
|
+
* outbox-driven release that follows is a no-op on an empty park. The deadline
|
|
1036
|
+
* then bounds an agent that never settles (`ExpireCancel`).
|
|
1037
|
+
*/
|
|
1038
|
+
const cancelTurn = async (command, deps) => {
|
|
1039
|
+
const conversation = deps.conversations.get(command.conversationId);
|
|
1040
|
+
if (conversation.turn.state !== "running" || conversation.turn.turnId !== command.turnId) return;
|
|
1041
|
+
const pending = conversation.snapshot().pending;
|
|
1042
|
+
conversation.cancelTurn(command.turnId);
|
|
1043
|
+
deps.conversations.save(conversation);
|
|
1044
|
+
for (const permission of pending.permissions) deps.codingAgent.resolvePermission(permission.permissionId, { type: "cancelled" });
|
|
1045
|
+
for (const elicitation of pending.elicitations) deps.codingAgent.resolveElicitation(elicitation.elicitationId, { type: "cancel" });
|
|
1046
|
+
await deps.codingAgent.cancel(command.conversationId);
|
|
1047
|
+
deps.scheduler.schedule(CANCEL_DEADLINE_MS, () => {
|
|
1048
|
+
deps.background.run(deps.bus.handle(createCommand("ExpireCancel", {
|
|
1049
|
+
conversationId: command.conversationId,
|
|
1050
|
+
turnId: command.turnId
|
|
1051
|
+
})));
|
|
1052
|
+
});
|
|
1053
|
+
};
|
|
1054
|
+
//#endregion
|
|
1055
|
+
//#region src/application/handlers/close-conversation.ts
|
|
1056
|
+
/** Drop one conversation from this process. Unknown ids are a no-op. */
|
|
1057
|
+
const closeConversation = async (command, deps) => {
|
|
1058
|
+
const conversation = deps.conversations.find(command.conversationId);
|
|
1059
|
+
if (conversation === null) return;
|
|
1060
|
+
if (conversation.turn.state === "running") await deps.codingAgent.cancel(conversation.id);
|
|
1061
|
+
conversation.close();
|
|
1062
|
+
deps.conversations.delete(conversation);
|
|
1063
|
+
await deps.codingAgent.closeSession(conversation.id);
|
|
1064
|
+
};
|
|
1065
|
+
//#endregion
|
|
1066
|
+
//#region src/application/handlers/close-all-conversations.ts
|
|
1067
|
+
/** Shutdown: close every open conversation, then stop the agent process. */
|
|
1068
|
+
const closeAllConversations = async (_command, deps) => {
|
|
1069
|
+
for (const conversation of deps.conversations.all()) await closeConversation(createCommand("CloseConversation", { conversationId: conversation.id }), deps);
|
|
1070
|
+
await deps.codingAgent.stop();
|
|
1071
|
+
};
|
|
1072
|
+
//#endregion
|
|
1073
|
+
//#region src/application/handlers/close-idle-conversation.ts
|
|
1074
|
+
/**
|
|
1075
|
+
* Bound the Host's memory: close a conversation whose last turn ended a full
|
|
1076
|
+
* idle window ago. A running turn or fresh activity is a no-op; the timer that
|
|
1077
|
+
* follows the next `FinishTurn` will look again. A returning viewer re-attaches
|
|
1078
|
+
* and the session reloads.
|
|
1079
|
+
*/
|
|
1080
|
+
const closeIdleConversation = async (command, deps) => {
|
|
1081
|
+
const conversation = deps.conversations.find(command.conversationId);
|
|
1082
|
+
if (conversation === null) return;
|
|
1083
|
+
if (conversation.turn.state === "running") return;
|
|
1084
|
+
if (deps.now().getTime() - Date.parse(conversation.lastActivityAt) < 18e5) return;
|
|
1085
|
+
await deps.bus.handle(createCommand("CloseConversation", { conversationId: command.conversationId }));
|
|
1086
|
+
};
|
|
1087
|
+
//#endregion
|
|
1088
|
+
//#region src/application/handlers/complete-elicitation.ts
|
|
1089
|
+
const completeElicitation = async (command, deps) => {
|
|
1090
|
+
const conversation = deps.conversations.get(command.conversationId);
|
|
1091
|
+
conversation.completeElicitation(command.elicitationId);
|
|
1092
|
+
deps.conversations.save(conversation);
|
|
1093
|
+
};
|
|
1094
|
+
//#endregion
|
|
1095
|
+
//#region src/domain/conversation/conversation-view-reducer.ts
|
|
1096
|
+
/** A canonical event cannot update the current conversation view. */
|
|
1097
|
+
var ConversationViewError = class extends h("ConversationViewError") {
|
|
1098
|
+
constructor(args) {
|
|
1099
|
+
super({
|
|
1100
|
+
...args,
|
|
1101
|
+
classification: "terminal"
|
|
1102
|
+
});
|
|
1103
|
+
}
|
|
1104
|
+
};
|
|
1105
|
+
/** The view of a conversation before any event. */
|
|
1106
|
+
function emptyConversationView() {
|
|
1107
|
+
return {
|
|
1108
|
+
items: [],
|
|
1109
|
+
tools: [],
|
|
1110
|
+
plans: [],
|
|
1111
|
+
pending: {
|
|
1112
|
+
permissions: [],
|
|
1113
|
+
elicitations: []
|
|
1114
|
+
}
|
|
1115
|
+
};
|
|
1116
|
+
}
|
|
1117
|
+
/**
|
|
1118
|
+
* Fold one canonical event into the view in place. Turn events are not the view's
|
|
1119
|
+
* concern; the aggregate tracks the turn. @throws ConversationViewError on a bad sequence.
|
|
1120
|
+
*/
|
|
1121
|
+
function applyConversationEvent(view, event) {
|
|
1122
|
+
switch (event.type) {
|
|
1123
|
+
case "message.started":
|
|
1124
|
+
addItem(view, {
|
|
1125
|
+
type: "message",
|
|
1126
|
+
turnId: event.turnId,
|
|
1127
|
+
messageId: event.messageId,
|
|
1128
|
+
role: event.role,
|
|
1129
|
+
content: []
|
|
1130
|
+
});
|
|
1131
|
+
return;
|
|
1132
|
+
case "reasoning.started":
|
|
1133
|
+
addItem(view, {
|
|
1134
|
+
type: "reasoning",
|
|
1135
|
+
turnId: event.turnId,
|
|
1136
|
+
messageId: event.messageId,
|
|
1137
|
+
content: []
|
|
1138
|
+
});
|
|
1139
|
+
return;
|
|
1140
|
+
case "message.delta":
|
|
1141
|
+
appendContent(view, event.messageId, "message", event.content);
|
|
1142
|
+
return;
|
|
1143
|
+
case "reasoning.delta":
|
|
1144
|
+
appendContent(view, event.messageId, "reasoning", event.content);
|
|
1145
|
+
return;
|
|
1146
|
+
case "tool.updated": {
|
|
1147
|
+
const index = view.tools.findIndex((tool) => tool.toolCallId === event.tool.toolCallId);
|
|
1148
|
+
if (index === -1) {
|
|
1149
|
+
view.tools.push(event.tool);
|
|
1150
|
+
view.items.push({
|
|
1151
|
+
type: "tool",
|
|
1152
|
+
turnId: event.turnId,
|
|
1153
|
+
toolCallId: event.tool.toolCallId
|
|
1154
|
+
});
|
|
1155
|
+
} else view.tools[index] = event.tool;
|
|
1156
|
+
return;
|
|
1157
|
+
}
|
|
1158
|
+
case "plan.updated":
|
|
1159
|
+
view.plans = [...view.plans.filter((plan) => plan.planId !== event.plan.planId), event.plan];
|
|
1160
|
+
return;
|
|
1161
|
+
case "plan.removed":
|
|
1162
|
+
view.plans = view.plans.filter((plan) => plan.planId !== event.planId);
|
|
1163
|
+
return;
|
|
1164
|
+
case "conversation.usage.updated":
|
|
1165
|
+
view.usage = event.usage;
|
|
1166
|
+
return;
|
|
1167
|
+
case "conversation.configuration.updated":
|
|
1168
|
+
view.configuration = [...event.options];
|
|
1169
|
+
return;
|
|
1170
|
+
case "conversation.commands.updated":
|
|
1171
|
+
view.commands = [...event.commands];
|
|
1172
|
+
return;
|
|
1173
|
+
case "conversation.mode.updated":
|
|
1174
|
+
view.modeId = event.modeId;
|
|
1175
|
+
return;
|
|
1176
|
+
case "permission.requested":
|
|
1177
|
+
view.pending.permissions.push({
|
|
1178
|
+
turnId: event.turnId,
|
|
1179
|
+
permissionId: event.permissionId,
|
|
1180
|
+
toolCallId: event.toolCallId,
|
|
1181
|
+
title: event.title,
|
|
1182
|
+
options: [...event.options]
|
|
1183
|
+
});
|
|
1184
|
+
return;
|
|
1185
|
+
case "permission.resolved":
|
|
1186
|
+
view.pending.permissions = view.pending.permissions.filter((permission) => permission.permissionId !== event.permissionId);
|
|
1187
|
+
return;
|
|
1188
|
+
case "elicitation.requested":
|
|
1189
|
+
view.pending.elicitations.push({
|
|
1190
|
+
turnId: event.turnId,
|
|
1191
|
+
elicitationId: event.elicitationId,
|
|
1192
|
+
request: event.request
|
|
1193
|
+
});
|
|
1194
|
+
return;
|
|
1195
|
+
case "elicitation.resolved":
|
|
1196
|
+
case "elicitation.completed":
|
|
1197
|
+
view.pending.elicitations = view.pending.elicitations.filter((elicitation) => elicitation.elicitationId !== event.elicitationId);
|
|
1198
|
+
return;
|
|
1199
|
+
case "turn.started":
|
|
1200
|
+
case "turn.finished":
|
|
1201
|
+
case "message.completed":
|
|
1202
|
+
case "reasoning.completed":
|
|
1203
|
+
case "conversation.metadata.updated":
|
|
1204
|
+
case "conversation.failed": return;
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
function addItem(view, item) {
|
|
1208
|
+
if (view.items.some((current) => current.type !== "tool" && current.messageId === item.messageId)) throw new ConversationViewError({ message: "The message already exists" });
|
|
1209
|
+
view.items.push(item);
|
|
1210
|
+
}
|
|
1211
|
+
function appendContent(view, messageId, type, content) {
|
|
1212
|
+
const item = view.items.find((current) => current.type !== "tool" && current.messageId === messageId);
|
|
1213
|
+
if (item === void 0 || item.type !== type) throw new ConversationViewError({ message: "The message does not exist" });
|
|
1214
|
+
item.content.push(content);
|
|
1215
|
+
}
|
|
1216
|
+
//#endregion
|
|
1217
|
+
//#region src/domain/conversation/message-identity.ts
|
|
1218
|
+
/**
|
|
1219
|
+
* Deterministic ids for what the coding agent does not name (§1: no `messageId`
|
|
1220
|
+
* on any chunk). The same transcript maps to the same ids on every load, so the
|
|
1221
|
+
* relay can replace its copy without a diff. The turn id itself is `turnIdFor`
|
|
1222
|
+
* in `@porte/core`, shared with the relay.
|
|
1223
|
+
*/
|
|
1224
|
+
function userMessageId(turnId) {
|
|
1225
|
+
return MessageIdSchema.parse(`${turnId}:user`);
|
|
1226
|
+
}
|
|
1227
|
+
/** `ordinal` counts stream boundaries inside the turn, starting at 1. */
|
|
1228
|
+
function assistantMessageId(turnId, ordinal) {
|
|
1229
|
+
return MessageIdSchema.parse(`${turnId}:assistant:${String(ordinal)}`);
|
|
1230
|
+
}
|
|
1231
|
+
function reasoningMessageId(turnId, ordinal) {
|
|
1232
|
+
return MessageIdSchema.parse(`${turnId}:reasoning:${String(ordinal)}`);
|
|
1233
|
+
}
|
|
1234
|
+
function permissionId(turnId, acpRequestId) {
|
|
1235
|
+
return PermissionIdSchema.parse(`${turnId}:permission:${String(acpRequestId)}`);
|
|
1236
|
+
}
|
|
1237
|
+
function elicitationId(turnId, acpRequestId) {
|
|
1238
|
+
return ElicitationIdSchema.parse(`${turnId}:elicitation:${String(acpRequestId)}`);
|
|
1239
|
+
}
|
|
1240
|
+
//#endregion
|
|
1241
|
+
//#region src/domain/entity.ts
|
|
1242
|
+
/**
|
|
1243
|
+
* Base for aggregates: identity, raised events, and the shape the repository stores.
|
|
1244
|
+
* `id` keeps the branded type from `TData` (`ConversationId`, Grok's session id);
|
|
1245
|
+
* the host mints no aggregate id itself.
|
|
1246
|
+
*/
|
|
1247
|
+
var Entity = class {
|
|
1248
|
+
events = [];
|
|
1249
|
+
addEvent(event) {
|
|
1250
|
+
this.events.push(event);
|
|
1251
|
+
}
|
|
1252
|
+
collectEvents() {
|
|
1253
|
+
return [...this.events];
|
|
1254
|
+
}
|
|
1255
|
+
clearEvents() {
|
|
1256
|
+
this.events = [];
|
|
1257
|
+
}
|
|
1258
|
+
};
|
|
1259
|
+
//#endregion
|
|
1260
|
+
//#region src/infrastructure/grok/git-root.ts
|
|
1261
|
+
/**
|
|
1262
|
+
* The repository a directory belongs to, or undefined outside one.
|
|
1263
|
+
*
|
|
1264
|
+
* Mirrors what Grok records as a session facet, for the one case Grok has not
|
|
1265
|
+
* recorded yet: a conversation this host is creating right now.
|
|
1266
|
+
*
|
|
1267
|
+
* Nearest match wins, so a submodule reports itself rather than its parent. A
|
|
1268
|
+
* `.git` file counts as well as a directory, which is how a worktree resolves
|
|
1269
|
+
* to the worktree rather than to the checkout it was cut from.
|
|
1270
|
+
*/
|
|
1271
|
+
function findGitRoot(cwd) {
|
|
1272
|
+
let directory = resolve(cwd);
|
|
1273
|
+
for (;;) {
|
|
1274
|
+
if (existsSync(`${directory}${sep}.git`)) return directory;
|
|
1275
|
+
const parent = dirname(directory);
|
|
1276
|
+
if (parent === directory) return void 0;
|
|
1277
|
+
directory = parent;
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
/**
|
|
1281
|
+
* One spelling for one repository.
|
|
1282
|
+
*
|
|
1283
|
+
* Grok writes its facet with a trailing separator and this host resolves paths
|
|
1284
|
+
* without one. Both are the same repository, and the browser groups on the
|
|
1285
|
+
* string, so they have to arrive spelled the same way.
|
|
1286
|
+
*/
|
|
1287
|
+
function normaliseGitRoot(gitRoot) {
|
|
1288
|
+
const trimmed = gitRoot.endsWith(sep) ? gitRoot.slice(0, -sep.length) : gitRoot;
|
|
1289
|
+
return trimmed.length > 0 ? trimmed : sep;
|
|
1290
|
+
}
|
|
1291
|
+
//#endregion
|
|
1292
|
+
//#region src/domain/conversation/conversation.ts
|
|
1293
|
+
/**
|
|
1294
|
+
* One coding conversation open on this process.
|
|
1295
|
+
*
|
|
1296
|
+
* Owns the live turn and the transcript. Every transition raises the canonical
|
|
1297
|
+
* `ConversationEvent` the relay consumes, folds it into `state`, and wraps it as
|
|
1298
|
+
* `ConversationEventRaised`. The coding agent stays the system of record; `replay`
|
|
1299
|
+
* rebuilds `state` from its history without raising.
|
|
1300
|
+
*/
|
|
1301
|
+
var Conversation = class Conversation extends Entity {
|
|
1302
|
+
data;
|
|
1303
|
+
constructor(data) {
|
|
1304
|
+
super();
|
|
1305
|
+
this.data = data;
|
|
1306
|
+
}
|
|
1307
|
+
/** Start an empty conversation in a git workspace. */
|
|
1308
|
+
static create(input) {
|
|
1309
|
+
return new Conversation({
|
|
1310
|
+
id: input.id,
|
|
1311
|
+
cwd: input.cwd,
|
|
1312
|
+
gitRoot: normaliseGitRoot(input.gitRoot),
|
|
1313
|
+
title: "",
|
|
1314
|
+
updatedAt: input.now.toISOString(),
|
|
1315
|
+
state: {
|
|
1316
|
+
turn: { state: "idle" },
|
|
1317
|
+
...emptyConversationView()
|
|
1318
|
+
},
|
|
1319
|
+
lastActivityAt: input.now.toISOString()
|
|
1320
|
+
});
|
|
1321
|
+
}
|
|
1322
|
+
/** Rebuild one conversation from coding-agent facts. Raises nothing. */
|
|
1323
|
+
static restore(input) {
|
|
1324
|
+
return new Conversation({
|
|
1325
|
+
id: input.id,
|
|
1326
|
+
cwd: input.cwd,
|
|
1327
|
+
gitRoot: normaliseGitRoot(input.gitRoot),
|
|
1328
|
+
title: input.title,
|
|
1329
|
+
updatedAt: input.updatedAt,
|
|
1330
|
+
state: {
|
|
1331
|
+
turn: { state: "idle" },
|
|
1332
|
+
...emptyConversationView()
|
|
1333
|
+
},
|
|
1334
|
+
lastActivityAt: input.updatedAt
|
|
1335
|
+
});
|
|
1336
|
+
}
|
|
1337
|
+
get id() {
|
|
1338
|
+
return this.data.id;
|
|
1339
|
+
}
|
|
1340
|
+
get cwd() {
|
|
1341
|
+
return this.data.cwd;
|
|
1342
|
+
}
|
|
1343
|
+
get gitRoot() {
|
|
1344
|
+
return this.data.gitRoot;
|
|
1345
|
+
}
|
|
1346
|
+
get title() {
|
|
1347
|
+
return this.data.title;
|
|
1348
|
+
}
|
|
1349
|
+
get updatedAt() {
|
|
1350
|
+
return this.data.updatedAt;
|
|
1351
|
+
}
|
|
1352
|
+
get turn() {
|
|
1353
|
+
return this.data.state.turn;
|
|
1354
|
+
}
|
|
1355
|
+
/** A copy; later transitions do not change it. */
|
|
1356
|
+
snapshot() {
|
|
1357
|
+
return structuredClone(this.data.state);
|
|
1358
|
+
}
|
|
1359
|
+
toPlainObject() {
|
|
1360
|
+
return this.data;
|
|
1361
|
+
}
|
|
1362
|
+
/** Fold the agent's history into the transcript without raising. Idle only. */
|
|
1363
|
+
replay(events) {
|
|
1364
|
+
if (this.data.state.turn.state !== "idle") throw new ConversationBusyError();
|
|
1365
|
+
for (const event of events) this.fold(event);
|
|
1366
|
+
}
|
|
1367
|
+
/**
|
|
1368
|
+
* Start a turn with the user's message and return the turn id this aggregate minted.
|
|
1369
|
+
*
|
|
1370
|
+
* The id is `turnIdFor(id, promptIndex)`, with `promptIndex` predicted as the
|
|
1371
|
+
* count of user messages so far; the mapper checks it against Grok's own. A
|
|
1372
|
+
* repeated `attemptId`, running or the last finished one, returns the same
|
|
1373
|
+
* turn and starts nothing. Another turn running is `ConversationBusyError`.
|
|
1374
|
+
*/
|
|
1375
|
+
beginTurn(attemptId, userMessage) {
|
|
1376
|
+
const last = this.data.lastAttempt;
|
|
1377
|
+
if (last?.attemptId === attemptId) return last.turnId;
|
|
1378
|
+
if (this.data.state.turn.state === "running") throw new ConversationBusyError();
|
|
1379
|
+
const promptIndex = this.data.state.items.filter((item) => item.type === "message" && item.role === "user").length;
|
|
1380
|
+
const turnId = turnIdFor(this.data.id, promptIndex);
|
|
1381
|
+
const messageId = userMessageId(turnId);
|
|
1382
|
+
this.data = {
|
|
1383
|
+
...this.data,
|
|
1384
|
+
state: {
|
|
1385
|
+
...this.data.state,
|
|
1386
|
+
turn: {
|
|
1387
|
+
state: "running",
|
|
1388
|
+
turnId,
|
|
1389
|
+
attemptId
|
|
1390
|
+
}
|
|
1391
|
+
},
|
|
1392
|
+
lastAttempt: {
|
|
1393
|
+
attemptId,
|
|
1394
|
+
turnId,
|
|
1395
|
+
promptIndex
|
|
1396
|
+
}
|
|
1397
|
+
};
|
|
1398
|
+
this.raise({
|
|
1399
|
+
type: "turn.started",
|
|
1400
|
+
turnId,
|
|
1401
|
+
attemptId
|
|
1402
|
+
});
|
|
1403
|
+
this.raise({
|
|
1404
|
+
type: "message.started",
|
|
1405
|
+
turnId,
|
|
1406
|
+
messageId,
|
|
1407
|
+
role: "user"
|
|
1408
|
+
});
|
|
1409
|
+
for (const content of userMessage.content) this.raise({
|
|
1410
|
+
type: "message.delta",
|
|
1411
|
+
turnId,
|
|
1412
|
+
messageId,
|
|
1413
|
+
content
|
|
1414
|
+
});
|
|
1415
|
+
this.raise({
|
|
1416
|
+
type: "message.completed",
|
|
1417
|
+
turnId,
|
|
1418
|
+
messageId
|
|
1419
|
+
});
|
|
1420
|
+
return turnId;
|
|
1421
|
+
}
|
|
1422
|
+
/** The prompt index behind the running or last turn, for the mapper's check. */
|
|
1423
|
+
promptIndexOf(turnId) {
|
|
1424
|
+
const last = this.data.lastAttempt;
|
|
1425
|
+
if (last === void 0 || last.turnId !== turnId) throw new TurnNotFoundError();
|
|
1426
|
+
return last.promptIndex;
|
|
1427
|
+
}
|
|
1428
|
+
/** The aggregate's current view of one tool call, for partial ACP updates. */
|
|
1429
|
+
findTool(toolCallId) {
|
|
1430
|
+
return this.data.state.tools.find((tool) => tool.toolCallId === toolCallId);
|
|
1431
|
+
}
|
|
1432
|
+
/** Record activity for idle eviction. */
|
|
1433
|
+
touch(now) {
|
|
1434
|
+
this.data = {
|
|
1435
|
+
...this.data,
|
|
1436
|
+
lastActivityAt: now
|
|
1437
|
+
};
|
|
1438
|
+
}
|
|
1439
|
+
get lastActivityAt() {
|
|
1440
|
+
return this.data.lastActivityAt;
|
|
1441
|
+
}
|
|
1442
|
+
/** Park a permission request on the running turn. */
|
|
1443
|
+
requestPermission(request) {
|
|
1444
|
+
const turnId = this.runningTurnId();
|
|
1445
|
+
this.raise({
|
|
1446
|
+
type: "permission.requested",
|
|
1447
|
+
turnId,
|
|
1448
|
+
...request
|
|
1449
|
+
});
|
|
1450
|
+
}
|
|
1451
|
+
answerPermission(turnId, permissionId, optionId) {
|
|
1452
|
+
const pending = this.data.state.pending.permissions.find((permission) => permission.turnId === turnId && permission.permissionId === permissionId);
|
|
1453
|
+
if (pending === void 0 || !pending.options.some((option) => option.optionId === optionId)) throw new PermissionNotFoundError();
|
|
1454
|
+
this.raise({
|
|
1455
|
+
type: "permission.resolved",
|
|
1456
|
+
turnId,
|
|
1457
|
+
permissionId,
|
|
1458
|
+
outcome: {
|
|
1459
|
+
type: "selected",
|
|
1460
|
+
optionId
|
|
1461
|
+
}
|
|
1462
|
+
});
|
|
1463
|
+
}
|
|
1464
|
+
/** Park an elicitation on the running turn. */
|
|
1465
|
+
requestElicitation(request) {
|
|
1466
|
+
const turnId = this.runningTurnId();
|
|
1467
|
+
this.raise({
|
|
1468
|
+
type: "elicitation.requested",
|
|
1469
|
+
turnId,
|
|
1470
|
+
...request
|
|
1471
|
+
});
|
|
1472
|
+
}
|
|
1473
|
+
answerElicitation(turnId, elicitationId, answer) {
|
|
1474
|
+
if (this.data.state.pending.elicitations.find((elicitation) => elicitation.turnId === turnId && elicitation.elicitationId === elicitationId) === void 0) throw new ElicitationNotFoundError();
|
|
1475
|
+
this.raise({
|
|
1476
|
+
type: "elicitation.resolved",
|
|
1477
|
+
turnId,
|
|
1478
|
+
elicitationId,
|
|
1479
|
+
outcome: elicitationOutcome(answer)
|
|
1480
|
+
});
|
|
1481
|
+
}
|
|
1482
|
+
/** The agent reports a URL elicitation finished after the user accepted it. */
|
|
1483
|
+
completeElicitation(elicitationId) {
|
|
1484
|
+
const turnId = this.runningTurnId();
|
|
1485
|
+
this.raise({
|
|
1486
|
+
type: "elicitation.completed",
|
|
1487
|
+
turnId,
|
|
1488
|
+
elicitationId
|
|
1489
|
+
});
|
|
1490
|
+
}
|
|
1491
|
+
/**
|
|
1492
|
+
* Resolve every pending interaction as cancelled. The turn stays running until
|
|
1493
|
+
* the agent answers the prompt with `cancelled`. A turn that is not running is
|
|
1494
|
+
* a no-op: cancel and the natural end may race, and both outcomes are final.
|
|
1495
|
+
*/
|
|
1496
|
+
cancelTurn(turnId) {
|
|
1497
|
+
const turn = this.data.state.turn;
|
|
1498
|
+
if (turn.state !== "running" || turn.turnId !== turnId) return;
|
|
1499
|
+
this.cancelPending(turnId);
|
|
1500
|
+
}
|
|
1501
|
+
/** One turn's slice of the transcript, for `turn.get`. */
|
|
1502
|
+
turnTranscript(turnId) {
|
|
1503
|
+
const items = this.data.state.items.filter((item) => item.turnId === turnId);
|
|
1504
|
+
if (items.length === 0) throw new TurnNotFoundError();
|
|
1505
|
+
const toolCallIds = new Set(items.flatMap((item) => item.type === "tool" ? [item.toolCallId] : []));
|
|
1506
|
+
const tools = this.data.state.tools.filter((tool) => toolCallIds.has(tool.toolCallId));
|
|
1507
|
+
const last = this.data.lastAttempt;
|
|
1508
|
+
const slice = last?.turnId === turnId ? {
|
|
1509
|
+
turnId,
|
|
1510
|
+
attemptId: last.attemptId,
|
|
1511
|
+
items,
|
|
1512
|
+
tools
|
|
1513
|
+
} : {
|
|
1514
|
+
turnId,
|
|
1515
|
+
items,
|
|
1516
|
+
tools
|
|
1517
|
+
};
|
|
1518
|
+
return structuredClone(slice);
|
|
1519
|
+
}
|
|
1520
|
+
/** End the turn. A turn that already ended is a no-op. */
|
|
1521
|
+
finishTurn(turnId, outcome) {
|
|
1522
|
+
const turn = this.data.state.turn;
|
|
1523
|
+
if (turn.state !== "running" || turn.turnId !== turnId) return;
|
|
1524
|
+
this.cancelPending(turnId);
|
|
1525
|
+
this.data.state.turn = { state: "idle" };
|
|
1526
|
+
this.raise({
|
|
1527
|
+
type: "turn.finished",
|
|
1528
|
+
turnId,
|
|
1529
|
+
outcome
|
|
1530
|
+
});
|
|
1531
|
+
}
|
|
1532
|
+
/** Leave this process: pending interactions resolve as cancelled, the socket can go. */
|
|
1533
|
+
close() {
|
|
1534
|
+
const turn = this.data.state.turn;
|
|
1535
|
+
if (turn.state === "running") this.cancelPending(turn.turnId);
|
|
1536
|
+
this.addEvent(createEvent("ConversationClosed", { conversationId: this.data.id }));
|
|
1537
|
+
}
|
|
1538
|
+
applyMetadata(update) {
|
|
1539
|
+
this.data = {
|
|
1540
|
+
...this.data,
|
|
1541
|
+
title: update.title === void 0 ? this.data.title : update.title ?? "",
|
|
1542
|
+
updatedAt: update.updatedAt ?? this.data.updatedAt
|
|
1543
|
+
};
|
|
1544
|
+
this.raise({
|
|
1545
|
+
type: "conversation.metadata.updated",
|
|
1546
|
+
update
|
|
1547
|
+
});
|
|
1548
|
+
}
|
|
1549
|
+
/**
|
|
1550
|
+
* Record events the coding agent produced during the live turn. Turn-scoped
|
|
1551
|
+
* events need the running turn; metadata folds into the aggregate too.
|
|
1552
|
+
*/
|
|
1553
|
+
applyAgentEvents(events) {
|
|
1554
|
+
for (const event of events) {
|
|
1555
|
+
if (event.type === "conversation.metadata.updated") {
|
|
1556
|
+
this.applyMetadata(event.update);
|
|
1557
|
+
continue;
|
|
1558
|
+
}
|
|
1559
|
+
if ("turnId" in event) this.requireTurn(event.turnId);
|
|
1560
|
+
this.raise(event);
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
raise(event) {
|
|
1564
|
+
this.fold(event);
|
|
1565
|
+
this.addEvent(createEvent("ConversationEventRaised", {
|
|
1566
|
+
conversationId: this.data.id,
|
|
1567
|
+
event
|
|
1568
|
+
}));
|
|
1569
|
+
}
|
|
1570
|
+
fold(event) {
|
|
1571
|
+
applyConversationEvent(this.data.state, event);
|
|
1572
|
+
}
|
|
1573
|
+
runningTurnId() {
|
|
1574
|
+
const turn = this.data.state.turn;
|
|
1575
|
+
if (turn.state !== "running") throw new TurnNotFoundError();
|
|
1576
|
+
return turn.turnId;
|
|
1577
|
+
}
|
|
1578
|
+
requireTurn(turnId) {
|
|
1579
|
+
if (this.runningTurnId() !== turnId) throw new TurnNotFoundError();
|
|
1580
|
+
}
|
|
1581
|
+
cancelPending(turnId) {
|
|
1582
|
+
const { permissions, elicitations } = this.data.state.pending;
|
|
1583
|
+
for (const { permissionId } of permissions) this.raise({
|
|
1584
|
+
type: "permission.resolved",
|
|
1585
|
+
turnId,
|
|
1586
|
+
permissionId,
|
|
1587
|
+
outcome: { type: "cancelled" }
|
|
1588
|
+
});
|
|
1589
|
+
for (const { elicitationId } of elicitations) this.raise({
|
|
1590
|
+
type: "elicitation.resolved",
|
|
1591
|
+
turnId,
|
|
1592
|
+
elicitationId,
|
|
1593
|
+
outcome: { type: "cancelled" }
|
|
1594
|
+
});
|
|
1595
|
+
}
|
|
1596
|
+
};
|
|
1597
|
+
function elicitationOutcome(answer) {
|
|
1598
|
+
switch (answer.type) {
|
|
1599
|
+
case "submit": return {
|
|
1600
|
+
type: "submitted",
|
|
1601
|
+
values: answer.values
|
|
1602
|
+
};
|
|
1603
|
+
case "accept": return { type: "accepted" };
|
|
1604
|
+
case "decline": return { type: "declined" };
|
|
1605
|
+
case "cancel": return { type: "cancelled" };
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
//#endregion
|
|
1609
|
+
//#region src/application/handlers/create-conversation.ts
|
|
1610
|
+
/** Create one coding-agent session in a git workspace and open it on this process. */
|
|
1611
|
+
const createConversation = async (command, deps) => {
|
|
1612
|
+
const gitRoot = findGitRoot(command.cwd);
|
|
1613
|
+
if (gitRoot === void 0) throw new WorkspaceNotAllowedError();
|
|
1614
|
+
const session = await deps.codingAgent.createSession(command);
|
|
1615
|
+
const conversation = Conversation.create({
|
|
1616
|
+
id: session.id,
|
|
1617
|
+
cwd: command.cwd,
|
|
1618
|
+
gitRoot,
|
|
1619
|
+
now: deps.now()
|
|
1620
|
+
});
|
|
1621
|
+
conversation.applyAgentEvents(session.events);
|
|
1622
|
+
deps.conversations.insert(conversation);
|
|
1623
|
+
return makeConversationSummary(conversation);
|
|
1624
|
+
};
|
|
1625
|
+
//#endregion
|
|
1626
|
+
//#region src/application/handlers/drop-conversation-socket.ts
|
|
1627
|
+
/** Effect: a closed conversation needs no relay socket. */
|
|
1628
|
+
const dropConversationSocket = async (event, deps) => {
|
|
1629
|
+
deps.connections.closeConversation(event.conversationId);
|
|
1630
|
+
};
|
|
1631
|
+
//#endregion
|
|
1632
|
+
//#region src/application/handlers/expire-cancel.ts
|
|
1633
|
+
const logger$6 = createLogger("expire-cancel");
|
|
1634
|
+
/**
|
|
1635
|
+
* The cancel deadline fired. A prompt that settled meanwhile is a no-op; one
|
|
1636
|
+
* still running has an unresponsive agent: close its session so the mapper and
|
|
1637
|
+
* process state go with it, then finish the turn as cancelled. The next turn
|
|
1638
|
+
* reloads the session.
|
|
1639
|
+
*/
|
|
1640
|
+
const expireCancel = async (command, deps) => {
|
|
1641
|
+
const conversation = deps.conversations.find(command.conversationId);
|
|
1642
|
+
if (conversation === null) return;
|
|
1643
|
+
const turn = conversation.turn;
|
|
1644
|
+
if (turn.state !== "running" || turn.turnId !== command.turnId) return;
|
|
1645
|
+
logger$6.warn("cancel_deadline_expired", {
|
|
1646
|
+
error: new AgentUnresponsiveError(),
|
|
1647
|
+
details: {
|
|
1648
|
+
conversationId: command.conversationId,
|
|
1649
|
+
turnId: command.turnId
|
|
1650
|
+
}
|
|
1651
|
+
});
|
|
1652
|
+
await deps.codingAgent.closeSession(command.conversationId);
|
|
1653
|
+
await deps.bus.handle(createCommand("FinishTurn", {
|
|
1654
|
+
conversationId: command.conversationId,
|
|
1655
|
+
turnId: command.turnId,
|
|
1656
|
+
outcome: { type: "cancelled" }
|
|
1657
|
+
}));
|
|
1658
|
+
};
|
|
1659
|
+
//#endregion
|
|
1660
|
+
//#region src/application/handlers/finish-turn.ts
|
|
1661
|
+
/** End a turn and start the idle clock. A conversation closed meanwhile has nothing to end. */
|
|
1662
|
+
const finishTurn = async (command, deps) => {
|
|
1663
|
+
const conversation = deps.conversations.find(command.conversationId);
|
|
1664
|
+
if (conversation === null) return;
|
|
1665
|
+
if (command.usage !== void 0) conversation.applyAgentEvents([{
|
|
1666
|
+
type: "conversation.usage.updated",
|
|
1667
|
+
usage: command.usage
|
|
1668
|
+
}]);
|
|
1669
|
+
conversation.finishTurn(command.turnId, command.outcome);
|
|
1670
|
+
conversation.touch(IsoDateTimeSchema.parse(deps.now().toISOString()));
|
|
1671
|
+
deps.conversations.save(conversation);
|
|
1672
|
+
const { conversationId } = command;
|
|
1673
|
+
deps.scheduler.schedule(IDLE_EVICTION_MS, () => {
|
|
1674
|
+
deps.background.run(deps.bus.handle(createCommand("CloseIdleConversation", { conversationId })));
|
|
1675
|
+
});
|
|
1676
|
+
};
|
|
1677
|
+
//#endregion
|
|
1678
|
+
//#region src/application/handlers/get-conversation.ts
|
|
1679
|
+
/** The open conversation snapshot for the Worker first paint. */
|
|
1680
|
+
const getConversation = async (query, deps) => omitUnpersistableContent(deps.conversations.get(query.conversationId).snapshot());
|
|
1681
|
+
/** Drop tool bodies and media bytes the Worker SQLite row cannot store. */
|
|
1682
|
+
function omitUnpersistableContent(state) {
|
|
1683
|
+
return {
|
|
1684
|
+
...state,
|
|
1685
|
+
items: state.items.map(omitUnpersistableItem),
|
|
1686
|
+
tools: state.tools.map(omitUnpersistableTool)
|
|
1687
|
+
};
|
|
1688
|
+
}
|
|
1689
|
+
/** Drop tool bodies and media bytes from one turn slice, as `conversation.get` does. */
|
|
1690
|
+
function omitUnpersistableTurn(turn) {
|
|
1691
|
+
return {
|
|
1692
|
+
turnId: turn.turnId,
|
|
1693
|
+
items: turn.items.map(omitUnpersistableItem),
|
|
1694
|
+
tools: turn.tools.map(omitUnpersistableTool)
|
|
1695
|
+
};
|
|
1696
|
+
}
|
|
1697
|
+
function omitUnpersistableItem(item) {
|
|
1698
|
+
if (item.type === "tool") return item;
|
|
1699
|
+
return {
|
|
1700
|
+
...item,
|
|
1701
|
+
content: item.content.filter(isPersistableContent)
|
|
1702
|
+
};
|
|
1703
|
+
}
|
|
1704
|
+
function isPersistableContent(content) {
|
|
1705
|
+
return content.type === "text" || content.type === "resource-link";
|
|
1706
|
+
}
|
|
1707
|
+
function omitUnpersistableTool(tool) {
|
|
1708
|
+
const next = {
|
|
1709
|
+
toolCallId: tool.toolCallId,
|
|
1710
|
+
title: tool.title,
|
|
1711
|
+
kind: tool.kind,
|
|
1712
|
+
status: tool.status,
|
|
1713
|
+
content: [],
|
|
1714
|
+
locations: tool.locations
|
|
1715
|
+
};
|
|
1716
|
+
if (tool.name !== void 0) next.name = tool.name;
|
|
1717
|
+
if (tool._meta !== void 0) next._meta = tool._meta;
|
|
1718
|
+
return next;
|
|
1719
|
+
}
|
|
1720
|
+
//#endregion
|
|
1721
|
+
//#region src/application/handlers/get-turn.ts
|
|
1722
|
+
/** One turn's items and tools, so the relay can write the finished turn under stable ids. */
|
|
1723
|
+
const getTurn = async (query, deps) => omitUnpersistableTurn(deps.conversations.get(query.conversationId).turnTranscript(query.turnId));
|
|
1724
|
+
//#endregion
|
|
1725
|
+
//#region src/application/handlers/list-conversations.ts
|
|
1726
|
+
/** One page of the agent's conversation list; rows without a git root are not conversations. */
|
|
1727
|
+
const listConversations = async (query, deps) => {
|
|
1728
|
+
const page = await deps.codingAgent.listSessions(query.cursor);
|
|
1729
|
+
const conversations = page.sessions.map(makeConversationSummary);
|
|
1730
|
+
return page.next === void 0 ? { conversations } : {
|
|
1731
|
+
conversations,
|
|
1732
|
+
next: page.next
|
|
1733
|
+
};
|
|
1734
|
+
};
|
|
1735
|
+
//#endregion
|
|
1736
|
+
//#region src/application/handlers/open-conversation.ts
|
|
1737
|
+
/**
|
|
1738
|
+
* Load an existing session onto this process and fold its history. Runs on
|
|
1739
|
+
* every conversation socket open; a conversation that is already open stays as is.
|
|
1740
|
+
*/
|
|
1741
|
+
const openConversation = async (command, deps) => {
|
|
1742
|
+
if (deps.conversations.find(command.conversationId) !== null) return;
|
|
1743
|
+
const gitRoot = findGitRoot(command.cwd);
|
|
1744
|
+
if (gitRoot === void 0) throw new WorkspaceNotAllowedError();
|
|
1745
|
+
const loaded = await deps.codingAgent.loadSession(command.conversationId, command.cwd);
|
|
1746
|
+
const conversation = Conversation.restore({
|
|
1747
|
+
id: command.conversationId,
|
|
1748
|
+
cwd: command.cwd,
|
|
1749
|
+
gitRoot,
|
|
1750
|
+
title: loaded.title,
|
|
1751
|
+
updatedAt: IsoDateTimeSchema.parse(deps.now().toISOString())
|
|
1752
|
+
});
|
|
1753
|
+
conversation.replay(loaded.events);
|
|
1754
|
+
conversation.touch(IsoDateTimeSchema.parse(deps.now().toISOString()));
|
|
1755
|
+
deps.conversations.insert(conversation);
|
|
1756
|
+
};
|
|
1757
|
+
//#endregion
|
|
1758
|
+
//#region src/application/handlers/publish-conversation-event.ts
|
|
1759
|
+
/** Effect: forward the canonical event to the relay; metadata also goes to the control socket. */
|
|
1760
|
+
const publishConversationEvent = async (raised, deps) => {
|
|
1761
|
+
if (raised.event.type === "conversation.metadata.updated") deps.connections.control.conversationUpdated(raised.conversationId, raised.event.update);
|
|
1762
|
+
deps.connections.conversation(raised.conversationId)?.sendEvent(raised.event);
|
|
1763
|
+
};
|
|
1764
|
+
//#endregion
|
|
1765
|
+
//#region src/application/handlers/release-parked-request.ts
|
|
1766
|
+
/** Effect: a resolved permission or elicitation answers the agent request parked for it. */
|
|
1767
|
+
const releaseParkedRequest = async (raised, deps) => {
|
|
1768
|
+
const event = raised.event;
|
|
1769
|
+
if (event.type === "permission.resolved") deps.codingAgent.resolvePermission(event.permissionId, event.outcome);
|
|
1770
|
+
else if (event.type === "elicitation.resolved") deps.codingAgent.resolveElicitation(event.elicitationId, toAnswer(event.outcome));
|
|
1771
|
+
};
|
|
1772
|
+
function toAnswer(outcome) {
|
|
1773
|
+
switch (outcome.type) {
|
|
1774
|
+
case "submitted": return {
|
|
1775
|
+
type: "submit",
|
|
1776
|
+
values: outcome.values
|
|
1777
|
+
};
|
|
1778
|
+
case "accepted": return { type: "accept" };
|
|
1779
|
+
case "declined": return { type: "decline" };
|
|
1780
|
+
case "cancelled": return { type: "cancel" };
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1783
|
+
//#endregion
|
|
1784
|
+
//#region src/application/handlers/request-elicitation.ts
|
|
1785
|
+
const requestElicitation = async (command, deps) => {
|
|
1786
|
+
const conversation = deps.conversations.get(command.conversationId);
|
|
1787
|
+
conversation.requestElicitation({
|
|
1788
|
+
elicitationId: command.elicitationId,
|
|
1789
|
+
request: command.request
|
|
1790
|
+
});
|
|
1791
|
+
deps.conversations.save(conversation);
|
|
1792
|
+
};
|
|
1793
|
+
//#endregion
|
|
1794
|
+
//#region src/application/handlers/request-permission.ts
|
|
1795
|
+
const requestPermission = async (command, deps) => {
|
|
1796
|
+
const conversation = deps.conversations.get(command.conversationId);
|
|
1797
|
+
conversation.requestPermission({
|
|
1798
|
+
permissionId: command.permissionId,
|
|
1799
|
+
toolCallId: command.toolCallId,
|
|
1800
|
+
title: command.title,
|
|
1801
|
+
options: command.options
|
|
1802
|
+
});
|
|
1803
|
+
deps.conversations.save(conversation);
|
|
1804
|
+
};
|
|
1805
|
+
//#endregion
|
|
1806
|
+
//#region src/application/handlers/set-model.ts
|
|
1807
|
+
/** Switch the agent model; the relay learns the new value from `conversation.configuration.updated`. */
|
|
1808
|
+
const setModel = async (command, deps) => {
|
|
1809
|
+
const conversation = deps.conversations.get(command.conversationId);
|
|
1810
|
+
const events = await deps.codingAgent.setModel(command.conversationId, command.modelId);
|
|
1811
|
+
conversation.applyAgentEvents(events);
|
|
1812
|
+
deps.conversations.save(conversation);
|
|
1813
|
+
};
|
|
1814
|
+
//#endregion
|
|
1815
|
+
//#region src/application/handlers/start-turn.ts
|
|
1816
|
+
/**
|
|
1817
|
+
* Begin the turn now, run the prompt in the background, and end the turn with
|
|
1818
|
+
* `FinishTurn` when the agent answers. The relay sees `turn.started` before this returns.
|
|
1819
|
+
*
|
|
1820
|
+
* The aggregate mints the turn id and dedupes the attempt; a repeated attempt
|
|
1821
|
+
* begins nothing and sends no second prompt. A session the process no longer
|
|
1822
|
+
* holds (cancel deadline, idle eviction) is loaded again first.
|
|
1823
|
+
*/
|
|
1824
|
+
const startTurn = async (command, deps) => {
|
|
1825
|
+
const conversation = deps.conversations.get(command.conversationId);
|
|
1826
|
+
if (!deps.codingAgent.isOpen(conversation.id)) await deps.codingAgent.loadSession(conversation.id, conversation.cwd);
|
|
1827
|
+
const wasRunning = conversation.turn.state === "running";
|
|
1828
|
+
const turnId = conversation.beginTurn(command.attemptId, command.userMessage);
|
|
1829
|
+
const turnAfter = conversation.turn;
|
|
1830
|
+
if (wasRunning || turnAfter.state !== "running") {
|
|
1831
|
+
deps.conversations.save(conversation);
|
|
1832
|
+
return;
|
|
1833
|
+
}
|
|
1834
|
+
const promptIndex = conversation.promptIndexOf(turnId);
|
|
1835
|
+
conversation.touch(IsoDateTimeSchema.parse(deps.now().toISOString()));
|
|
1836
|
+
deps.conversations.save(conversation);
|
|
1837
|
+
const { conversationId } = command;
|
|
1838
|
+
deps.background.run(deps.codingAgent.prompt(conversationId, turnId, promptIndex, command.userMessage.content).then((result) => {
|
|
1839
|
+
const finish = {
|
|
1840
|
+
conversationId,
|
|
1841
|
+
turnId,
|
|
1842
|
+
outcome: result.outcome
|
|
1843
|
+
};
|
|
1844
|
+
if (result.usage !== void 0) finish.usage = result.usage;
|
|
1845
|
+
return deps.bus.handle(createCommand("FinishTurn", finish));
|
|
1846
|
+
}, (cause) => deps.bus.handle(createCommand("FinishTurn", {
|
|
1847
|
+
conversationId,
|
|
1848
|
+
turnId,
|
|
1849
|
+
outcome: failedOutcome(cause)
|
|
1850
|
+
}))));
|
|
1851
|
+
};
|
|
1852
|
+
/** The relay contract allows three tags; anything not transient is internal. */
|
|
1853
|
+
function failedOutcome(cause) {
|
|
1854
|
+
const message = cause instanceof Error && cause.message.length > 0 ? cause.message : "Turn failed";
|
|
1855
|
+
return {
|
|
1856
|
+
type: "failed",
|
|
1857
|
+
error: {
|
|
1858
|
+
_tag: isClassifiedError(cause) && cause.classification === "transient" ? CODING_AGENT_UNAVAILABLE_ERROR : INTERNAL_SERVER_ERROR,
|
|
1859
|
+
message
|
|
1860
|
+
}
|
|
1861
|
+
};
|
|
1862
|
+
}
|
|
1863
|
+
const DEFAULT_REGISTRY = {
|
|
1864
|
+
commands: {
|
|
1865
|
+
CreateConversation: createConversation,
|
|
1866
|
+
OpenConversation: openConversation,
|
|
1867
|
+
CloseConversation: closeConversation,
|
|
1868
|
+
CloseAllConversations: closeAllConversations,
|
|
1869
|
+
CloseIdleConversation: closeIdleConversation,
|
|
1870
|
+
StartTurn: startTurn,
|
|
1871
|
+
FinishTurn: finishTurn,
|
|
1872
|
+
CancelTurn: cancelTurn,
|
|
1873
|
+
ExpireCancel: expireCancel,
|
|
1874
|
+
ApplyAgentUpdate: applyAgentUpdate,
|
|
1875
|
+
RequestPermission: requestPermission,
|
|
1876
|
+
AnswerPermission: answerPermission,
|
|
1877
|
+
RequestElicitation: requestElicitation,
|
|
1878
|
+
AnswerElicitation: answerElicitation,
|
|
1879
|
+
CompleteElicitation: completeElicitation,
|
|
1880
|
+
SetModel: setModel
|
|
1881
|
+
},
|
|
1882
|
+
events: {
|
|
1883
|
+
ConversationEventRaised: [publishConversationEvent, releaseParkedRequest],
|
|
1884
|
+
ConversationClosed: [dropConversationSocket]
|
|
1885
|
+
},
|
|
1886
|
+
queries: {
|
|
1887
|
+
ListConversations: listConversations,
|
|
1888
|
+
GetConversation: getConversation,
|
|
1889
|
+
GetTurn: getTurn
|
|
1890
|
+
}
|
|
1891
|
+
};
|
|
1892
|
+
//#endregion
|
|
1893
|
+
//#region src/application/message-bus.ts
|
|
1894
|
+
const logger$5 = createLogger("message-bus");
|
|
1895
|
+
/**
|
|
1896
|
+
* MessageBus - Central message processing system
|
|
1897
|
+
* - Commands: one handler; returns its result after the outbox drains.
|
|
1898
|
+
* - Events: raised by aggregates, pushed to the outbox by repositories, drained here after
|
|
1899
|
+
* every handler and fanned out to every subscriber in parallel. A failed subscriber is
|
|
1900
|
+
* logged once and never fails the command.
|
|
1901
|
+
* - Queries: one handler, read-only.
|
|
1902
|
+
*/
|
|
1903
|
+
var MessageBus = class {
|
|
1904
|
+
deps;
|
|
1905
|
+
commands;
|
|
1906
|
+
events;
|
|
1907
|
+
queries;
|
|
1908
|
+
constructor(deps, registry = DEFAULT_REGISTRY) {
|
|
1909
|
+
this.deps = deps;
|
|
1910
|
+
this.commands = registry.commands;
|
|
1911
|
+
this.events = registry.events;
|
|
1912
|
+
this.queries = registry.queries;
|
|
1913
|
+
}
|
|
1914
|
+
async handle(message) {
|
|
1915
|
+
try {
|
|
1916
|
+
return await this.dispatch(message);
|
|
1917
|
+
} finally {
|
|
1918
|
+
await this.drainOutbox();
|
|
1919
|
+
}
|
|
1920
|
+
}
|
|
1921
|
+
async dispatch(message) {
|
|
1922
|
+
if (isCommand(message)) return this.handleCommand(message);
|
|
1923
|
+
if (isQuery(message)) return this.handleQuery(message);
|
|
1924
|
+
if (isEvent(message)) await this.handleEvent(message);
|
|
1925
|
+
}
|
|
1926
|
+
async handleCommand(command) {
|
|
1927
|
+
const handler = this.commands[command.name];
|
|
1928
|
+
if (handler === void 0) throw new NoHandlerError({
|
|
1929
|
+
kind: "command",
|
|
1930
|
+
name: command.name
|
|
1931
|
+
});
|
|
1932
|
+
return handler(command, this.deps);
|
|
1933
|
+
}
|
|
1934
|
+
async handleQuery(query) {
|
|
1935
|
+
const handler = this.queries[query.name];
|
|
1936
|
+
if (handler === void 0) throw new NoHandlerError({
|
|
1937
|
+
kind: "query",
|
|
1938
|
+
name: query.name
|
|
1939
|
+
});
|
|
1940
|
+
return handler(query, this.deps);
|
|
1941
|
+
}
|
|
1942
|
+
async handleEvent(event) {
|
|
1943
|
+
const handlers = this.events[event.name] ?? [];
|
|
1944
|
+
const outcomes = await Promise.allSettled(handlers.map((handler) => handler(event, this.deps)));
|
|
1945
|
+
for (const [index, outcome] of outcomes.entries()) {
|
|
1946
|
+
if (outcome.status === "fulfilled") continue;
|
|
1947
|
+
logger$5.error("event_handler_failed", {
|
|
1948
|
+
error: outcome.reason,
|
|
1949
|
+
details: {
|
|
1950
|
+
eventName: event.name,
|
|
1951
|
+
handlerIndex: index
|
|
1952
|
+
}
|
|
1953
|
+
});
|
|
1954
|
+
}
|
|
1955
|
+
}
|
|
1956
|
+
async drainOutbox() {
|
|
1957
|
+
for (let events = this.deps.outbox.drain(); events.length > 0; events = this.deps.outbox.drain()) for (const event of events) await this.handleEvent(event);
|
|
1958
|
+
}
|
|
1959
|
+
};
|
|
1960
|
+
//#endregion
|
|
1961
|
+
//#region src/entrypoints/acp/acp-inbound.ts
|
|
1962
|
+
/**
|
|
1963
|
+
* ACP → bus. What the agent pushes becomes commands; each runs as background
|
|
1964
|
+
* work so a failing one is logged once there and never blocks the agent.
|
|
1965
|
+
*/
|
|
1966
|
+
function createAgentInbound(bus, background) {
|
|
1967
|
+
return {
|
|
1968
|
+
onEvents: (conversationId, events) => {
|
|
1969
|
+
background.run(bus.handle(createCommand("ApplyAgentUpdate", {
|
|
1970
|
+
conversationId,
|
|
1971
|
+
events
|
|
1972
|
+
})));
|
|
1973
|
+
},
|
|
1974
|
+
onPermissionRequest: (conversationId, request) => {
|
|
1975
|
+
background.run(bus.handle(createCommand("RequestPermission", {
|
|
1976
|
+
conversationId,
|
|
1977
|
+
...request
|
|
1978
|
+
})));
|
|
1979
|
+
},
|
|
1980
|
+
onElicitationRequest: (conversationId, request) => {
|
|
1981
|
+
background.run(bus.handle(createCommand("RequestElicitation", {
|
|
1982
|
+
conversationId,
|
|
1983
|
+
...request
|
|
1984
|
+
})));
|
|
1985
|
+
},
|
|
1986
|
+
onElicitationComplete: (conversationId, elicitationId) => {
|
|
1987
|
+
background.run(bus.handle(createCommand("CompleteElicitation", {
|
|
1988
|
+
conversationId,
|
|
1989
|
+
elicitationId
|
|
1990
|
+
})));
|
|
1991
|
+
}
|
|
1992
|
+
};
|
|
1993
|
+
}
|
|
1994
|
+
//#endregion
|
|
1995
|
+
//#region src/entrypoints/websocket/control-method-handlers.ts
|
|
1996
|
+
/** Parse → one bus message → answer. No logic lives here. */
|
|
1997
|
+
const CONTROL_METHOD_HANDLERS = {
|
|
1998
|
+
"conversations.list": (params, context) => context.bus.handle(createQuery("ListConversations", params)),
|
|
1999
|
+
"conversation.create": (params, context) => context.bus.handle(createCommand("CreateConversation", params)),
|
|
2000
|
+
"conversation.attach": async (params, context) => {
|
|
2001
|
+
context.connections.connectConversation(params.conversationId, params.cwd);
|
|
2002
|
+
return null;
|
|
2003
|
+
}
|
|
2004
|
+
};
|
|
2005
|
+
//#endregion
|
|
2006
|
+
//#region src/application/ports/coding-agent.ts
|
|
2007
|
+
/** The one configuration option the host exposes; `conversation.configuration.set` targets it. */
|
|
2008
|
+
const MODEL_OPTION_ID = "model";
|
|
2009
|
+
//#endregion
|
|
2010
|
+
//#region src/entrypoints/websocket/conversation-method-handlers.ts
|
|
2011
|
+
/** Parse → one bus message → answer. No logic lives here. */
|
|
2012
|
+
const CONVERSATION_METHOD_HANDLERS = {
|
|
2013
|
+
"conversation.close": async (_params, { bus, conversationId }) => {
|
|
2014
|
+
await bus.handle(createCommand("CloseConversation", { conversationId }));
|
|
2015
|
+
return null;
|
|
2016
|
+
},
|
|
2017
|
+
"conversation.get": (_params, { bus, conversationId }) => bus.handle(createQuery("GetConversation", { conversationId })),
|
|
2018
|
+
"turn.start": async (params, { bus, conversationId }) => {
|
|
2019
|
+
await bus.handle(createCommand("StartTurn", {
|
|
2020
|
+
conversationId,
|
|
2021
|
+
...params
|
|
2022
|
+
}));
|
|
2023
|
+
return null;
|
|
2024
|
+
},
|
|
2025
|
+
"turn.get": (params, { bus, conversationId }) => bus.handle(createQuery("GetTurn", {
|
|
2026
|
+
conversationId,
|
|
2027
|
+
turnId: params.turnId
|
|
2028
|
+
})),
|
|
2029
|
+
"turn.cancel": async (params, { bus, conversationId }) => {
|
|
2030
|
+
await bus.handle(createCommand("CancelTurn", {
|
|
2031
|
+
conversationId,
|
|
2032
|
+
turnId: params.turnId
|
|
2033
|
+
}));
|
|
2034
|
+
return null;
|
|
2035
|
+
},
|
|
2036
|
+
"conversation.configuration.set": async (params, { bus, conversationId }) => {
|
|
2037
|
+
if (params.optionId !== "model" || params.value.type !== "select") throw new ConfigurationNotFoundError();
|
|
2038
|
+
await bus.handle(createCommand("SetModel", {
|
|
2039
|
+
conversationId,
|
|
2040
|
+
modelId: params.value.value
|
|
2041
|
+
}));
|
|
2042
|
+
return null;
|
|
2043
|
+
},
|
|
2044
|
+
"permission.answer": async (params, { bus, conversationId }) => {
|
|
2045
|
+
await bus.handle(createCommand("AnswerPermission", {
|
|
2046
|
+
conversationId,
|
|
2047
|
+
...params
|
|
2048
|
+
}));
|
|
2049
|
+
return null;
|
|
2050
|
+
},
|
|
2051
|
+
"elicitation.answer": async (params, { bus, conversationId }) => {
|
|
2052
|
+
await bus.handle(createCommand("AnswerElicitation", {
|
|
2053
|
+
conversationId,
|
|
2054
|
+
...params
|
|
2055
|
+
}));
|
|
2056
|
+
return null;
|
|
2057
|
+
}
|
|
2058
|
+
};
|
|
2059
|
+
//#endregion
|
|
2060
|
+
//#region src/infrastructure/errors/to-error-payload.ts
|
|
2061
|
+
const logger$4 = createLogger("error-boundary");
|
|
2062
|
+
/** Convert one Host failure into the contract a JSON-RPC client may see. Logs once. */
|
|
2063
|
+
function toErrorPayload(cause) {
|
|
2064
|
+
const mapped = mapHostFailure(cause);
|
|
2065
|
+
if (mapped !== void 0) return serializeDomainError(mapped);
|
|
2066
|
+
return serializeUnknownFailure(cause);
|
|
2067
|
+
}
|
|
2068
|
+
function mapHostFailure(cause) {
|
|
2069
|
+
return isDomainError(cause) ? cause : void 0;
|
|
2070
|
+
}
|
|
2071
|
+
function serializeDomainError(failure) {
|
|
2072
|
+
logger$4.warn("handled_domain_error", {
|
|
2073
|
+
error: failure,
|
|
2074
|
+
details: { tag: failure._tag }
|
|
2075
|
+
});
|
|
2076
|
+
return failure._tag === "ValidationError" ? {
|
|
2077
|
+
_tag: failure._tag,
|
|
2078
|
+
message: failure.message,
|
|
2079
|
+
issues: [...failure.issues]
|
|
2080
|
+
} : {
|
|
2081
|
+
_tag: failure._tag,
|
|
2082
|
+
message: failure.message
|
|
2083
|
+
};
|
|
2084
|
+
}
|
|
2085
|
+
function serializeUnknownFailure(cause) {
|
|
2086
|
+
const classification = isClassifiedError(cause) ? cause.classification : "unknown";
|
|
2087
|
+
logger$4.error("unexpected_error", {
|
|
2088
|
+
error: cause,
|
|
2089
|
+
details: { classification }
|
|
2090
|
+
});
|
|
2091
|
+
const replacement = classification === "transient" ? new ServiceUnavailableError() : new InternalServerError();
|
|
2092
|
+
return {
|
|
2093
|
+
_tag: replacement._tag,
|
|
2094
|
+
message: replacement.message
|
|
2095
|
+
};
|
|
2096
|
+
}
|
|
2097
|
+
//#endregion
|
|
2098
|
+
//#region src/entrypoints/websocket/json-rpc-handler.ts
|
|
2099
|
+
/** Create one JSON-RPC onFrame that returns a response document or nothing. */
|
|
2100
|
+
function createJsonRpcHandler(input) {
|
|
2101
|
+
return (frame) => handleFrame(frame, input);
|
|
2102
|
+
}
|
|
2103
|
+
async function handleFrame(frame, input) {
|
|
2104
|
+
try {
|
|
2105
|
+
const incoming = readJsonRpcIncoming(frame, input.methods, input.requestId);
|
|
2106
|
+
if (incoming.kind === "notification") {
|
|
2107
|
+
const handler = input.notificationHandlers[incoming.data.method];
|
|
2108
|
+
if (handler === void 0) throw new JsonRpcReadError({
|
|
2109
|
+
id: null,
|
|
2110
|
+
code: JSON_RPC_ERROR_CODES.methodNotFound,
|
|
2111
|
+
message: "Method not found"
|
|
2112
|
+
});
|
|
2113
|
+
await handler(incoming.data.params, input.context);
|
|
2114
|
+
return;
|
|
2115
|
+
}
|
|
2116
|
+
if (incoming.kind === "response") throw new JsonRpcReadError({
|
|
2117
|
+
id: null,
|
|
2118
|
+
code: JSON_RPC_ERROR_CODES.invalidRequest,
|
|
2119
|
+
message: "Invalid Request"
|
|
2120
|
+
});
|
|
2121
|
+
return await handleJsonRpcRequest(incoming.data, (params) => input.handlers[incoming.data.method](params, input.context), (cause) => ({
|
|
2122
|
+
code: HOST_APPLICATION_ERROR_CODE,
|
|
2123
|
+
message: HOST_APPLICATION_ERROR_MESSAGE,
|
|
2124
|
+
data: toErrorPayload(cause)
|
|
2125
|
+
}));
|
|
2126
|
+
} catch (cause) {
|
|
2127
|
+
if (cause instanceof JsonRpcReadError) return jsonRpcError(cause.id, cause.code, cause.message);
|
|
2128
|
+
throw cause;
|
|
2129
|
+
}
|
|
2130
|
+
}
|
|
2131
|
+
//#endregion
|
|
2132
|
+
//#region src/infrastructure/websocket/websocket-notifications.ts
|
|
2133
|
+
/** Numbers every notification on one connection, from 1; the relay applies them in this order. */
|
|
2134
|
+
function sequence() {
|
|
2135
|
+
let last = 0;
|
|
2136
|
+
return () => {
|
|
2137
|
+
last += 1;
|
|
2138
|
+
return SequenceNumberSchema.parse(last);
|
|
2139
|
+
};
|
|
2140
|
+
}
|
|
2141
|
+
/** Send application notifications through the control connection. */
|
|
2142
|
+
function createControlNotifications(send) {
|
|
2143
|
+
const next = sequence();
|
|
2144
|
+
return { conversationUpdated: (conversationId, update) => {
|
|
2145
|
+
send(JSON.stringify(jsonRpcNotification("conversation.updated", {
|
|
2146
|
+
seq: next(),
|
|
2147
|
+
conversationId,
|
|
2148
|
+
update
|
|
2149
|
+
})));
|
|
2150
|
+
} };
|
|
2151
|
+
}
|
|
2152
|
+
/** Send application notifications through one conversation connection. */
|
|
2153
|
+
function createConversationNotifications(send) {
|
|
2154
|
+
const next = sequence();
|
|
2155
|
+
return { sendEvent: (event) => {
|
|
2156
|
+
send(JSON.stringify(jsonRpcNotification("conversation.event", {
|
|
2157
|
+
seq: next(),
|
|
2158
|
+
event
|
|
2159
|
+
})));
|
|
2160
|
+
} };
|
|
2161
|
+
}
|
|
2162
|
+
//#endregion
|
|
2163
|
+
//#region src/entrypoints/websocket/control-connection.ts
|
|
2164
|
+
/** Owns the control JSON-RPC endpoint and its socket. */
|
|
2165
|
+
var ControlConnection = class {
|
|
2166
|
+
transport;
|
|
2167
|
+
notifications;
|
|
2168
|
+
stopped;
|
|
2169
|
+
onFrame;
|
|
2170
|
+
constructor(transport, handlers, context) {
|
|
2171
|
+
this.transport = transport;
|
|
2172
|
+
this.notifications = createControlNotifications((frame) => transport.send(frame));
|
|
2173
|
+
this.stopped = transport.stopped;
|
|
2174
|
+
this.onFrame = createJsonRpcHandler({
|
|
2175
|
+
methods: HostControlMethods,
|
|
2176
|
+
requestId: HostRequestIdSchema,
|
|
2177
|
+
handlers,
|
|
2178
|
+
notificationHandlers: {},
|
|
2179
|
+
context
|
|
2180
|
+
});
|
|
2181
|
+
}
|
|
2182
|
+
start(onStatus) {
|
|
2183
|
+
this.transport.start({
|
|
2184
|
+
onFrame: this.onFrame,
|
|
2185
|
+
onStatus
|
|
2186
|
+
});
|
|
2187
|
+
}
|
|
2188
|
+
stop() {
|
|
2189
|
+
this.transport.stop();
|
|
2190
|
+
}
|
|
2191
|
+
};
|
|
2192
|
+
//#endregion
|
|
2193
|
+
//#region src/entrypoints/websocket/conversation-connection.ts
|
|
2194
|
+
/** Owns one conversation JSON-RPC endpoint and its socket. */
|
|
2195
|
+
var ConversationConnection = class {
|
|
2196
|
+
conversationId;
|
|
2197
|
+
transport;
|
|
2198
|
+
notifications;
|
|
2199
|
+
/** Settles once, when this conversation socket will not come back. */
|
|
2200
|
+
stopped;
|
|
2201
|
+
onFrame;
|
|
2202
|
+
onUp;
|
|
2203
|
+
constructor(conversationId, cwd, transport, handlers, bus) {
|
|
2204
|
+
this.conversationId = conversationId;
|
|
2205
|
+
this.transport = transport;
|
|
2206
|
+
this.notifications = createConversationNotifications((frame) => transport.send(frame));
|
|
2207
|
+
this.stopped = transport.stopped;
|
|
2208
|
+
this.onFrame = createJsonRpcHandler({
|
|
2209
|
+
methods: HostConversationMethods,
|
|
2210
|
+
requestId: HostRequestIdSchema,
|
|
2211
|
+
handlers,
|
|
2212
|
+
notificationHandlers: {},
|
|
2213
|
+
context: {
|
|
2214
|
+
conversationId,
|
|
2215
|
+
bus
|
|
2216
|
+
}
|
|
2217
|
+
});
|
|
2218
|
+
this.onUp = () => bus.handle(createCommand("OpenConversation", {
|
|
2219
|
+
conversationId,
|
|
2220
|
+
cwd
|
|
2221
|
+
}));
|
|
2222
|
+
}
|
|
2223
|
+
start() {
|
|
2224
|
+
this.transport.start({
|
|
2225
|
+
onFrame: this.onFrame,
|
|
2226
|
+
onUp: this.onUp
|
|
2227
|
+
});
|
|
2228
|
+
}
|
|
2229
|
+
stop() {
|
|
2230
|
+
this.transport.stop();
|
|
2231
|
+
}
|
|
2232
|
+
};
|
|
2233
|
+
//#endregion
|
|
2234
|
+
//#region src/entrypoints/websocket/host-connection-manager.ts
|
|
2235
|
+
const logger$3 = createLogger("host-connection-manager");
|
|
2236
|
+
/** Owns the control connection and the active conversation connection registry. */
|
|
2237
|
+
var HostConnectionManager = class {
|
|
2238
|
+
input;
|
|
2239
|
+
createTransport;
|
|
2240
|
+
controlConnection;
|
|
2241
|
+
conversations = /* @__PURE__ */ new Map();
|
|
2242
|
+
constructor(input, createTransport) {
|
|
2243
|
+
this.input = input;
|
|
2244
|
+
this.createTransport = createTransport;
|
|
2245
|
+
const transport = createTransport({
|
|
2246
|
+
url: this.controlUrl(),
|
|
2247
|
+
subprotocol: HOST_CONTROL_SUBPROTOCOL,
|
|
2248
|
+
authorization: `Bearer ${input.token}`
|
|
2249
|
+
});
|
|
2250
|
+
this.controlConnection = new ControlConnection(transport, input.controlHandlers, {
|
|
2251
|
+
bus: input.bus,
|
|
2252
|
+
connections: this
|
|
2253
|
+
});
|
|
2254
|
+
}
|
|
2255
|
+
get controlStopped() {
|
|
2256
|
+
return this.controlConnection.stopped;
|
|
2257
|
+
}
|
|
2258
|
+
get control() {
|
|
2259
|
+
return this.controlConnection.notifications;
|
|
2260
|
+
}
|
|
2261
|
+
connectControl(onStatus) {
|
|
2262
|
+
this.controlConnection.start(onStatus);
|
|
2263
|
+
}
|
|
2264
|
+
connectConversation(conversationId, cwd) {
|
|
2265
|
+
if (this.conversations.has(conversationId)) return;
|
|
2266
|
+
const connection = new ConversationConnection(conversationId, cwd, this.createTransport({
|
|
2267
|
+
url: this.conversationUrl(conversationId),
|
|
2268
|
+
subprotocol: HOST_CONVERSATION_SUBPROTOCOL,
|
|
2269
|
+
authorization: `Bearer ${this.input.token}`
|
|
2270
|
+
}), this.input.conversationHandlers, this.input.bus);
|
|
2271
|
+
this.conversations.set(conversationId, connection);
|
|
2272
|
+
connection.stopped.then(() => {
|
|
2273
|
+
this.removeConversation(connection);
|
|
2274
|
+
}, (cause) => {
|
|
2275
|
+
logger$3.error("host_conversation_connection_failed", {
|
|
2276
|
+
error: cause,
|
|
2277
|
+
details: { conversationId }
|
|
2278
|
+
});
|
|
2279
|
+
this.removeConversation(connection);
|
|
2280
|
+
});
|
|
2281
|
+
connection.start();
|
|
2282
|
+
}
|
|
2283
|
+
conversation(conversationId) {
|
|
2284
|
+
return this.conversations.get(conversationId)?.notifications ?? null;
|
|
2285
|
+
}
|
|
2286
|
+
closeConversation(conversationId) {
|
|
2287
|
+
const connection = this.conversations.get(conversationId);
|
|
2288
|
+
if (connection === void 0) return;
|
|
2289
|
+
connection.stop();
|
|
2290
|
+
this.removeConversation(connection);
|
|
2291
|
+
}
|
|
2292
|
+
closeAll() {
|
|
2293
|
+
for (const connection of this.conversations.values()) connection.stop();
|
|
2294
|
+
this.conversations.clear();
|
|
2295
|
+
this.controlConnection.stop();
|
|
2296
|
+
}
|
|
2297
|
+
removeConversation(connection) {
|
|
2298
|
+
if (this.conversations.get(connection.conversationId) === connection) this.conversations.delete(connection.conversationId);
|
|
2299
|
+
}
|
|
2300
|
+
controlUrl() {
|
|
2301
|
+
const url = new URL("/api/host/ws", this.input.baseUrl);
|
|
2302
|
+
url.protocol = url.protocol === "http:" ? "ws:" : "wss:";
|
|
2303
|
+
return url.href;
|
|
2304
|
+
}
|
|
2305
|
+
conversationUrl(conversationId) {
|
|
2306
|
+
const url = new URL(this.controlUrl());
|
|
2307
|
+
url.pathname = `${url.pathname.replace(/\/$/, "")}/sub/conversation-agent/${encodeURIComponent(conversationId)}`;
|
|
2308
|
+
return url.href;
|
|
2309
|
+
}
|
|
2310
|
+
};
|
|
2311
|
+
//#endregion
|
|
2312
|
+
//#region src/infrastructure/acp/acp-content.ts
|
|
2313
|
+
/**
|
|
2314
|
+
* ACP `models` on a session response (spec 1, `session/new|load|resume`). The SDK
|
|
2315
|
+
* types predate it, so it is parsed off the raw response here.
|
|
2316
|
+
*/
|
|
2317
|
+
const sessionModelsSchema = object({
|
|
2318
|
+
currentModelId: string().min(1),
|
|
2319
|
+
availableModels: array(object({
|
|
2320
|
+
modelId: string().min(1),
|
|
2321
|
+
name: string().min(1),
|
|
2322
|
+
description: string().nullish(),
|
|
2323
|
+
_meta: record(string(), unknown()).nullish()
|
|
2324
|
+
}))
|
|
2325
|
+
});
|
|
2326
|
+
/** `models` from a session response, or undefined when the agent sent none. */
|
|
2327
|
+
function parseSessionModels(response) {
|
|
2328
|
+
const parsed = object({ models: sessionModelsSchema.optional() }).safeParse(response);
|
|
2329
|
+
return parsed.success ? parsed.data.models : void 0;
|
|
2330
|
+
}
|
|
2331
|
+
/** Present the agent's model list as the one `select` option the relay contract knows. */
|
|
2332
|
+
function modelsToConfiguration(models, currentModelId = models.currentModelId) {
|
|
2333
|
+
return {
|
|
2334
|
+
type: "select",
|
|
2335
|
+
id: MODEL_OPTION_ID,
|
|
2336
|
+
name: "Model",
|
|
2337
|
+
category: "model",
|
|
2338
|
+
currentValue: currentModelId,
|
|
2339
|
+
options: models.availableModels.map((model) => {
|
|
2340
|
+
const option = {
|
|
2341
|
+
type: "option",
|
|
2342
|
+
value: model.modelId,
|
|
2343
|
+
name: model.name
|
|
2344
|
+
};
|
|
2345
|
+
if (model.description !== void 0 && model.description !== null) option.description = model.description;
|
|
2346
|
+
return option;
|
|
2347
|
+
})
|
|
2348
|
+
};
|
|
2349
|
+
}
|
|
2350
|
+
/** Canonical prompt content → ACP content block. Inverse of `mapCanonicalContent` for prompts. */
|
|
2351
|
+
function toAcpContent(content) {
|
|
2352
|
+
if (content.type === "resource-link") {
|
|
2353
|
+
const link = {
|
|
2354
|
+
type: "resource_link",
|
|
2355
|
+
uri: content.uri,
|
|
2356
|
+
name: content.name
|
|
2357
|
+
};
|
|
2358
|
+
if (content.title !== void 0) link.title = content.title;
|
|
2359
|
+
if (content.description !== void 0) link.description = content.description;
|
|
2360
|
+
if (content.mimeType !== void 0) link.mimeType = content.mimeType;
|
|
2361
|
+
if (content.size !== void 0) link.size = content.size;
|
|
2362
|
+
return link;
|
|
2363
|
+
}
|
|
2364
|
+
if (content.type !== "resource") return content;
|
|
2365
|
+
const resource = content.resource;
|
|
2366
|
+
const embedded = resource.content.type === "text" ? {
|
|
2367
|
+
uri: resource.uri,
|
|
2368
|
+
text: resource.content.text
|
|
2369
|
+
} : {
|
|
2370
|
+
uri: resource.uri,
|
|
2371
|
+
blob: resource.content.data
|
|
2372
|
+
};
|
|
2373
|
+
if (resource.mimeType !== void 0) embedded.mimeType = resource.mimeType;
|
|
2374
|
+
return {
|
|
2375
|
+
type: "resource",
|
|
2376
|
+
resource: embedded
|
|
2377
|
+
};
|
|
2378
|
+
}
|
|
2379
|
+
function mapCommand(command) {
|
|
2380
|
+
const mapped = {
|
|
2381
|
+
name: command.name,
|
|
2382
|
+
description: command.description
|
|
2383
|
+
};
|
|
2384
|
+
if (command.input !== void 0 && command.input !== null) mapped.inputHint = command.input.hint;
|
|
2385
|
+
return mapped;
|
|
2386
|
+
}
|
|
2387
|
+
function mapConfiguration(option) {
|
|
2388
|
+
if (option.type === "boolean") {
|
|
2389
|
+
const mapped = {
|
|
2390
|
+
type: "boolean",
|
|
2391
|
+
id: option.id,
|
|
2392
|
+
name: option.name,
|
|
2393
|
+
currentValue: option.currentValue
|
|
2394
|
+
};
|
|
2395
|
+
if (option.description !== void 0 && option.description !== null) mapped.description = option.description;
|
|
2396
|
+
if (option.category !== void 0 && option.category !== null) mapped.category = option.category;
|
|
2397
|
+
return mapped;
|
|
2398
|
+
}
|
|
2399
|
+
const mapped = {
|
|
2400
|
+
type: "select",
|
|
2401
|
+
id: option.id,
|
|
2402
|
+
name: option.name,
|
|
2403
|
+
currentValue: option.currentValue,
|
|
2404
|
+
options: option.options.map((value) => {
|
|
2405
|
+
if ("group" in value) return {
|
|
2406
|
+
type: "group",
|
|
2407
|
+
group: value.group,
|
|
2408
|
+
name: value.name,
|
|
2409
|
+
options: value.options.map(mapSelectConfigurationValue)
|
|
2410
|
+
};
|
|
2411
|
+
return mapSelectConfigurationValue(value);
|
|
2412
|
+
})
|
|
2413
|
+
};
|
|
2414
|
+
if (option.description !== void 0 && option.description !== null) mapped.description = option.description;
|
|
2415
|
+
if (option.category !== void 0 && option.category !== null) mapped.category = option.category;
|
|
2416
|
+
return mapped;
|
|
2417
|
+
}
|
|
2418
|
+
function mapSelectConfigurationValue(value) {
|
|
2419
|
+
const item = {
|
|
2420
|
+
type: "option",
|
|
2421
|
+
value: value.value,
|
|
2422
|
+
name: value.name
|
|
2423
|
+
};
|
|
2424
|
+
if (value.description !== void 0 && value.description !== null) item.description = value.description;
|
|
2425
|
+
return item;
|
|
2426
|
+
}
|
|
2427
|
+
function mapPlan(plan) {
|
|
2428
|
+
if (plan.type === "items") return {
|
|
2429
|
+
type: "items",
|
|
2430
|
+
planId: plan.planId,
|
|
2431
|
+
entries: plan.entries
|
|
2432
|
+
};
|
|
2433
|
+
if (plan.type === "file") return {
|
|
2434
|
+
type: "file",
|
|
2435
|
+
planId: plan.planId,
|
|
2436
|
+
uri: plan.uri
|
|
2437
|
+
};
|
|
2438
|
+
return {
|
|
2439
|
+
type: "markdown",
|
|
2440
|
+
planId: plan.planId,
|
|
2441
|
+
content: plan.content
|
|
2442
|
+
};
|
|
2443
|
+
}
|
|
2444
|
+
function mapCanonicalContent(content) {
|
|
2445
|
+
if (content.type === "resource_link") {
|
|
2446
|
+
const mapped = {
|
|
2447
|
+
type: "resource-link",
|
|
2448
|
+
uri: content.uri,
|
|
2449
|
+
name: content.name
|
|
2450
|
+
};
|
|
2451
|
+
if (content.title !== void 0 && content.title !== null) mapped.title = content.title;
|
|
2452
|
+
if (content.description !== void 0 && content.description !== null) mapped.description = content.description;
|
|
2453
|
+
if (content.mimeType !== void 0 && content.mimeType !== null) mapped.mimeType = content.mimeType;
|
|
2454
|
+
if (content.size !== void 0 && content.size !== null) mapped.size = content.size;
|
|
2455
|
+
copyContentMetadata(mapped, content);
|
|
2456
|
+
return mapped;
|
|
2457
|
+
}
|
|
2458
|
+
if (content.type === "resource") {
|
|
2459
|
+
const resource = {
|
|
2460
|
+
uri: content.resource.uri,
|
|
2461
|
+
content: "text" in content.resource ? {
|
|
2462
|
+
type: "text",
|
|
2463
|
+
text: content.resource.text
|
|
2464
|
+
} : {
|
|
2465
|
+
type: "blob",
|
|
2466
|
+
data: content.resource.blob
|
|
2467
|
+
}
|
|
2468
|
+
};
|
|
2469
|
+
if (content.resource.mimeType !== void 0 && content.resource.mimeType !== null) resource.mimeType = content.resource.mimeType;
|
|
2470
|
+
if (content.resource._meta !== void 0 && content.resource._meta !== null) resource._meta = mapMeta(content.resource._meta);
|
|
2471
|
+
const mapped = {
|
|
2472
|
+
type: "resource",
|
|
2473
|
+
resource
|
|
2474
|
+
};
|
|
2475
|
+
copyContentMetadata(mapped, content);
|
|
2476
|
+
return mapped;
|
|
2477
|
+
}
|
|
2478
|
+
let mapped;
|
|
2479
|
+
if (content.type === "text") mapped = {
|
|
2480
|
+
type: "text",
|
|
2481
|
+
text: content.text
|
|
2482
|
+
};
|
|
2483
|
+
else if (content.type === "audio") mapped = {
|
|
2484
|
+
type: "audio",
|
|
2485
|
+
data: content.data,
|
|
2486
|
+
mimeType: content.mimeType
|
|
2487
|
+
};
|
|
2488
|
+
else {
|
|
2489
|
+
mapped = {
|
|
2490
|
+
type: "image",
|
|
2491
|
+
data: content.data,
|
|
2492
|
+
mimeType: content.mimeType
|
|
2493
|
+
};
|
|
2494
|
+
if (content.uri !== void 0 && content.uri !== null) mapped.uri = content.uri;
|
|
2495
|
+
}
|
|
2496
|
+
copyContentMetadata(mapped, content);
|
|
2497
|
+
return mapped;
|
|
2498
|
+
}
|
|
2499
|
+
function mapToolContent(content) {
|
|
2500
|
+
if (content.type === "content") {
|
|
2501
|
+
const mapped = {
|
|
2502
|
+
type: "content",
|
|
2503
|
+
content: mapCanonicalContent(content.content)
|
|
2504
|
+
};
|
|
2505
|
+
if (content._meta !== void 0 && content._meta !== null) mapped._meta = mapMeta(content._meta);
|
|
2506
|
+
return mapped;
|
|
2507
|
+
}
|
|
2508
|
+
if (content.type === "diff") {
|
|
2509
|
+
const mapped = {
|
|
2510
|
+
type: "diff",
|
|
2511
|
+
path: content.path,
|
|
2512
|
+
oldText: content.oldText ?? null,
|
|
2513
|
+
newText: content.newText
|
|
2514
|
+
};
|
|
2515
|
+
if (content._meta !== void 0 && content._meta !== null) mapped._meta = mapMeta(content._meta);
|
|
2516
|
+
return mapped;
|
|
2517
|
+
}
|
|
2518
|
+
const mapped = {
|
|
2519
|
+
type: "terminal",
|
|
2520
|
+
terminalId: content.terminalId
|
|
2521
|
+
};
|
|
2522
|
+
if (content._meta !== void 0 && content._meta !== null) mapped._meta = mapMeta(content._meta);
|
|
2523
|
+
return mapped;
|
|
2524
|
+
}
|
|
2525
|
+
function copyContentMetadata(target, source) {
|
|
2526
|
+
if (source.annotations !== void 0 && source.annotations !== null) {
|
|
2527
|
+
const annotations = {};
|
|
2528
|
+
if (source.annotations.audience !== void 0 && source.annotations.audience !== null) annotations.audience = [...source.annotations.audience];
|
|
2529
|
+
if (source.annotations.lastModified !== void 0 && source.annotations.lastModified !== null) annotations.lastModified = source.annotations.lastModified;
|
|
2530
|
+
if (source.annotations.priority !== void 0 && source.annotations.priority !== null) annotations.priority = source.annotations.priority;
|
|
2531
|
+
if (source.annotations._meta !== void 0 && source.annotations._meta !== null) annotations._meta = mapMeta(source.annotations._meta);
|
|
2532
|
+
target.annotations = annotations;
|
|
2533
|
+
}
|
|
2534
|
+
if (source._meta !== void 0 && source._meta !== null) target._meta = mapMeta(source._meta);
|
|
2535
|
+
}
|
|
2536
|
+
function mapJson(value) {
|
|
2537
|
+
const parsed = json().safeParse(value);
|
|
2538
|
+
return parsed.success ? parsed.data : void 0;
|
|
2539
|
+
}
|
|
2540
|
+
function mapMeta(value) {
|
|
2541
|
+
if (value === null || value === void 0) return void 0;
|
|
2542
|
+
const parsed = record(string(), json()).safeParse(value);
|
|
2543
|
+
return parsed.success ? parsed.data : void 0;
|
|
2544
|
+
}
|
|
2545
|
+
function mapLocation(location) {
|
|
2546
|
+
const mapped = { path: location.path };
|
|
2547
|
+
if (location.line !== void 0 && location.line !== null) mapped.line = location.line;
|
|
2548
|
+
return mapped;
|
|
2549
|
+
}
|
|
2550
|
+
//#endregion
|
|
2551
|
+
//#region src/infrastructure/acp/acp-update-mapper.ts
|
|
2552
|
+
const logger$2 = createLogger("acp-update-mapper");
|
|
2553
|
+
/** Grok's per-turn key on `user_message_chunk._meta`; the only stable turn id in a replay. */
|
|
2554
|
+
const promptIndexSchema = number().int().nonnegative();
|
|
2555
|
+
/** The agent sent an update in an order the mapper cannot place. */
|
|
2556
|
+
var AcpUpdateSequenceError = class extends h("AcpUpdateSequenceError") {
|
|
2557
|
+
constructor(message) {
|
|
2558
|
+
super({
|
|
2559
|
+
message,
|
|
2560
|
+
classification: "terminal"
|
|
2561
|
+
});
|
|
2562
|
+
}
|
|
2563
|
+
};
|
|
2564
|
+
/** The agent sent a value that cannot form a canonical event. */
|
|
2565
|
+
var AcpUpdateValueError = class extends h("AcpUpdateValueError") {
|
|
2566
|
+
constructor(message) {
|
|
2567
|
+
super({
|
|
2568
|
+
message,
|
|
2569
|
+
classification: "terminal"
|
|
2570
|
+
});
|
|
2571
|
+
}
|
|
2572
|
+
};
|
|
2573
|
+
/** The agent sent an update for a different session. */
|
|
2574
|
+
var AcpSessionMismatchError = class extends h("AcpSessionMismatchError") {
|
|
2575
|
+
constructor() {
|
|
2576
|
+
super({
|
|
2577
|
+
message: "ACP update belongs to a different session",
|
|
2578
|
+
classification: "terminal"
|
|
2579
|
+
});
|
|
2580
|
+
}
|
|
2581
|
+
};
|
|
2582
|
+
/**
|
|
2583
|
+
* Converts ACP `session/update` notifications into canonical events, for replay
|
|
2584
|
+
* (`session/load`) and live turns alike. One instance per open conversation.
|
|
2585
|
+
*
|
|
2586
|
+
* Grok names no message (§1 of the redesign doc), so ids come from stream
|
|
2587
|
+
* boundaries: a chunk on a stream with no open message starts one; `tool_call`
|
|
2588
|
+
* and the end of a turn close every open stream. Turn events themselves belong
|
|
2589
|
+
* to the `Conversation` aggregate; the mapper never emits `turn.*`.
|
|
2590
|
+
*/
|
|
2591
|
+
var AcpUpdateMapper = class {
|
|
2592
|
+
conversationId;
|
|
2593
|
+
turn;
|
|
2594
|
+
ordinal = 0;
|
|
2595
|
+
open = /* @__PURE__ */ new Map();
|
|
2596
|
+
tools = /* @__PURE__ */ new Map();
|
|
2597
|
+
commandsKey;
|
|
2598
|
+
expectedPromptIndex;
|
|
2599
|
+
promptIndexChecked = false;
|
|
2600
|
+
/**
|
|
2601
|
+
* @param conversationId - The session every update must belong to.
|
|
2602
|
+
*
|
|
2603
|
+
* The mapper keeps its own tool views: the aggregate applies events through
|
|
2604
|
+
* the bus asynchronously, so a `tool_call_update` can arrive before the
|
|
2605
|
+
* aggregate has folded the `tool_call` it patches.
|
|
2606
|
+
*/
|
|
2607
|
+
constructor(conversationId) {
|
|
2608
|
+
this.conversationId = conversationId;
|
|
2609
|
+
}
|
|
2610
|
+
/** The relay turn in flight, if any. Replay turns are not live. */
|
|
2611
|
+
get liveTurnId() {
|
|
2612
|
+
return this.turn?.live === true ? this.turn.turnId : void 0;
|
|
2613
|
+
}
|
|
2614
|
+
/**
|
|
2615
|
+
* A relay turn starts; its user message is raised by the aggregate, not mapped.
|
|
2616
|
+
*
|
|
2617
|
+
* `expectedPromptIndex` is the aggregate's prediction behind `turnId`. The first
|
|
2618
|
+
* live `user_message_chunk` carries Grok's `_meta.promptIndex`; a mismatch is an
|
|
2619
|
+
* invariant error, logged once by the caller.
|
|
2620
|
+
*/
|
|
2621
|
+
beginTurn(turnId, expectedPromptIndex) {
|
|
2622
|
+
if (this.turn?.live === true) throw new AcpUpdateSequenceError("A live turn is already active");
|
|
2623
|
+
this.turn = {
|
|
2624
|
+
turnId,
|
|
2625
|
+
live: true
|
|
2626
|
+
};
|
|
2627
|
+
this.expectedPromptIndex = expectedPromptIndex;
|
|
2628
|
+
this.promptIndexChecked = false;
|
|
2629
|
+
this.ordinal = 0;
|
|
2630
|
+
this.open.clear();
|
|
2631
|
+
}
|
|
2632
|
+
/** The prompt settled: close every open stream. */
|
|
2633
|
+
endTurn() {
|
|
2634
|
+
if (this.turn?.live !== true) throw new AcpUpdateSequenceError("No live turn is active");
|
|
2635
|
+
const events = this.events(this.closeStreams());
|
|
2636
|
+
this.turn = void 0;
|
|
2637
|
+
return events;
|
|
2638
|
+
}
|
|
2639
|
+
map(notification) {
|
|
2640
|
+
if (notification.sessionId !== this.conversationId) throw new AcpSessionMismatchError();
|
|
2641
|
+
return this.events(this.mapUpdate(notification.update));
|
|
2642
|
+
}
|
|
2643
|
+
mapUpdate(update) {
|
|
2644
|
+
switch (update.sessionUpdate) {
|
|
2645
|
+
case "user_message_chunk":
|
|
2646
|
+
if (this.turn?.live === true) {
|
|
2647
|
+
this.checkPromptIndex(update);
|
|
2648
|
+
return [];
|
|
2649
|
+
}
|
|
2650
|
+
return [...this.startReplayTurn(update), ...this.mapContent("user", update)];
|
|
2651
|
+
case "agent_message_chunk": return this.mapContent("assistant", update);
|
|
2652
|
+
case "agent_thought_chunk": return this.mapContent("reasoning", update);
|
|
2653
|
+
case "tool_call": return this.mapToolCall(update);
|
|
2654
|
+
case "tool_call_update": return this.mapToolCallUpdate(update);
|
|
2655
|
+
case "plan": return [{
|
|
2656
|
+
type: "plan.updated",
|
|
2657
|
+
turnId: this.requireTurn(),
|
|
2658
|
+
plan: {
|
|
2659
|
+
type: "items",
|
|
2660
|
+
planId: "legacy",
|
|
2661
|
+
entries: update.entries
|
|
2662
|
+
}
|
|
2663
|
+
}];
|
|
2664
|
+
case "plan_update": return [{
|
|
2665
|
+
type: "plan.updated",
|
|
2666
|
+
turnId: this.requireTurn(),
|
|
2667
|
+
plan: mapPlan(update.plan)
|
|
2668
|
+
}];
|
|
2669
|
+
case "plan_removed": return [{
|
|
2670
|
+
type: "plan.removed",
|
|
2671
|
+
turnId: this.requireTurn(),
|
|
2672
|
+
planId: update.planId
|
|
2673
|
+
}];
|
|
2674
|
+
case "available_commands_update": return this.mapCommands(update.availableCommands.map(mapCommand));
|
|
2675
|
+
case "current_mode_update": return [{
|
|
2676
|
+
type: "conversation.mode.updated",
|
|
2677
|
+
modeId: update.currentModeId
|
|
2678
|
+
}];
|
|
2679
|
+
case "config_option_update": return [{
|
|
2680
|
+
type: "conversation.configuration.updated",
|
|
2681
|
+
options: update.configOptions.map(mapConfiguration)
|
|
2682
|
+
}];
|
|
2683
|
+
case "session_info_update": return mapSessionInfo(update);
|
|
2684
|
+
case "usage_update": {
|
|
2685
|
+
const usage = {
|
|
2686
|
+
usedTokens: update.used,
|
|
2687
|
+
sizeTokens: update.size
|
|
2688
|
+
};
|
|
2689
|
+
if (update.cost !== void 0 && update.cost !== null) usage.cost = update.cost;
|
|
2690
|
+
return [{
|
|
2691
|
+
type: "conversation.usage.updated",
|
|
2692
|
+
usage
|
|
2693
|
+
}];
|
|
2694
|
+
}
|
|
2695
|
+
case "compaction_update":
|
|
2696
|
+
case "compaction_summary_chunk": throw new AcpUpdateValueError("ACP sent a compaction update that Porte did not advertise");
|
|
2697
|
+
}
|
|
2698
|
+
return update;
|
|
2699
|
+
}
|
|
2700
|
+
/**
|
|
2701
|
+
* The aggregate predicted the prompt index behind the live turn id; Grok's
|
|
2702
|
+
* `_meta.promptIndex` on the echo is the truth. A mismatch means the ids of
|
|
2703
|
+
* this turn flip on the next replay, so it is logged loudly, once.
|
|
2704
|
+
*/
|
|
2705
|
+
checkPromptIndex(update) {
|
|
2706
|
+
if (this.promptIndexChecked) return;
|
|
2707
|
+
this.promptIndexChecked = true;
|
|
2708
|
+
const actual = promptIndexSchema.safeParse(update._meta?.promptIndex);
|
|
2709
|
+
if (!actual.success || actual.data === this.expectedPromptIndex) return;
|
|
2710
|
+
logger$2.warn("prompt_index_mismatch", { details: {
|
|
2711
|
+
conversationId: this.conversationId,
|
|
2712
|
+
expected: this.expectedPromptIndex,
|
|
2713
|
+
actual: actual.data
|
|
2714
|
+
} });
|
|
2715
|
+
}
|
|
2716
|
+
/** Grok fires the same ~100 KB command list after every tool call; emit it once. */
|
|
2717
|
+
mapCommands(commands) {
|
|
2718
|
+
const key = JSON.stringify(commands);
|
|
2719
|
+
if (key === this.commandsKey) return [];
|
|
2720
|
+
this.commandsKey = key;
|
|
2721
|
+
return [{
|
|
2722
|
+
type: "conversation.commands.updated",
|
|
2723
|
+
commands: [...commands]
|
|
2724
|
+
}];
|
|
2725
|
+
}
|
|
2726
|
+
startReplayTurn(update) {
|
|
2727
|
+
const promptIndex = promptIndexSchema.safeParse(update._meta?.promptIndex);
|
|
2728
|
+
if (!promptIndex.success) throw new AcpUpdateValueError("ACP replay user message has no promptIndex");
|
|
2729
|
+
const turnId = turnIdFor(this.conversationId, promptIndex.data);
|
|
2730
|
+
if (this.turn?.live === false && this.turn.turnId === turnId) return [];
|
|
2731
|
+
const closed = this.closeStreams();
|
|
2732
|
+
this.turn = {
|
|
2733
|
+
turnId,
|
|
2734
|
+
live: false
|
|
2735
|
+
};
|
|
2736
|
+
this.ordinal = 0;
|
|
2737
|
+
return closed;
|
|
2738
|
+
}
|
|
2739
|
+
mapContent(stream, update) {
|
|
2740
|
+
const turnId = this.requireTurn();
|
|
2741
|
+
const events = [];
|
|
2742
|
+
let messageId = this.open.get(stream);
|
|
2743
|
+
if (messageId === void 0) {
|
|
2744
|
+
messageId = this.messageId(stream, turnId, update.messageId);
|
|
2745
|
+
this.open.set(stream, messageId);
|
|
2746
|
+
events.push(stream === "reasoning" ? {
|
|
2747
|
+
type: "reasoning.started",
|
|
2748
|
+
turnId,
|
|
2749
|
+
messageId
|
|
2750
|
+
} : {
|
|
2751
|
+
type: "message.started",
|
|
2752
|
+
turnId,
|
|
2753
|
+
messageId,
|
|
2754
|
+
role: stream
|
|
2755
|
+
});
|
|
2756
|
+
}
|
|
2757
|
+
const content = mapCanonicalContent(update.content);
|
|
2758
|
+
events.push(stream === "reasoning" ? {
|
|
2759
|
+
type: "reasoning.delta",
|
|
2760
|
+
turnId,
|
|
2761
|
+
messageId,
|
|
2762
|
+
content
|
|
2763
|
+
} : {
|
|
2764
|
+
type: "message.delta",
|
|
2765
|
+
turnId,
|
|
2766
|
+
messageId,
|
|
2767
|
+
content
|
|
2768
|
+
});
|
|
2769
|
+
return events;
|
|
2770
|
+
}
|
|
2771
|
+
messageId(stream, turnId, acpMessageId) {
|
|
2772
|
+
if (acpMessageId !== void 0 && acpMessageId !== null) {
|
|
2773
|
+
const parsed = MessageIdSchema.safeParse(acpMessageId);
|
|
2774
|
+
if (!parsed.success) throw new AcpUpdateValueError("ACP message ID is invalid");
|
|
2775
|
+
return parsed.data;
|
|
2776
|
+
}
|
|
2777
|
+
if (stream === "user") return userMessageId(turnId);
|
|
2778
|
+
this.ordinal += 1;
|
|
2779
|
+
return stream === "assistant" ? assistantMessageId(turnId, this.ordinal) : reasoningMessageId(turnId, this.ordinal);
|
|
2780
|
+
}
|
|
2781
|
+
mapToolCall(update) {
|
|
2782
|
+
const turnId = this.requireTurn();
|
|
2783
|
+
const parsed = ToolViewSchema.safeParse({
|
|
2784
|
+
toolCallId: update.toolCallId,
|
|
2785
|
+
title: update.title,
|
|
2786
|
+
kind: update.kind ?? "other",
|
|
2787
|
+
status: update.status ?? "pending",
|
|
2788
|
+
name: update.name ?? void 0,
|
|
2789
|
+
content: (update.content ?? []).map(mapToolContent),
|
|
2790
|
+
locations: (update.locations ?? []).map(mapLocation),
|
|
2791
|
+
rawInput: mapJson(update.rawInput),
|
|
2792
|
+
rawOutput: mapJson(update.rawOutput),
|
|
2793
|
+
_meta: mapMeta(update._meta)
|
|
2794
|
+
});
|
|
2795
|
+
if (!parsed.success) throw new AcpUpdateValueError("ACP tool call is invalid");
|
|
2796
|
+
this.tools.set(update.toolCallId, parsed.data);
|
|
2797
|
+
const events = this.closeStreams();
|
|
2798
|
+
events.push({
|
|
2799
|
+
type: "tool.updated",
|
|
2800
|
+
turnId,
|
|
2801
|
+
tool: parsed.data
|
|
2802
|
+
});
|
|
2803
|
+
return events;
|
|
2804
|
+
}
|
|
2805
|
+
mapToolCallUpdate(update) {
|
|
2806
|
+
const turnId = this.requireTurn();
|
|
2807
|
+
const current = this.tools.get(update.toolCallId);
|
|
2808
|
+
if (current === void 0) throw new AcpUpdateSequenceError("ACP updated a tool call before it started");
|
|
2809
|
+
const next = { ...current };
|
|
2810
|
+
if (update.title !== void 0 && update.title !== null) next.title = update.title;
|
|
2811
|
+
if (update.name !== void 0 && update.name !== null) next.name = update.name;
|
|
2812
|
+
if (update.kind !== void 0 && update.kind !== null) next.kind = update.kind;
|
|
2813
|
+
if (update.status !== void 0 && update.status !== null) next.status = update.status;
|
|
2814
|
+
if (update.content !== void 0 && update.content !== null) next.content = update.content.map(mapToolContent);
|
|
2815
|
+
if (update.locations !== void 0 && update.locations !== null) next.locations = update.locations.map(mapLocation);
|
|
2816
|
+
if (update.rawInput !== void 0) next.rawInput = mapJson(update.rawInput);
|
|
2817
|
+
if (update.rawOutput !== void 0) next.rawOutput = mapJson(update.rawOutput);
|
|
2818
|
+
if (update._meta !== void 0 && update._meta !== null) next._meta = mapMeta(update._meta);
|
|
2819
|
+
const parsed = ToolViewSchema.safeParse(next);
|
|
2820
|
+
if (!parsed.success) throw new AcpUpdateValueError("ACP tool update is invalid");
|
|
2821
|
+
this.tools.set(update.toolCallId, parsed.data);
|
|
2822
|
+
return [{
|
|
2823
|
+
type: "tool.updated",
|
|
2824
|
+
turnId,
|
|
2825
|
+
tool: parsed.data
|
|
2826
|
+
}];
|
|
2827
|
+
}
|
|
2828
|
+
closeStreams() {
|
|
2829
|
+
const turn = this.turn;
|
|
2830
|
+
if (turn === void 0) return [];
|
|
2831
|
+
const closed = [];
|
|
2832
|
+
for (const [stream, messageId] of this.open) closed.push(stream === "reasoning" ? {
|
|
2833
|
+
type: "reasoning.completed",
|
|
2834
|
+
turnId: turn.turnId,
|
|
2835
|
+
messageId
|
|
2836
|
+
} : {
|
|
2837
|
+
type: "message.completed",
|
|
2838
|
+
turnId: turn.turnId,
|
|
2839
|
+
messageId
|
|
2840
|
+
});
|
|
2841
|
+
this.open.clear();
|
|
2842
|
+
return closed;
|
|
2843
|
+
}
|
|
2844
|
+
requireTurn() {
|
|
2845
|
+
if (this.turn === void 0) throw new AcpUpdateSequenceError("ACP update arrived outside a turn");
|
|
2846
|
+
return this.turn.turnId;
|
|
2847
|
+
}
|
|
2848
|
+
events(data) {
|
|
2849
|
+
return data.map((item) => {
|
|
2850
|
+
const parsed = ConversationEventSchema.safeParse(item);
|
|
2851
|
+
if (!parsed.success) throw new AcpUpdateValueError("ACP update cannot form a canonical event");
|
|
2852
|
+
return parsed.data;
|
|
2853
|
+
});
|
|
2854
|
+
}
|
|
2855
|
+
};
|
|
2856
|
+
function mapSessionInfo(update) {
|
|
2857
|
+
if (update.title === void 0 && update.updatedAt === void 0) return [];
|
|
2858
|
+
const metadata = {};
|
|
2859
|
+
if (update.title !== void 0) metadata.title = update.title;
|
|
2860
|
+
if (update.updatedAt !== void 0) metadata.updatedAt = update.updatedAt;
|
|
2861
|
+
return [{
|
|
2862
|
+
type: "conversation.metadata.updated",
|
|
2863
|
+
update: metadata
|
|
2864
|
+
}];
|
|
2865
|
+
}
|
|
2866
|
+
//#endregion
|
|
2867
|
+
//#region src/infrastructure/acp/error.ts
|
|
2868
|
+
/** A request from the ACP agent cannot be completed by the Host. */
|
|
2869
|
+
var AcpClientRequestError = class extends h("AcpClientRequestError") {
|
|
2870
|
+
constructor(args) {
|
|
2871
|
+
super({
|
|
2872
|
+
...args,
|
|
2873
|
+
data: args.data,
|
|
2874
|
+
classification: "terminal"
|
|
2875
|
+
});
|
|
2876
|
+
}
|
|
2877
|
+
};
|
|
2878
|
+
/** The ACP agent process could not start. */
|
|
2879
|
+
var AcpStartError = class extends h("AcpStartError") {
|
|
2880
|
+
constructor(args) {
|
|
2881
|
+
super({
|
|
2882
|
+
...args,
|
|
2883
|
+
message: "ACP agent process could not start",
|
|
2884
|
+
classification: "terminal"
|
|
2885
|
+
});
|
|
2886
|
+
}
|
|
2887
|
+
};
|
|
2888
|
+
/** An ACP request returned a JSON-RPC error. */
|
|
2889
|
+
var AcpRpcError = class extends h("AcpRpcError") {
|
|
2890
|
+
constructor(args) {
|
|
2891
|
+
super({
|
|
2892
|
+
...args,
|
|
2893
|
+
message: args.rpc.message,
|
|
2894
|
+
classification: "terminal"
|
|
2895
|
+
});
|
|
2896
|
+
}
|
|
2897
|
+
};
|
|
2898
|
+
/** The ACP agent process exited before a request finished. */
|
|
2899
|
+
var AcpExitedError = class extends h("AcpExitedError") {
|
|
2900
|
+
constructor(args) {
|
|
2901
|
+
super({
|
|
2902
|
+
...args,
|
|
2903
|
+
message: `ACP agent process exited ${String(args.code)}`,
|
|
2904
|
+
classification: "transient"
|
|
2905
|
+
});
|
|
2906
|
+
}
|
|
2907
|
+
};
|
|
2908
|
+
/** The ACP process connection failed for a reason other than exit or timeout. */
|
|
2909
|
+
var AcpProcessError = class extends h("AcpProcessError") {
|
|
2910
|
+
constructor(args) {
|
|
2911
|
+
super({
|
|
2912
|
+
...args,
|
|
2913
|
+
message: "ACP process connection failed",
|
|
2914
|
+
classification: "transient"
|
|
2915
|
+
});
|
|
2916
|
+
}
|
|
2917
|
+
};
|
|
2918
|
+
/** An ACP request did not finish before its deadline. */
|
|
2919
|
+
var AcpTimeoutError = class extends h("AcpTimeoutError") {
|
|
2920
|
+
constructor(args) {
|
|
2921
|
+
super({
|
|
2922
|
+
...args,
|
|
2923
|
+
message: "ACP request timed out",
|
|
2924
|
+
classification: "transient"
|
|
2925
|
+
});
|
|
2926
|
+
}
|
|
2927
|
+
};
|
|
2928
|
+
/** The ACP agent selected a protocol version that this client does not implement. */
|
|
2929
|
+
var AcpProtocolVersionMismatchError = class extends h("AcpProtocolVersionMismatchError") {
|
|
2930
|
+
constructor(args) {
|
|
2931
|
+
super({
|
|
2932
|
+
...args,
|
|
2933
|
+
message: `ACP protocol version ${String(args.received)} is not supported`,
|
|
2934
|
+
classification: "terminal"
|
|
2935
|
+
});
|
|
2936
|
+
}
|
|
2937
|
+
};
|
|
2938
|
+
//#endregion
|
|
2939
|
+
//#region src/infrastructure/acp/incoming-request.ts
|
|
2940
|
+
const permissionOptionSchema = object({
|
|
2941
|
+
optionId: string().min(1),
|
|
2942
|
+
name: string().min(1),
|
|
2943
|
+
kind: _enum([
|
|
2944
|
+
"allow_once",
|
|
2945
|
+
"allow_always",
|
|
2946
|
+
"reject_once",
|
|
2947
|
+
"reject_always"
|
|
2948
|
+
])
|
|
2949
|
+
});
|
|
2950
|
+
const permissionParamsSchema = object({
|
|
2951
|
+
sessionId: string().min(1),
|
|
2952
|
+
toolCall: object({
|
|
2953
|
+
toolCallId: ToolCallIdSchema,
|
|
2954
|
+
title: string().nullable().optional()
|
|
2955
|
+
}),
|
|
2956
|
+
options: array(permissionOptionSchema)
|
|
2957
|
+
});
|
|
2958
|
+
const elicitationPropertySchema = discriminatedUnion("type", [
|
|
2959
|
+
object({
|
|
2960
|
+
type: literal("string"),
|
|
2961
|
+
title: string().nullish(),
|
|
2962
|
+
enum: array(string()).min(1).optional()
|
|
2963
|
+
}),
|
|
2964
|
+
object({
|
|
2965
|
+
type: literal("number"),
|
|
2966
|
+
title: string().nullish()
|
|
2967
|
+
}),
|
|
2968
|
+
object({
|
|
2969
|
+
type: literal("integer"),
|
|
2970
|
+
title: string().nullish()
|
|
2971
|
+
}),
|
|
2972
|
+
object({
|
|
2973
|
+
type: literal("boolean"),
|
|
2974
|
+
title: string().nullish()
|
|
2975
|
+
})
|
|
2976
|
+
]);
|
|
2977
|
+
const elicitationParamsSchema = discriminatedUnion("mode", [object({
|
|
2978
|
+
mode: literal("form"),
|
|
2979
|
+
sessionId: string().min(1),
|
|
2980
|
+
requestedSchema: object({
|
|
2981
|
+
properties: record(string(), elicitationPropertySchema),
|
|
2982
|
+
required: array(string()).nullish()
|
|
2983
|
+
})
|
|
2984
|
+
}), object({
|
|
2985
|
+
mode: literal("url"),
|
|
2986
|
+
sessionId: string().min(1),
|
|
2987
|
+
elicitationId: string().min(1),
|
|
2988
|
+
url: httpUrl()
|
|
2989
|
+
})]);
|
|
2990
|
+
const readFileParamsSchema = object({
|
|
2991
|
+
path: string().min(1),
|
|
2992
|
+
line: number().optional(),
|
|
2993
|
+
limit: number().optional()
|
|
2994
|
+
});
|
|
2995
|
+
const writeFileParamsSchema = object({
|
|
2996
|
+
path: string().min(1),
|
|
2997
|
+
content: string()
|
|
2998
|
+
});
|
|
2999
|
+
/** Answer one JSON-RPC request from the ACP agent. */
|
|
3000
|
+
async function answerIncomingRequest(cwd, method, params) {
|
|
3001
|
+
if (method === "fs/read_text_file") return readTextFile(cwd, params);
|
|
3002
|
+
if (method === "fs/write_text_file") return writeTextFile(cwd, params);
|
|
3003
|
+
throw new AcpClientRequestError({
|
|
3004
|
+
code: -32601,
|
|
3005
|
+
message: `method not found: ${method}`
|
|
3006
|
+
});
|
|
3007
|
+
}
|
|
3008
|
+
/** Parse one ACP permission request at the Grok boundary. */
|
|
3009
|
+
function parsePermissionRequest(params) {
|
|
3010
|
+
const parsed = permissionParamsSchema.safeParse(params);
|
|
3011
|
+
if (!parsed.success) throw new AcpClientRequestError({
|
|
3012
|
+
code: -32602,
|
|
3013
|
+
message: "invalid session/request_permission params"
|
|
3014
|
+
});
|
|
3015
|
+
return parsed.data;
|
|
3016
|
+
}
|
|
3017
|
+
/** Parse the supported ACP elicitation subset at the Grok boundary. */
|
|
3018
|
+
function parseElicitationRequest(params) {
|
|
3019
|
+
const parsed = elicitationParamsSchema.safeParse(params);
|
|
3020
|
+
if (!parsed.success) throw new AcpClientRequestError({
|
|
3021
|
+
code: -32602,
|
|
3022
|
+
message: "invalid elicitation/create params"
|
|
3023
|
+
});
|
|
3024
|
+
if (parsed.data.mode === "url") return {
|
|
3025
|
+
sessionId: parsed.data.sessionId,
|
|
3026
|
+
elicitationId: parsed.data.elicitationId,
|
|
3027
|
+
request: {
|
|
3028
|
+
type: "url",
|
|
3029
|
+
url: parsed.data.url
|
|
3030
|
+
}
|
|
3031
|
+
};
|
|
3032
|
+
const required = new Set(parsed.data.requestedSchema.required ?? []);
|
|
3033
|
+
const fields = Object.entries(parsed.data.requestedSchema.properties).map(([id, property]) => {
|
|
3034
|
+
const label = property.title ?? id;
|
|
3035
|
+
if (property.type === "string") return property.enum === void 0 ? {
|
|
3036
|
+
type: "text",
|
|
3037
|
+
id,
|
|
3038
|
+
label,
|
|
3039
|
+
required: required.has(id)
|
|
3040
|
+
} : {
|
|
3041
|
+
type: "text",
|
|
3042
|
+
id,
|
|
3043
|
+
label,
|
|
3044
|
+
required: required.has(id),
|
|
3045
|
+
options: property.enum
|
|
3046
|
+
};
|
|
3047
|
+
if (property.type === "boolean") return {
|
|
3048
|
+
type: "boolean",
|
|
3049
|
+
id,
|
|
3050
|
+
label,
|
|
3051
|
+
required: required.has(id)
|
|
3052
|
+
};
|
|
3053
|
+
return {
|
|
3054
|
+
type: "number",
|
|
3055
|
+
id,
|
|
3056
|
+
label,
|
|
3057
|
+
required: required.has(id)
|
|
3058
|
+
};
|
|
3059
|
+
});
|
|
3060
|
+
if (fields.length === 0) throw new AcpClientRequestError({
|
|
3061
|
+
code: -32602,
|
|
3062
|
+
message: "elicitation form has no supported fields"
|
|
3063
|
+
});
|
|
3064
|
+
return {
|
|
3065
|
+
sessionId: parsed.data.sessionId,
|
|
3066
|
+
request: {
|
|
3067
|
+
type: "form",
|
|
3068
|
+
fields
|
|
3069
|
+
}
|
|
3070
|
+
};
|
|
3071
|
+
}
|
|
3072
|
+
async function readTextFile(cwd, params) {
|
|
3073
|
+
const parsed = readFileParamsSchema.safeParse(params);
|
|
3074
|
+
if (!parsed.success) throw new AcpClientRequestError({
|
|
3075
|
+
code: -32602,
|
|
3076
|
+
message: "invalid fs/read_text_file params"
|
|
3077
|
+
});
|
|
3078
|
+
const path = resolveConversationPath(cwd, parsed.data.path);
|
|
3079
|
+
try {
|
|
3080
|
+
return { content: sliceLines(await readFile(path, "utf8"), parsed.data.line, parsed.data.limit) };
|
|
3081
|
+
} catch (cause) {
|
|
3082
|
+
throw new AcpClientRequestError({
|
|
3083
|
+
code: -32e3,
|
|
3084
|
+
message: fileErrorMessage(cause)
|
|
3085
|
+
});
|
|
3086
|
+
}
|
|
3087
|
+
}
|
|
3088
|
+
async function writeTextFile(cwd, params) {
|
|
3089
|
+
const parsed = writeFileParamsSchema.safeParse(params);
|
|
3090
|
+
if (!parsed.success) throw new AcpClientRequestError({
|
|
3091
|
+
code: -32602,
|
|
3092
|
+
message: "invalid fs/write_text_file params"
|
|
3093
|
+
});
|
|
3094
|
+
const path = resolveConversationPath(cwd, parsed.data.path);
|
|
3095
|
+
try {
|
|
3096
|
+
await mkdir(dirname(path), { recursive: true });
|
|
3097
|
+
await writeFile(path, parsed.data.content);
|
|
3098
|
+
return {};
|
|
3099
|
+
} catch (cause) {
|
|
3100
|
+
throw new AcpClientRequestError({
|
|
3101
|
+
code: -32e3,
|
|
3102
|
+
message: fileErrorMessage(cause)
|
|
3103
|
+
});
|
|
3104
|
+
}
|
|
3105
|
+
}
|
|
3106
|
+
function resolveConversationPath(cwd, requestedPath) {
|
|
3107
|
+
const root = resolve(cwd);
|
|
3108
|
+
const path = resolve(root, requestedPath);
|
|
3109
|
+
const fromRoot = relative(root, path);
|
|
3110
|
+
if (fromRoot === ".." || fromRoot.startsWith(`..${sep}`) || isAbsolute(fromRoot)) throw new AcpClientRequestError({
|
|
3111
|
+
code: -32602,
|
|
3112
|
+
message: "path is outside the conversation directory"
|
|
3113
|
+
});
|
|
3114
|
+
return path;
|
|
3115
|
+
}
|
|
3116
|
+
function sliceLines(raw, line, limit) {
|
|
3117
|
+
if (line === void 0 && limit === void 0) return raw;
|
|
3118
|
+
const lines = raw.split("\n");
|
|
3119
|
+
const start = line === void 0 ? 0 : Math.max(line - 1, 0);
|
|
3120
|
+
const end = limit === void 0 ? lines.length : start + limit;
|
|
3121
|
+
return lines.slice(start, end).join("\n");
|
|
3122
|
+
}
|
|
3123
|
+
function fileErrorMessage(cause) {
|
|
3124
|
+
if (cause instanceof Error) return cause.message;
|
|
3125
|
+
return "file operation failed";
|
|
3126
|
+
}
|
|
3127
|
+
//#endregion
|
|
3128
|
+
//#region src/infrastructure/acp/acp-coding-agent.ts
|
|
3129
|
+
/** A turn may run tools for a long time; the relay cancels, the host does not time out. */
|
|
3130
|
+
const PROMPT_TIMEOUT_MS = 18e5;
|
|
3131
|
+
const sessionIdParamsSchema = object({ sessionId: ConversationIdSchema });
|
|
3132
|
+
/**
|
|
3133
|
+
* `CodingAgent` over one ACP process.
|
|
3134
|
+
*
|
|
3135
|
+
* Keeps only what ACP needs: the sessions this process opened (`cwd` for fs
|
|
3136
|
+
* requests, one mapper each) and the client requests parked until the relay
|
|
3137
|
+
* answers them. Turn state and transcripts belong to the `Conversation` aggregate.
|
|
3138
|
+
*/
|
|
3139
|
+
var AcpCodingAgent = class AcpCodingAgent {
|
|
3140
|
+
agent;
|
|
3141
|
+
listener;
|
|
3142
|
+
sessions = /* @__PURE__ */ new Map();
|
|
3143
|
+
parkedPermissions = /* @__PURE__ */ new Map();
|
|
3144
|
+
parkedElicitations = /* @__PURE__ */ new Map();
|
|
3145
|
+
elicitationOwners = /* @__PURE__ */ new Map();
|
|
3146
|
+
/** Updates for a session whose `session/new` response has not arrived yet. */
|
|
3147
|
+
orphans = /* @__PURE__ */ new Map();
|
|
3148
|
+
constructor(agent, listener) {
|
|
3149
|
+
this.agent = agent;
|
|
3150
|
+
this.listener = listener;
|
|
3151
|
+
}
|
|
3152
|
+
/** Start the agent with this adapter's inbound callbacks wired in. */
|
|
3153
|
+
static async start(launch, listener) {
|
|
3154
|
+
let adapter;
|
|
3155
|
+
const ready = await launch({
|
|
3156
|
+
onUpdate: (notification) => adapter?.receiveUpdate(notification),
|
|
3157
|
+
onRequest: (id, method, params) => {
|
|
3158
|
+
if (adapter === void 0) throw new AcpClientRequestError({
|
|
3159
|
+
code: -32603,
|
|
3160
|
+
message: "agent is starting"
|
|
3161
|
+
});
|
|
3162
|
+
return adapter.answerRequest(id, method, params);
|
|
3163
|
+
},
|
|
3164
|
+
onElicitationComplete: ({ elicitationId: id }) => adapter?.completeElicitation(id)
|
|
3165
|
+
});
|
|
3166
|
+
adapter = new AcpCodingAgent(ready, listener);
|
|
3167
|
+
return adapter;
|
|
3168
|
+
}
|
|
3169
|
+
async listSessions(cursor) {
|
|
3170
|
+
const listed = await this.agent.process.request({
|
|
3171
|
+
method: "session/list",
|
|
3172
|
+
params: cursor === void 0 ? {} : { cursor }
|
|
3173
|
+
});
|
|
3174
|
+
const sessions = listed.sessions.flatMap((row) => {
|
|
3175
|
+
const facts = this.agent.sessionFacts(row);
|
|
3176
|
+
return facts === void 0 ? [] : [facts];
|
|
3177
|
+
});
|
|
3178
|
+
return listed.nextCursor == null ? { sessions } : {
|
|
3179
|
+
sessions,
|
|
3180
|
+
next: ConversationCursorSchema.parse(listed.nextCursor)
|
|
3181
|
+
};
|
|
3182
|
+
}
|
|
3183
|
+
async createSession(input) {
|
|
3184
|
+
const created = await this.agent.process.request({
|
|
3185
|
+
method: "session/new",
|
|
3186
|
+
params: {
|
|
3187
|
+
cwd: input.cwd,
|
|
3188
|
+
mcpServers: toMcpServers(input.mcpServers)
|
|
3189
|
+
}
|
|
3190
|
+
});
|
|
3191
|
+
const id = ConversationIdSchema.parse(created.sessionId);
|
|
3192
|
+
const session = this.open(id, input.cwd, parseSessionModels(created));
|
|
3193
|
+
return {
|
|
3194
|
+
id,
|
|
3195
|
+
events: [...this.configurationEvents(session), ...this.adoptOrphans(id)]
|
|
3196
|
+
};
|
|
3197
|
+
}
|
|
3198
|
+
async loadSession(id, cwd) {
|
|
3199
|
+
const session = this.open(id, cwd, void 0);
|
|
3200
|
+
session.replay = [];
|
|
3201
|
+
try {
|
|
3202
|
+
const loaded = await this.agent.process.request({
|
|
3203
|
+
method: "session/load",
|
|
3204
|
+
params: {
|
|
3205
|
+
sessionId: id,
|
|
3206
|
+
cwd,
|
|
3207
|
+
mcpServers: []
|
|
3208
|
+
}
|
|
3209
|
+
});
|
|
3210
|
+
session.models = parseSessionModels(loaded);
|
|
3211
|
+
session.contextTokens = this.agent.contextTokens(session.models);
|
|
3212
|
+
return {
|
|
3213
|
+
title: this.agent.sessionTitle(loaded),
|
|
3214
|
+
events: [...session.replay, ...this.configurationEvents(session)]
|
|
3215
|
+
};
|
|
3216
|
+
} catch (cause) {
|
|
3217
|
+
this.sessions.delete(id);
|
|
3218
|
+
throw cause;
|
|
3219
|
+
} finally {
|
|
3220
|
+
session.replay = void 0;
|
|
3221
|
+
}
|
|
3222
|
+
}
|
|
3223
|
+
isOpen(id) {
|
|
3224
|
+
return this.sessions.has(id);
|
|
3225
|
+
}
|
|
3226
|
+
async prompt(id, turnId, promptIndex, content) {
|
|
3227
|
+
const session = this.requireSession(id);
|
|
3228
|
+
session.mapper.beginTurn(turnId, promptIndex);
|
|
3229
|
+
try {
|
|
3230
|
+
const response = await this.agent.process.request({
|
|
3231
|
+
method: "session/prompt",
|
|
3232
|
+
params: {
|
|
3233
|
+
sessionId: id,
|
|
3234
|
+
prompt: content.map(toAcpContent)
|
|
3235
|
+
},
|
|
3236
|
+
timeoutMs: PROMPT_TIMEOUT_MS
|
|
3237
|
+
});
|
|
3238
|
+
const usage = this.agent.promptUsage(response._meta, session.contextTokens);
|
|
3239
|
+
return usage === void 0 ? { outcome: toOutcome(response.stopReason) } : {
|
|
3240
|
+
outcome: toOutcome(response.stopReason),
|
|
3241
|
+
usage
|
|
3242
|
+
};
|
|
3243
|
+
} finally {
|
|
3244
|
+
this.listener.onEvents(id, session.mapper.endTurn());
|
|
3245
|
+
}
|
|
3246
|
+
}
|
|
3247
|
+
async cancel(id) {
|
|
3248
|
+
this.requireSession(id);
|
|
3249
|
+
if (this.agent.process.exited) return;
|
|
3250
|
+
await this.agent.process.notify({
|
|
3251
|
+
method: "session/cancel",
|
|
3252
|
+
params: { sessionId: id }
|
|
3253
|
+
});
|
|
3254
|
+
}
|
|
3255
|
+
async setModel(id, modelId) {
|
|
3256
|
+
const session = this.requireSession(id);
|
|
3257
|
+
await this.agent.process.request({
|
|
3258
|
+
method: "session/set_model",
|
|
3259
|
+
params: {
|
|
3260
|
+
sessionId: id,
|
|
3261
|
+
modelId
|
|
3262
|
+
}
|
|
3263
|
+
});
|
|
3264
|
+
if (session.models === void 0) return [];
|
|
3265
|
+
session.models = {
|
|
3266
|
+
...session.models,
|
|
3267
|
+
currentModelId: modelId
|
|
3268
|
+
};
|
|
3269
|
+
session.contextTokens = this.agent.contextTokens(session.models);
|
|
3270
|
+
return this.configurationEvents(session);
|
|
3271
|
+
}
|
|
3272
|
+
async closeSession(id) {
|
|
3273
|
+
if (this.sessions.get(id) === void 0) return;
|
|
3274
|
+
this.sessions.delete(id);
|
|
3275
|
+
this.orphans.delete(id);
|
|
3276
|
+
this.releaseParked(id);
|
|
3277
|
+
if (this.agent.capabilities.sessionCapabilities?.close == null) return;
|
|
3278
|
+
if (this.agent.process.exited) return;
|
|
3279
|
+
await this.agent.process.request({
|
|
3280
|
+
method: "session/close",
|
|
3281
|
+
params: { sessionId: id }
|
|
3282
|
+
});
|
|
3283
|
+
}
|
|
3284
|
+
resolvePermission(id, outcome) {
|
|
3285
|
+
const parked = this.parkedPermissions.get(id);
|
|
3286
|
+
if (parked === void 0) return;
|
|
3287
|
+
this.parkedPermissions.delete(id);
|
|
3288
|
+
parked.resolve(outcome.type === "selected" ? { outcome: {
|
|
3289
|
+
outcome: "selected",
|
|
3290
|
+
optionId: outcome.optionId
|
|
3291
|
+
} } : { outcome: { outcome: "cancelled" } });
|
|
3292
|
+
}
|
|
3293
|
+
resolveElicitation(id, answer) {
|
|
3294
|
+
const parked = this.parkedElicitations.get(id);
|
|
3295
|
+
if (parked === void 0) return;
|
|
3296
|
+
this.parkedElicitations.delete(id);
|
|
3297
|
+
if (answer.type !== "accept") this.elicitationOwners.delete(id);
|
|
3298
|
+
parked.resolve(elicitationResponse(answer));
|
|
3299
|
+
}
|
|
3300
|
+
async stop() {
|
|
3301
|
+
for (const id of this.sessions.keys()) this.releaseParked(id);
|
|
3302
|
+
this.sessions.clear();
|
|
3303
|
+
await this.agent.process.stop();
|
|
3304
|
+
}
|
|
3305
|
+
open(id, cwd, models) {
|
|
3306
|
+
const session = {
|
|
3307
|
+
cwd,
|
|
3308
|
+
mapper: new AcpUpdateMapper(id),
|
|
3309
|
+
models,
|
|
3310
|
+
contextTokens: this.agent.contextTokens(models),
|
|
3311
|
+
replay: void 0
|
|
3312
|
+
};
|
|
3313
|
+
this.sessions.set(id, session);
|
|
3314
|
+
return session;
|
|
3315
|
+
}
|
|
3316
|
+
requireSession(id) {
|
|
3317
|
+
const session = this.sessions.get(id);
|
|
3318
|
+
if (session === void 0) throw new ConversationNotFoundError();
|
|
3319
|
+
return session;
|
|
3320
|
+
}
|
|
3321
|
+
configurationEvents(session) {
|
|
3322
|
+
if (session.models === void 0) return [];
|
|
3323
|
+
return [{
|
|
3324
|
+
type: "conversation.configuration.updated",
|
|
3325
|
+
options: [modelsToConfiguration(session.models)]
|
|
3326
|
+
}];
|
|
3327
|
+
}
|
|
3328
|
+
receiveUpdate(notification) {
|
|
3329
|
+
const id = ConversationIdSchema.safeParse(notification.sessionId);
|
|
3330
|
+
if (!id.success) return;
|
|
3331
|
+
const session = this.sessions.get(id.data);
|
|
3332
|
+
if (session === void 0) {
|
|
3333
|
+
const pending = this.orphans.get(id.data) ?? [];
|
|
3334
|
+
pending.push(notification);
|
|
3335
|
+
this.orphans.set(id.data, pending);
|
|
3336
|
+
return;
|
|
3337
|
+
}
|
|
3338
|
+
const events = session.mapper.map(notification);
|
|
3339
|
+
if (session.replay !== void 0) session.replay.push(...events);
|
|
3340
|
+
else if (events.length > 0) this.listener.onEvents(id.data, events);
|
|
3341
|
+
}
|
|
3342
|
+
adoptOrphans(id) {
|
|
3343
|
+
const pending = this.orphans.get(id) ?? [];
|
|
3344
|
+
this.orphans.delete(id);
|
|
3345
|
+
const session = this.requireSession(id);
|
|
3346
|
+
return pending.flatMap((notification) => session.mapper.map(notification));
|
|
3347
|
+
}
|
|
3348
|
+
answerRequest = async (requestId, method, params) => {
|
|
3349
|
+
const parsed = sessionIdParamsSchema.safeParse(params);
|
|
3350
|
+
const session = parsed.success ? this.sessions.get(parsed.data.sessionId) : void 0;
|
|
3351
|
+
if (!parsed.success || session === void 0) throw new AcpClientRequestError({
|
|
3352
|
+
code: -32602,
|
|
3353
|
+
message: "unknown session"
|
|
3354
|
+
});
|
|
3355
|
+
if (requestId === null) throw new AcpClientRequestError({
|
|
3356
|
+
code: -32600,
|
|
3357
|
+
message: "request has no id"
|
|
3358
|
+
});
|
|
3359
|
+
const conversationId = parsed.data.sessionId;
|
|
3360
|
+
if (method === "session/request_permission") return this.parkPermission(conversationId, session, requestId, params);
|
|
3361
|
+
if (method === "elicitation/create") return this.parkElicitation(conversationId, session, requestId, params);
|
|
3362
|
+
return answerIncomingRequest(session.cwd, method, params);
|
|
3363
|
+
};
|
|
3364
|
+
parkPermission(conversationId, session, requestId, params) {
|
|
3365
|
+
const request = parsePermissionRequest(params);
|
|
3366
|
+
const turnId = session.mapper.liveTurnId;
|
|
3367
|
+
if (turnId === void 0) throw new AcpClientRequestError({
|
|
3368
|
+
code: -32600,
|
|
3369
|
+
message: "no active turn"
|
|
3370
|
+
});
|
|
3371
|
+
const id = permissionId(turnId, requestId);
|
|
3372
|
+
const parked = new Promise((resolve) => {
|
|
3373
|
+
this.parkedPermissions.set(id, {
|
|
3374
|
+
conversationId,
|
|
3375
|
+
resolve
|
|
3376
|
+
});
|
|
3377
|
+
});
|
|
3378
|
+
this.listener.onPermissionRequest(conversationId, {
|
|
3379
|
+
permissionId: id,
|
|
3380
|
+
toolCallId: request.toolCall.toolCallId,
|
|
3381
|
+
title: request.toolCall.title ?? "",
|
|
3382
|
+
options: request.options
|
|
3383
|
+
});
|
|
3384
|
+
return parked;
|
|
3385
|
+
}
|
|
3386
|
+
parkElicitation(conversationId, session, requestId, params) {
|
|
3387
|
+
const request = parseElicitationRequest(params);
|
|
3388
|
+
const turnId = session.mapper.liveTurnId;
|
|
3389
|
+
if (turnId === void 0) throw new AcpClientRequestError({
|
|
3390
|
+
code: -32600,
|
|
3391
|
+
message: "no active turn"
|
|
3392
|
+
});
|
|
3393
|
+
const id = request.elicitationId === void 0 ? elicitationId(turnId, requestId) : ElicitationIdSchema.parse(request.elicitationId);
|
|
3394
|
+
const parked = new Promise((resolve) => {
|
|
3395
|
+
this.parkedElicitations.set(id, {
|
|
3396
|
+
conversationId,
|
|
3397
|
+
resolve
|
|
3398
|
+
});
|
|
3399
|
+
});
|
|
3400
|
+
this.elicitationOwners.set(id, conversationId);
|
|
3401
|
+
this.listener.onElicitationRequest(conversationId, {
|
|
3402
|
+
elicitationId: id,
|
|
3403
|
+
request: request.request
|
|
3404
|
+
});
|
|
3405
|
+
return parked;
|
|
3406
|
+
}
|
|
3407
|
+
completeElicitation(raw) {
|
|
3408
|
+
const id = ElicitationIdSchema.safeParse(raw);
|
|
3409
|
+
if (!id.success) return;
|
|
3410
|
+
const owner = this.elicitationOwners.get(id.data);
|
|
3411
|
+
if (owner === void 0) return;
|
|
3412
|
+
this.elicitationOwners.delete(id.data);
|
|
3413
|
+
this.listener.onElicitationComplete(owner, id.data);
|
|
3414
|
+
}
|
|
3415
|
+
/** Answer every parked request of one session as cancelled so the agent can move on. */
|
|
3416
|
+
releaseParked(conversationId) {
|
|
3417
|
+
for (const [id, parked] of this.parkedPermissions) {
|
|
3418
|
+
if (parked.conversationId !== conversationId) continue;
|
|
3419
|
+
this.parkedPermissions.delete(id);
|
|
3420
|
+
parked.resolve({ outcome: { outcome: "cancelled" } });
|
|
3421
|
+
}
|
|
3422
|
+
for (const [id, parked] of this.parkedElicitations) {
|
|
3423
|
+
if (parked.conversationId !== conversationId) continue;
|
|
3424
|
+
this.parkedElicitations.delete(id);
|
|
3425
|
+
this.elicitationOwners.delete(id);
|
|
3426
|
+
parked.resolve({ action: "cancel" });
|
|
3427
|
+
}
|
|
3428
|
+
}
|
|
3429
|
+
};
|
|
3430
|
+
function toOutcome(stopReason) {
|
|
3431
|
+
switch (stopReason) {
|
|
3432
|
+
case "end_turn": return {
|
|
3433
|
+
type: "completed",
|
|
3434
|
+
reason: "completed"
|
|
3435
|
+
};
|
|
3436
|
+
case "refusal": return {
|
|
3437
|
+
type: "completed",
|
|
3438
|
+
reason: "refused"
|
|
3439
|
+
};
|
|
3440
|
+
case "max_tokens":
|
|
3441
|
+
case "max_turn_requests": return {
|
|
3442
|
+
type: "completed",
|
|
3443
|
+
reason: "limit_reached"
|
|
3444
|
+
};
|
|
3445
|
+
case "cancelled": return { type: "cancelled" };
|
|
3446
|
+
}
|
|
3447
|
+
}
|
|
3448
|
+
function elicitationResponse(answer) {
|
|
3449
|
+
if (answer.type === "submit") return {
|
|
3450
|
+
action: "accept",
|
|
3451
|
+
content: answer.values
|
|
3452
|
+
};
|
|
3453
|
+
if (answer.type === "accept") return { action: "accept" };
|
|
3454
|
+
return { action: answer.type };
|
|
3455
|
+
}
|
|
3456
|
+
function toMcpServers(servers) {
|
|
3457
|
+
if (servers === void 0) return [];
|
|
3458
|
+
return [...servers];
|
|
3459
|
+
}
|
|
3460
|
+
//#endregion
|
|
3461
|
+
//#region src/application/errors/coding-agent-errors.ts
|
|
3462
|
+
/** A coding agent does not support a capability required by Porte. */
|
|
3463
|
+
var CodingAgentCapabilityError = class extends h("CodingAgentCapabilityError") {
|
|
3464
|
+
constructor(args) {
|
|
3465
|
+
super({
|
|
3466
|
+
...args,
|
|
3467
|
+
message: `Coding agent does not support ${args.capability}`,
|
|
3468
|
+
classification: "terminal"
|
|
3469
|
+
});
|
|
3470
|
+
}
|
|
3471
|
+
};
|
|
3472
|
+
/** A coding agent returned data that Porte cannot use. */
|
|
3473
|
+
var CodingAgentResponseError = class extends h("CodingAgentResponseError") {
|
|
3474
|
+
constructor(args) {
|
|
3475
|
+
super({
|
|
3476
|
+
...args,
|
|
3477
|
+
message: "Coding agent returned an invalid response",
|
|
3478
|
+
classification: "terminal"
|
|
3479
|
+
});
|
|
3480
|
+
}
|
|
3481
|
+
};
|
|
3482
|
+
//#endregion
|
|
3483
|
+
//#region src/infrastructure/acp/acp-agent-process.ts
|
|
3484
|
+
/** Deadline for one ACP JSON-RPC request unless the caller passes `timeoutMs`. */
|
|
3485
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 3e4;
|
|
3486
|
+
/**
|
|
3487
|
+
* One ACP agent process and the JSON-RPC connection to it over stdio.
|
|
3488
|
+
*
|
|
3489
|
+
* Owns spawn, typed requests with deadlines, inbound ACP client methods, and stop.
|
|
3490
|
+
* Does not know which agent binary it runs, or Porte conversations.
|
|
3491
|
+
*/
|
|
3492
|
+
var AcpAgentProcess = class AcpAgentProcess {
|
|
3493
|
+
child;
|
|
3494
|
+
stdio;
|
|
3495
|
+
stopped = false;
|
|
3496
|
+
abortListener;
|
|
3497
|
+
constructor(child, input) {
|
|
3498
|
+
this.child = child;
|
|
3499
|
+
const app = acp.client({ name: "porte" }).onNotification(acp.methods.client.session.update, ({ params }) => {
|
|
3500
|
+
input.onUpdate(params);
|
|
3501
|
+
}).onNotification(acp.methods.client.elicitation.complete, ({ params }) => {
|
|
3502
|
+
input.onElicitationComplete?.(params);
|
|
3503
|
+
});
|
|
3504
|
+
registerClientRequests(app, input.onRequest);
|
|
3505
|
+
child.stderr.resume();
|
|
3506
|
+
const output = Writable.toWeb(child.stdin);
|
|
3507
|
+
const source = Readable.toWeb(child.stdout);
|
|
3508
|
+
this.stdio = app.connect(acp.ndJsonStream(output, source));
|
|
3509
|
+
this.abortListener = addAbortListener(input.signal, () => {
|
|
3510
|
+
this.stop();
|
|
3511
|
+
});
|
|
3512
|
+
}
|
|
3513
|
+
/**
|
|
3514
|
+
* Spawn one ACP process and wait until the OS has started it.
|
|
3515
|
+
*
|
|
3516
|
+
* @param input - Binary, argv, working directory, host signal, and ACP callbacks.
|
|
3517
|
+
* @returns A live transport, or `AcpStartError` when spawn fails or the host signal is already aborted.
|
|
3518
|
+
*/
|
|
3519
|
+
static async start(input) {
|
|
3520
|
+
if (input.signal.aborted) throw new AcpStartError({ cause: input.signal.reason });
|
|
3521
|
+
const child = spawn(input.command, [...input.args], {
|
|
3522
|
+
cwd: input.cwd,
|
|
3523
|
+
env: process.env,
|
|
3524
|
+
stdio: [
|
|
3525
|
+
"pipe",
|
|
3526
|
+
"pipe",
|
|
3527
|
+
"pipe"
|
|
3528
|
+
]
|
|
3529
|
+
});
|
|
3530
|
+
try {
|
|
3531
|
+
await once(child, "spawn", { signal: input.signal });
|
|
3532
|
+
} catch (cause) {
|
|
3533
|
+
child.kill("SIGTERM");
|
|
3534
|
+
throw new AcpStartError({ cause });
|
|
3535
|
+
}
|
|
3536
|
+
return new AcpAgentProcess(child, input);
|
|
3537
|
+
}
|
|
3538
|
+
async request(request) {
|
|
3539
|
+
this.throwIfStopped();
|
|
3540
|
+
const timeoutMs = request.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
3541
|
+
const deadline = new AbortController();
|
|
3542
|
+
const timer = setTimeout(() => {
|
|
3543
|
+
deadline.abort(new AcpTimeoutError({ timeoutMs }));
|
|
3544
|
+
}, timeoutMs);
|
|
3545
|
+
const response = this.stdio.agent.request(request.method, request.params, { cancellationSignal: deadline.signal }).catch((cause) => {
|
|
3546
|
+
throw this.mapRequestError(cause);
|
|
3547
|
+
});
|
|
3548
|
+
const expired = once(deadline.signal, "abort").then(() => {
|
|
3549
|
+
throw deadline.signal.reason;
|
|
3550
|
+
});
|
|
3551
|
+
try {
|
|
3552
|
+
return await Promise.race([response, expired]);
|
|
3553
|
+
} finally {
|
|
3554
|
+
clearTimeout(timer);
|
|
3555
|
+
}
|
|
3556
|
+
}
|
|
3557
|
+
/**
|
|
3558
|
+
* Send one typed ACP notification without waiting for a response.
|
|
3559
|
+
*
|
|
3560
|
+
* @param notification - ACP notification method and params.
|
|
3561
|
+
*/
|
|
3562
|
+
async notify(notification) {
|
|
3563
|
+
this.throwIfStopped();
|
|
3564
|
+
try {
|
|
3565
|
+
await this.stdio.agent.notify(notification.method, notification.params);
|
|
3566
|
+
} catch (cause) {
|
|
3567
|
+
throw new AcpProcessError({ cause });
|
|
3568
|
+
}
|
|
3569
|
+
}
|
|
3570
|
+
/**
|
|
3571
|
+
* Close JSON-RPC and stop the child (SIGTERM, then SIGKILL).
|
|
3572
|
+
*/
|
|
3573
|
+
async stop() {
|
|
3574
|
+
this.abortListener?.[Symbol.dispose]();
|
|
3575
|
+
this.abortListener = void 0;
|
|
3576
|
+
if (this.stopped) return;
|
|
3577
|
+
this.stopped = true;
|
|
3578
|
+
this.stdio.close();
|
|
3579
|
+
if (this.child.exitCode !== null || this.child.signalCode !== null) return;
|
|
3580
|
+
this.child.kill("SIGTERM");
|
|
3581
|
+
if (await exited(this.child, 2e3)) return;
|
|
3582
|
+
this.child.kill("SIGKILL");
|
|
3583
|
+
await exited(this.child, 2e3);
|
|
3584
|
+
}
|
|
3585
|
+
/** True once the child is gone, by our stop or its own exit; every request then fails. */
|
|
3586
|
+
get exited() {
|
|
3587
|
+
return this.stopped || this.child.exitCode !== null || this.child.signalCode !== null;
|
|
3588
|
+
}
|
|
3589
|
+
throwIfStopped() {
|
|
3590
|
+
if (this.exited) throw new AcpExitedError({ code: this.child.exitCode });
|
|
3591
|
+
}
|
|
3592
|
+
mapRequestError(cause) {
|
|
3593
|
+
if (cause instanceof acp.RequestError) return new AcpRpcError({ rpc: cause.toErrorResponse() });
|
|
3594
|
+
if (this.child.exitCode !== null || this.child.signalCode !== null) return new AcpExitedError({ code: this.child.exitCode });
|
|
3595
|
+
return new AcpProcessError({ cause });
|
|
3596
|
+
}
|
|
3597
|
+
};
|
|
3598
|
+
function registerClientRequests(app, handler) {
|
|
3599
|
+
app.onRequest(acp.methods.client.session.requestPermission, (context) => handleClientRequest(handler, context, acp.methods.client.session.requestPermission)).onRequest(acp.methods.client.fs.readTextFile, (context) => handleClientRequest(handler, context, acp.methods.client.fs.readTextFile)).onRequest(acp.methods.client.fs.writeTextFile, (context) => handleClientRequest(handler, context, acp.methods.client.fs.writeTextFile)).onRequest(acp.methods.client.terminal.create, (context) => handleClientRequest(handler, context, acp.methods.client.terminal.create)).onRequest(acp.methods.client.terminal.output, (context) => handleClientRequest(handler, context, acp.methods.client.terminal.output)).onRequest(acp.methods.client.terminal.release, (context) => handleClientRequest(handler, context, acp.methods.client.terminal.release)).onRequest(acp.methods.client.terminal.waitForExit, (context) => handleClientRequest(handler, context, acp.methods.client.terminal.waitForExit)).onRequest(acp.methods.client.terminal.kill, (context) => handleClientRequest(handler, context, acp.methods.client.terminal.kill)).onRequest(acp.methods.client.elicitation.create, (context) => handleClientRequest(handler, context, acp.methods.client.elicitation.create));
|
|
3600
|
+
}
|
|
3601
|
+
async function handleClientRequest(handler, context, method) {
|
|
3602
|
+
const params = json().safeParse(context.params);
|
|
3603
|
+
if (!params.success) throw acp.RequestError.invalidParams(params.error, "Invalid JSON params");
|
|
3604
|
+
try {
|
|
3605
|
+
return await handler(context.requestId, method, params.data);
|
|
3606
|
+
} catch (cause) {
|
|
3607
|
+
if (cause instanceof AcpClientRequestError) throw new acp.RequestError(cause.code, cause.message, cause.data);
|
|
3608
|
+
throw cause;
|
|
3609
|
+
}
|
|
3610
|
+
}
|
|
3611
|
+
/** True when the child has exited within `timeoutMs`. */
|
|
3612
|
+
function exited(child, timeoutMs) {
|
|
3613
|
+
if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve(true);
|
|
3614
|
+
return once(child, "exit", { signal: AbortSignal.timeout(timeoutMs) }).then(() => true, () => false);
|
|
3615
|
+
}
|
|
3616
|
+
//#endregion
|
|
3617
|
+
//#region src/infrastructure/grok/grok-launch.ts
|
|
3618
|
+
/** Grok lists sessions with the repository under `_meta['x.ai/session'].facets.gitRoot`. */
|
|
3619
|
+
const grokSessionSchema = object({
|
|
3620
|
+
sessionId: ConversationIdSchema,
|
|
3621
|
+
cwd: string().min(1),
|
|
3622
|
+
title: string().optional(),
|
|
3623
|
+
updatedAt: IsoDateTimeSchema,
|
|
3624
|
+
_meta: object({ "x.ai/session": object({ facets: object({ gitRoot: string().min(1).optional() }) }).optional() }).optional()
|
|
3625
|
+
});
|
|
3626
|
+
/** Parse one Grok `session/list` row into session facts; rows with no git root are skipped. */
|
|
3627
|
+
function toSessionFacts(session) {
|
|
3628
|
+
const parsed = grokSessionSchema.safeParse(session);
|
|
3629
|
+
if (!parsed.success) throw new CodingAgentResponseError({ cause: parsed.error });
|
|
3630
|
+
const gitRoot = parsed.data._meta?.["x.ai/session"]?.facets.gitRoot;
|
|
3631
|
+
if (gitRoot === void 0) return void 0;
|
|
3632
|
+
return {
|
|
3633
|
+
id: parsed.data.sessionId,
|
|
3634
|
+
cwd: parsed.data.cwd,
|
|
3635
|
+
gitRoot: normaliseGitRoot(gitRoot),
|
|
3636
|
+
title: parsed.data.title ?? "",
|
|
3637
|
+
updatedAt: parsed.data.updatedAt
|
|
3638
|
+
};
|
|
3639
|
+
}
|
|
3640
|
+
const GROK_CACHED_TOKEN_AUTH_METHOD_ID = "cached_token";
|
|
3641
|
+
/** Grok reports the context size on each model's `_meta` (spike: `totalContextTokens`). */
|
|
3642
|
+
const modelMetaSchema = object({ totalContextTokens: number().int().positive() });
|
|
3643
|
+
/** Grok reports the tokens the last call used on the prompt response `_meta` (spike). */
|
|
3644
|
+
const promptMetaSchema = object({ totalTokens: number().int().nonnegative() });
|
|
3645
|
+
/**
|
|
3646
|
+
* Start Grok as one ACP process: spawn, `initialize`, `cached_token` auth, capability
|
|
3647
|
+
* check. Runs once at `porte up`; the adapter owns the returned process from then on.
|
|
3648
|
+
*
|
|
3649
|
+
* @throws CodingAgentUnavailableError when the process cannot start or the signal is aborted.
|
|
3650
|
+
* @throws CodingAgentCapabilityError when Grok lacks `session/list` or `session/load`.
|
|
3651
|
+
*/
|
|
3652
|
+
async function startGrok(signal, callbacks) {
|
|
3653
|
+
const process = await AcpAgentProcess.start({
|
|
3654
|
+
command: "grok",
|
|
3655
|
+
args: [
|
|
3656
|
+
"--no-auto-update",
|
|
3657
|
+
"agent",
|
|
3658
|
+
"stdio"
|
|
3659
|
+
],
|
|
3660
|
+
cwd: homedir(),
|
|
3661
|
+
signal,
|
|
3662
|
+
...callbacks
|
|
3663
|
+
}).catch((cause) => {
|
|
3664
|
+
throw new CodingAgentUnavailableError({ cause });
|
|
3665
|
+
});
|
|
3666
|
+
try {
|
|
3667
|
+
const initialized = await process.request({
|
|
3668
|
+
method: "initialize",
|
|
3669
|
+
params: {
|
|
3670
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
3671
|
+
clientCapabilities: {
|
|
3672
|
+
fs: {
|
|
3673
|
+
readTextFile: true,
|
|
3674
|
+
writeTextFile: true
|
|
3675
|
+
},
|
|
3676
|
+
elicitation: {
|
|
3677
|
+
form: {},
|
|
3678
|
+
url: {}
|
|
3679
|
+
},
|
|
3680
|
+
plan: {}
|
|
3681
|
+
},
|
|
3682
|
+
clientInfo: {
|
|
3683
|
+
name: "porte",
|
|
3684
|
+
title: "Porte",
|
|
3685
|
+
version: "0.1.0"
|
|
3686
|
+
}
|
|
3687
|
+
}
|
|
3688
|
+
});
|
|
3689
|
+
if (initialized.protocolVersion !== PROTOCOL_VERSION) throw new AcpProtocolVersionMismatchError({
|
|
3690
|
+
expected: PROTOCOL_VERSION,
|
|
3691
|
+
received: initialized.protocolVersion
|
|
3692
|
+
});
|
|
3693
|
+
await authenticate(process, initialized.authMethods);
|
|
3694
|
+
const capabilities = initialized.agentCapabilities ?? {};
|
|
3695
|
+
requireCapabilities(capabilities);
|
|
3696
|
+
return {
|
|
3697
|
+
process,
|
|
3698
|
+
capabilities,
|
|
3699
|
+
sessionFacts: toSessionFacts,
|
|
3700
|
+
sessionTitle,
|
|
3701
|
+
contextTokens,
|
|
3702
|
+
promptUsage
|
|
3703
|
+
};
|
|
3704
|
+
} catch (cause) {
|
|
3705
|
+
await process.stop();
|
|
3706
|
+
throw cause;
|
|
3707
|
+
}
|
|
3708
|
+
}
|
|
3709
|
+
async function authenticate(process, methods) {
|
|
3710
|
+
const cachedToken = methods?.find((method) => !("type" in method) && method.id === GROK_CACHED_TOKEN_AUTH_METHOD_ID);
|
|
3711
|
+
if (cachedToken === void 0) return;
|
|
3712
|
+
await process.request({
|
|
3713
|
+
method: "authenticate",
|
|
3714
|
+
params: {
|
|
3715
|
+
methodId: cachedToken.id,
|
|
3716
|
+
_meta: { headless: true }
|
|
3717
|
+
}
|
|
3718
|
+
});
|
|
3719
|
+
}
|
|
3720
|
+
function requireCapabilities(capabilities) {
|
|
3721
|
+
if (capabilities.sessionCapabilities?.list == null) throw new CodingAgentCapabilityError({
|
|
3722
|
+
capability: "conversation.list",
|
|
3723
|
+
cause: /* @__PURE__ */ new TypeError("Grok does not advertise ACP sessionCapabilities.list")
|
|
3724
|
+
});
|
|
3725
|
+
if (capabilities.loadSession !== true) throw new CodingAgentCapabilityError({
|
|
3726
|
+
capability: "conversation.open",
|
|
3727
|
+
cause: /* @__PURE__ */ new TypeError("Grok does not advertise ACP loadSession")
|
|
3728
|
+
});
|
|
3729
|
+
}
|
|
3730
|
+
/** Grok puts the list title on the load response under `_meta['x.ai/sessionDetail']` (capture). */
|
|
3731
|
+
const sessionDetailSchema = object({ "x.ai/sessionDetail": object({ title: string().optional() }).optional() });
|
|
3732
|
+
function sessionTitle(response) {
|
|
3733
|
+
const parsed = sessionDetailSchema.safeParse(response._meta);
|
|
3734
|
+
return parsed.success ? parsed.data["x.ai/sessionDetail"]?.title ?? "" : "";
|
|
3735
|
+
}
|
|
3736
|
+
function contextTokens(models) {
|
|
3737
|
+
const current = models?.availableModels.find((model) => model.modelId === models.currentModelId);
|
|
3738
|
+
const meta = modelMetaSchema.safeParse(current?._meta);
|
|
3739
|
+
return meta.success ? meta.data.totalContextTokens : void 0;
|
|
3740
|
+
}
|
|
3741
|
+
function promptUsage(meta, sizeTokens) {
|
|
3742
|
+
const parsed = promptMetaSchema.safeParse(meta);
|
|
3743
|
+
if (!parsed.success || sizeTokens === void 0) return void 0;
|
|
3744
|
+
return {
|
|
3745
|
+
usedTokens: Math.min(parsed.data.totalTokens, sizeTokens),
|
|
3746
|
+
sizeTokens
|
|
3747
|
+
};
|
|
3748
|
+
}
|
|
3749
|
+
//#endregion
|
|
3750
|
+
//#region src/infrastructure/node/background-tasks.ts
|
|
3751
|
+
const logger$1 = createLogger("background-tasks");
|
|
3752
|
+
var NodeBackgroundTasks = class {
|
|
3753
|
+
pending = /* @__PURE__ */ new Set();
|
|
3754
|
+
run(task) {
|
|
3755
|
+
const tracked = task.catch((cause) => {
|
|
3756
|
+
logger$1.error("background_task_failed", { error: cause });
|
|
3757
|
+
}).finally(() => {
|
|
3758
|
+
this.pending.delete(tracked);
|
|
3759
|
+
});
|
|
3760
|
+
this.pending.add(tracked);
|
|
3761
|
+
}
|
|
3762
|
+
async drain() {
|
|
3763
|
+
await Promise.allSettled(this.pending);
|
|
3764
|
+
}
|
|
3765
|
+
};
|
|
3766
|
+
//#endregion
|
|
3767
|
+
//#region src/infrastructure/node/scheduler.ts
|
|
3768
|
+
/** Node timers, unref'd so a pending deadline never blocks shutdown. */
|
|
3769
|
+
var NodeScheduler = class {
|
|
3770
|
+
schedule(delayMs, task) {
|
|
3771
|
+
setTimeout(task, delayMs).unref();
|
|
3772
|
+
}
|
|
3773
|
+
};
|
|
3774
|
+
//#endregion
|
|
3775
|
+
//#region src/infrastructure/persistence/event-outbox.ts
|
|
3776
|
+
/**
|
|
3777
|
+
* In-memory outbox. Repositories push the events an aggregate raised on `save`;
|
|
3778
|
+
* the message bus drains it after every handler, so events run after the writes.
|
|
3779
|
+
*/
|
|
3780
|
+
var EventOutbox = class {
|
|
3781
|
+
pending = [];
|
|
3782
|
+
push(events) {
|
|
3783
|
+
this.pending.push(...events);
|
|
3784
|
+
}
|
|
3785
|
+
/** Every event pushed since the last drain, in push order. */
|
|
3786
|
+
drain() {
|
|
3787
|
+
const drained = this.pending;
|
|
3788
|
+
this.pending = [];
|
|
3789
|
+
return drained;
|
|
3790
|
+
}
|
|
3791
|
+
};
|
|
3792
|
+
//#endregion
|
|
3793
|
+
//#region src/domain/conversation/conversation-errors.ts
|
|
3794
|
+
/** The conversation is already open on this process; open it once. */
|
|
3795
|
+
var ConversationAlreadyOpenError = class extends h("ConversationAlreadyOpenError") {
|
|
3796
|
+
constructor() {
|
|
3797
|
+
super({
|
|
3798
|
+
message: "Conversation is already open.",
|
|
3799
|
+
classification: "terminal"
|
|
3800
|
+
});
|
|
3801
|
+
}
|
|
3802
|
+
};
|
|
3803
|
+
//#endregion
|
|
3804
|
+
//#region src/infrastructure/persistence/in-memory-conversation-repository.ts
|
|
3805
|
+
var InMemoryConversationRepository = class {
|
|
3806
|
+
outbox;
|
|
3807
|
+
rows = /* @__PURE__ */ new Map();
|
|
3808
|
+
constructor(outbox) {
|
|
3809
|
+
this.outbox = outbox;
|
|
3810
|
+
}
|
|
3811
|
+
find(id) {
|
|
3812
|
+
return this.rows.get(id) ?? null;
|
|
3813
|
+
}
|
|
3814
|
+
get(id) {
|
|
3815
|
+
const conversation = this.rows.get(id);
|
|
3816
|
+
if (conversation === void 0) throw new ConversationNotFoundError();
|
|
3817
|
+
return conversation;
|
|
3818
|
+
}
|
|
3819
|
+
insert(conversation) {
|
|
3820
|
+
if (this.rows.has(conversation.id)) throw new ConversationAlreadyOpenError();
|
|
3821
|
+
this.rows.set(conversation.id, conversation);
|
|
3822
|
+
this.publish(conversation);
|
|
3823
|
+
}
|
|
3824
|
+
save(conversation) {
|
|
3825
|
+
if (!this.rows.has(conversation.id)) throw new ConversationNotFoundError();
|
|
3826
|
+
this.publish(conversation);
|
|
3827
|
+
}
|
|
3828
|
+
delete(conversation) {
|
|
3829
|
+
this.rows.delete(conversation.id);
|
|
3830
|
+
this.publish(conversation);
|
|
3831
|
+
}
|
|
3832
|
+
all() {
|
|
3833
|
+
return [...this.rows.values()];
|
|
3834
|
+
}
|
|
3835
|
+
publish(conversation) {
|
|
3836
|
+
this.outbox.push(conversation.collectEvents());
|
|
3837
|
+
conversation.clearEvents();
|
|
3838
|
+
}
|
|
3839
|
+
};
|
|
3840
|
+
//#endregion
|
|
3841
|
+
//#region src/infrastructure/websocket/party-socket-transport.ts
|
|
3842
|
+
const logger = createLogger("party-socket-transport");
|
|
3843
|
+
const PING_INTERVAL_MS = 3e4;
|
|
3844
|
+
const InboundMessageSchema = object({ data: unknown() });
|
|
3845
|
+
const InboundCloseSchema = object({
|
|
3846
|
+
code: number(),
|
|
3847
|
+
reason: string()
|
|
3848
|
+
});
|
|
3849
|
+
const InboundErrorSchema = object({
|
|
3850
|
+
error: unknown().optional(),
|
|
3851
|
+
message: string().optional()
|
|
3852
|
+
});
|
|
3853
|
+
/** Own one authenticated PartySocket and all WebSocket lifecycle work. */
|
|
3854
|
+
var PartySocketTransport = class {
|
|
3855
|
+
input;
|
|
3856
|
+
stoppedState = Promise.withResolvers();
|
|
3857
|
+
socket;
|
|
3858
|
+
listeners;
|
|
3859
|
+
closed = false;
|
|
3860
|
+
handshake;
|
|
3861
|
+
up = Promise.resolve();
|
|
3862
|
+
/** Settles once, when this socket will not come back. */
|
|
3863
|
+
stopped = this.stoppedState.promise;
|
|
3864
|
+
constructor(input, socket) {
|
|
3865
|
+
this.input = input;
|
|
3866
|
+
this.socket = socket ?? this.createSocket();
|
|
3867
|
+
}
|
|
3868
|
+
/** Attach listeners and start connecting. Does not wait for open. */
|
|
3869
|
+
start(listeners) {
|
|
3870
|
+
this.listeners = listeners;
|
|
3871
|
+
logger.debug("websocket_connecting", {
|
|
3872
|
+
url: this.input.url,
|
|
3873
|
+
subprotocol: this.input.subprotocol
|
|
3874
|
+
});
|
|
3875
|
+
listeners.onStatus?.({ type: "connecting" });
|
|
3876
|
+
this.socket.addEventListener("open", this.onOpen);
|
|
3877
|
+
this.socket.addEventListener("message", this.onMessage);
|
|
3878
|
+
this.socket.addEventListener("error", this.onError);
|
|
3879
|
+
this.socket.addEventListener("close", this.onClose);
|
|
3880
|
+
this.socket.reconnect();
|
|
3881
|
+
}
|
|
3882
|
+
/** Send one text frame. Retries only when the socket did not accept it. */
|
|
3883
|
+
async send(frame) {
|
|
3884
|
+
await sendJsonRpcFrame(() => this.socket.send(frame));
|
|
3885
|
+
}
|
|
3886
|
+
/** Stop reconnects and close locally. */
|
|
3887
|
+
stop() {
|
|
3888
|
+
this.closed = true;
|
|
3889
|
+
this.socket.close(1e3, "Host connection closed");
|
|
3890
|
+
this.stoppedState.resolve();
|
|
3891
|
+
}
|
|
3892
|
+
createSocket() {
|
|
3893
|
+
const AuthenticatedWebSocket = authenticatedWebSocketConstructor(this.input.authorization, this.recordHandshake);
|
|
3894
|
+
return new WebSocket(this.input.url, this.input.subprotocol, {
|
|
3895
|
+
WebSocket: AuthenticatedWebSocket,
|
|
3896
|
+
maxEnqueuedMessages: 0,
|
|
3897
|
+
startClosed: true,
|
|
3898
|
+
shouldReconnectOnClose: this.shouldReconnect
|
|
3899
|
+
});
|
|
3900
|
+
}
|
|
3901
|
+
onOpen = () => {
|
|
3902
|
+
if (this.closed) return;
|
|
3903
|
+
logger.debug("websocket_connected", { details: {
|
|
3904
|
+
url: this.input.url,
|
|
3905
|
+
retryCount: this.socket.retryCount
|
|
3906
|
+
} });
|
|
3907
|
+
this.listeners?.onStatus?.({
|
|
3908
|
+
type: "connected",
|
|
3909
|
+
attempt: this.socket.retryCount
|
|
3910
|
+
});
|
|
3911
|
+
const onUp = this.listeners?.onUp;
|
|
3912
|
+
this.up = onUp === void 0 ? Promise.resolve() : onUp().catch((cause) => {
|
|
3913
|
+
this.fail(new WebSocketHandlerError({ cause }));
|
|
3914
|
+
});
|
|
3915
|
+
};
|
|
3916
|
+
onMessage = (event) => {
|
|
3917
|
+
if (this.closed) return;
|
|
3918
|
+
const message = InboundMessageSchema.safeParse(event);
|
|
3919
|
+
const parsed = readJsonRpcTextFrame(JsonRpcTextSchema.safeParse(message.success ? message.data.data : void 0));
|
|
3920
|
+
if (!parsed.ok) {
|
|
3921
|
+
logger.warn("websocket_frame_rejected", { details: parsed.close });
|
|
3922
|
+
this.socket.close(parsed.close.code, parsed.close.reason);
|
|
3923
|
+
return;
|
|
3924
|
+
}
|
|
3925
|
+
this.deliverFrame(parsed.frame);
|
|
3926
|
+
};
|
|
3927
|
+
onError = (event) => {
|
|
3928
|
+
if (this.closed) return;
|
|
3929
|
+
const parsed = InboundErrorSchema.safeParse(event);
|
|
3930
|
+
logger.debug("websocket_error", {
|
|
3931
|
+
url: this.input.url,
|
|
3932
|
+
retryCount: this.socket.retryCount,
|
|
3933
|
+
message: parsed.success ? readErrorMessage(parsed.data) : void 0
|
|
3934
|
+
});
|
|
3935
|
+
};
|
|
3936
|
+
onClose = (event) => {
|
|
3937
|
+
if (this.closed) return;
|
|
3938
|
+
const handshake = this.handshake;
|
|
3939
|
+
this.handshake = void 0;
|
|
3940
|
+
if (handshake !== void 0 && !handshake.retry) {
|
|
3941
|
+
this.fail(handshake.error);
|
|
3942
|
+
return;
|
|
3943
|
+
}
|
|
3944
|
+
const closed = InboundCloseSchema.safeParse(event);
|
|
3945
|
+
const code = closed.success ? closed.data.code : 1006;
|
|
3946
|
+
const reason = closed.success ? closed.data.reason : "";
|
|
3947
|
+
if (isTerminalCloseCode(code)) {
|
|
3948
|
+
this.fail(new WebSocketProtocolClose({ message: `WebSocket connection closed: ${reason}` }));
|
|
3949
|
+
return;
|
|
3950
|
+
}
|
|
3951
|
+
logger.debug("websocket_reconnecting", {
|
|
3952
|
+
url: this.input.url,
|
|
3953
|
+
retryCount: this.socket.retryCount,
|
|
3954
|
+
handshakeStatus: handshake?.status,
|
|
3955
|
+
closeCode: code,
|
|
3956
|
+
closeReason: reason
|
|
3957
|
+
});
|
|
3958
|
+
this.listeners?.onStatus?.({
|
|
3959
|
+
type: "reconnecting",
|
|
3960
|
+
attempt: this.socket.retryCount + 1,
|
|
3961
|
+
cause: dropCause(handshake?.status)
|
|
3962
|
+
});
|
|
3963
|
+
};
|
|
3964
|
+
async deliverFrame(frame) {
|
|
3965
|
+
await this.up.catch(() => void 0);
|
|
3966
|
+
if (this.closed || this.listeners === void 0) return;
|
|
3967
|
+
try {
|
|
3968
|
+
const document = await this.listeners.onFrame(frame);
|
|
3969
|
+
if (document !== void 0) await this.send(JSON.stringify(document));
|
|
3970
|
+
} catch (cause) {
|
|
3971
|
+
this.fail(cause instanceof JsonRpcSendError ? cause : new WebSocketHandlerError({ cause }));
|
|
3972
|
+
}
|
|
3973
|
+
}
|
|
3974
|
+
recordHandshake = (status) => {
|
|
3975
|
+
this.handshake = shouldRetryHandshake(status) ? {
|
|
3976
|
+
retry: true,
|
|
3977
|
+
status
|
|
3978
|
+
} : {
|
|
3979
|
+
retry: false,
|
|
3980
|
+
status,
|
|
3981
|
+
error: new WebSocketHandshakeRefused({ status })
|
|
3982
|
+
};
|
|
3983
|
+
};
|
|
3984
|
+
shouldReconnect = (event) => {
|
|
3985
|
+
if (this.closed) return false;
|
|
3986
|
+
const handshake = this.handshake;
|
|
3987
|
+
if (handshake !== void 0) return handshake.retry;
|
|
3988
|
+
return !isTerminalCloseCode(event.code);
|
|
3989
|
+
};
|
|
3990
|
+
fail(cause) {
|
|
3991
|
+
this.closed = true;
|
|
3992
|
+
this.socket.close(1011, "WebSocket transport stopped");
|
|
3993
|
+
this.stoppedState.reject(cause);
|
|
3994
|
+
}
|
|
3995
|
+
};
|
|
3996
|
+
/** Create one configured PartySocket. */
|
|
3997
|
+
const createPartySocketTransport = (input) => new PartySocketTransport(input);
|
|
3998
|
+
function authenticatedWebSocketConstructor(authorization, recordHandshake) {
|
|
3999
|
+
return class AuthenticatedWebSocket extends WebSocket$1 {
|
|
4000
|
+
heartbeat;
|
|
4001
|
+
constructor(address, protocols) {
|
|
4002
|
+
super(address, protocols ?? [], { headers: { Authorization: authorization } });
|
|
4003
|
+
this.once("unexpected-response", (_request, response) => {
|
|
4004
|
+
recordHandshake(response.statusCode ?? 0);
|
|
4005
|
+
response.resume();
|
|
4006
|
+
this.close();
|
|
4007
|
+
});
|
|
4008
|
+
this.on("open", () => {
|
|
4009
|
+
this.heartbeat = setInterval(() => {
|
|
4010
|
+
if (this.readyState === WebSocket$1.OPEN) this.ping();
|
|
4011
|
+
}, PING_INTERVAL_MS);
|
|
4012
|
+
this.heartbeat.unref();
|
|
4013
|
+
});
|
|
4014
|
+
this.once("close", () => {
|
|
4015
|
+
if (this.heartbeat !== void 0) clearInterval(this.heartbeat);
|
|
4016
|
+
this.heartbeat = void 0;
|
|
4017
|
+
});
|
|
4018
|
+
}
|
|
4019
|
+
};
|
|
4020
|
+
}
|
|
4021
|
+
/**
|
|
4022
|
+
* A 5xx handshake means the relay's edge answered but the app behind it did
|
|
4023
|
+
* not (Cloudflare 530 through a tunnel, 502/503 on deploy). Anything else is
|
|
4024
|
+
* the network between here and there.
|
|
4025
|
+
*/
|
|
4026
|
+
function dropCause(handshakeStatus) {
|
|
4027
|
+
return handshakeStatus !== void 0 && handshakeStatus >= 500 ? "server-unreachable" : "connection-lost";
|
|
4028
|
+
}
|
|
4029
|
+
function isTerminalCloseCode(code) {
|
|
4030
|
+
return code === 1002 || code === 1003 || code === 1007 || code === 1008 || code === 1009;
|
|
4031
|
+
}
|
|
4032
|
+
function shouldRetryHandshake(status) {
|
|
4033
|
+
return status === 0 || status === 408 || status === 425 || status === 429 || status >= 500 && status <= 599;
|
|
4034
|
+
}
|
|
4035
|
+
function readErrorMessage(event) {
|
|
4036
|
+
if (event.message !== void 0) return event.message;
|
|
4037
|
+
return event.error instanceof Error ? event.error.message : void 0;
|
|
4038
|
+
}
|
|
4039
|
+
//#endregion
|
|
4040
|
+
//#region src/infrastructure/app-deps.ts
|
|
4041
|
+
/**
|
|
4042
|
+
* Composition root for `porte up`. Starts Grok eagerly: a host that cannot run
|
|
4043
|
+
* its agent should fail here, not on the first turn.
|
|
4044
|
+
*
|
|
4045
|
+
* The bus, the sockets, and the agent all need `deps`, and `deps` needs them;
|
|
4046
|
+
* the getters resolve that after construction. Nothing reads them before `run`.
|
|
4047
|
+
*/
|
|
4048
|
+
async function createAppDeps(input) {
|
|
4049
|
+
const outbox = new EventOutbox();
|
|
4050
|
+
const deps = {
|
|
4051
|
+
outbox,
|
|
4052
|
+
conversations: new InMemoryConversationRepository(outbox),
|
|
4053
|
+
background: new NodeBackgroundTasks(),
|
|
4054
|
+
scheduler: new NodeScheduler(),
|
|
4055
|
+
now: () => /* @__PURE__ */ new Date(),
|
|
4056
|
+
get bus() {
|
|
4057
|
+
return bus;
|
|
4058
|
+
},
|
|
4059
|
+
get connections() {
|
|
4060
|
+
return connections;
|
|
4061
|
+
},
|
|
4062
|
+
get codingAgent() {
|
|
4063
|
+
return codingAgent;
|
|
4064
|
+
}
|
|
4065
|
+
};
|
|
4066
|
+
const bus = new MessageBus(deps);
|
|
4067
|
+
const connections = new HostConnectionManager({
|
|
4068
|
+
baseUrl: input.credential.baseUrl,
|
|
4069
|
+
token: input.credential.token,
|
|
4070
|
+
controlHandlers: CONTROL_METHOD_HANDLERS,
|
|
4071
|
+
conversationHandlers: CONVERSATION_METHOD_HANDLERS,
|
|
4072
|
+
bus
|
|
4073
|
+
}, createPartySocketTransport);
|
|
4074
|
+
const codingAgent = await AcpCodingAgent.start((callbacks) => startGrok(input.signal, callbacks), createAgentInbound(bus, deps.background));
|
|
4075
|
+
return deps;
|
|
4076
|
+
}
|
|
4077
|
+
//#endregion
|
|
4078
|
+
//#region src/infrastructure/bootstrap/host-runtime.ts
|
|
4079
|
+
/** Create one inactive Host runtime: read the pairing, build the app, start the agent. */
|
|
4080
|
+
async function createHostRuntime(config, signal) {
|
|
4081
|
+
const credential = await new FileCredentialStore(config.dataDirectory).read();
|
|
4082
|
+
if (credential === null) throw new HostNotPairedError();
|
|
4083
|
+
return {
|
|
4084
|
+
runtime: new HostRuntime(signal, await createAppDeps({
|
|
4085
|
+
credential,
|
|
4086
|
+
signal
|
|
4087
|
+
})),
|
|
4088
|
+
relayUrl: credential.baseUrl
|
|
4089
|
+
};
|
|
4090
|
+
}
|
|
4091
|
+
//#endregion
|
|
4092
|
+
//#region src/entrypoints/cli/run-up-command.ts
|
|
4093
|
+
/** Run the Host until the process receives a shutdown signal. */
|
|
4094
|
+
async function runUpCommand(input) {
|
|
4095
|
+
const output = createOutput(input.stderr);
|
|
4096
|
+
const shutdown = new AbortController();
|
|
4097
|
+
const stop = () => {
|
|
4098
|
+
shutdown.abort();
|
|
4099
|
+
};
|
|
4100
|
+
process.once("SIGINT", stop);
|
|
4101
|
+
process.once("SIGTERM", stop);
|
|
4102
|
+
try {
|
|
4103
|
+
const { runtime, relayUrl } = await createHostRuntime(input.config, shutdown.signal);
|
|
4104
|
+
await runtime.run(reportRelayStatus(output, relayUrl));
|
|
4105
|
+
if (shutdown.signal.aborted) output.done("Stopped.");
|
|
4106
|
+
} finally {
|
|
4107
|
+
shutdown.abort();
|
|
4108
|
+
process.removeListener("SIGINT", stop);
|
|
4109
|
+
process.removeListener("SIGTERM", stop);
|
|
4110
|
+
}
|
|
4111
|
+
}
|
|
4112
|
+
/**
|
|
4113
|
+
* Turn socket states into the lines a person watches. A drop is one line that
|
|
4114
|
+
* rewrites itself on each retry; a terminal failure never reaches here, the
|
|
4115
|
+
* CLI error boundary prints it.
|
|
4116
|
+
*/
|
|
4117
|
+
function reportRelayStatus(output, baseUrl) {
|
|
4118
|
+
const { url, quiet } = output.emphasis;
|
|
4119
|
+
const site = new URL(baseUrl).host;
|
|
4120
|
+
let wasConnected = false;
|
|
4121
|
+
return (status) => {
|
|
4122
|
+
switch (status.type) {
|
|
4123
|
+
case "connecting":
|
|
4124
|
+
output.status(quiet(`Connecting to ${site}…`));
|
|
4125
|
+
return;
|
|
4126
|
+
case "connected":
|
|
4127
|
+
if (wasConnected) output.done("Reconnected.");
|
|
4128
|
+
else {
|
|
4129
|
+
output.done(`Connected. Open ${url(`${baseUrl}/conversations`)}`);
|
|
4130
|
+
wasConnected = true;
|
|
4131
|
+
}
|
|
4132
|
+
return;
|
|
4133
|
+
case "reconnecting":
|
|
4134
|
+
output.status(`${DROP_TEXT[status.cause]} ${quiet(`Retrying (attempt ${String(status.attempt)})…`)}`);
|
|
4135
|
+
return;
|
|
4136
|
+
}
|
|
4137
|
+
};
|
|
4138
|
+
}
|
|
4139
|
+
/** The relay's edge answered but Porte did not, versus nothing answered at all. */
|
|
4140
|
+
const DROP_TEXT = {
|
|
4141
|
+
"server-unreachable": "! Porte is unreachable.",
|
|
4142
|
+
"connection-lost": "! Connection lost."
|
|
4143
|
+
};
|
|
4144
|
+
//#endregion
|
|
4145
|
+
//#region src/entrypoints/cli/unpair-command.ts
|
|
4146
|
+
/** Revoke and remove this machine's stored credential. */
|
|
4147
|
+
async function runUnpairCommand(input) {
|
|
4148
|
+
const resources = createPairingResources(input.config);
|
|
4149
|
+
const output = createOutput(input.stderr);
|
|
4150
|
+
const stored = await resources.credentials.read();
|
|
4151
|
+
if (stored === null) {
|
|
4152
|
+
output.done("This Mac is not paired.");
|
|
4153
|
+
return 0;
|
|
4154
|
+
}
|
|
4155
|
+
await resources.authorizer.revoke(stored.token);
|
|
4156
|
+
await resources.credentials.clear();
|
|
4157
|
+
output.done(`Unpaired this Mac from ${output.emphasis.strong(new URL(stored.baseUrl).host)}`);
|
|
4158
|
+
return 0;
|
|
4159
|
+
}
|
|
4160
|
+
//#endregion
|
|
4161
|
+
//#region src/entrypoints/cli/run-cli.ts
|
|
4162
|
+
/** Run one CLI invocation and report each final error once. */
|
|
4163
|
+
async function run(argv, io) {
|
|
4164
|
+
const args = argv[0] === "--" ? argv.slice(1) : argv;
|
|
4165
|
+
try {
|
|
4166
|
+
return await dispatch(args, io);
|
|
4167
|
+
} catch (cause) {
|
|
4168
|
+
if (isCliError(cause)) return reportCliError(io.stderr, cause);
|
|
4169
|
+
return reportUnexpectedCliError(io.stderr, cause);
|
|
4170
|
+
}
|
|
4171
|
+
}
|
|
4172
|
+
async function dispatch(argv, io) {
|
|
4173
|
+
const command = parseCommand(argv);
|
|
4174
|
+
if (command.kind === "help") {
|
|
4175
|
+
io.stdout.write(`${command.text}\n`);
|
|
4176
|
+
return 0;
|
|
4177
|
+
}
|
|
4178
|
+
if (command.kind === "version") {
|
|
4179
|
+
io.stdout.write(`${VERSION}\n`);
|
|
4180
|
+
return 0;
|
|
4181
|
+
}
|
|
4182
|
+
const config = loadConfig(io.env);
|
|
4183
|
+
if (command.kind === "up") {
|
|
4184
|
+
await runUpCommand({
|
|
4185
|
+
config,
|
|
4186
|
+
stderr: io.stderr
|
|
4187
|
+
});
|
|
4188
|
+
return 0;
|
|
4189
|
+
}
|
|
4190
|
+
if (command.kind === "pair") return runPairCommand({
|
|
4191
|
+
config,
|
|
4192
|
+
stderr: io.stderr
|
|
4193
|
+
});
|
|
4194
|
+
return runUnpairCommand({
|
|
4195
|
+
config,
|
|
4196
|
+
stderr: io.stderr
|
|
4197
|
+
});
|
|
4198
|
+
}
|
|
4199
|
+
//#endregion
|
|
4200
|
+
export { run };
|