@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.
@@ -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 };