@runuai/host 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.
Files changed (64) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +91 -0
  3. package/bin/uai-host.mjs +14 -0
  4. package/db/migrations/0000_host_tasks.sql +12 -0
  5. package/db/migrations/0001_host_ui.sql +11 -0
  6. package/db/migrations/0002_host_github_tokens.sql +8 -0
  7. package/db/migrations/0003_host_ssh_keys.sql +8 -0
  8. package/db/migrations/0004_host_owner_name.sql +1 -0
  9. package/db/migrations/meta/_journal.json +41 -0
  10. package/db/schema.ts +82 -0
  11. package/images/standard/Dockerfile +232 -0
  12. package/images/standard/README.md +122 -0
  13. package/images/standard/container/code-server-settings.json +36 -0
  14. package/images/standard/container/uai-init +215 -0
  15. package/images/standard/tool-versions +2 -0
  16. package/lib/agent.ts +292 -0
  17. package/lib/agents/claude.ts +343 -0
  18. package/lib/agents/codex.ts +522 -0
  19. package/lib/agents/factory.ts +34 -0
  20. package/lib/agents/mock.ts +133 -0
  21. package/lib/agents/proc.ts +172 -0
  22. package/lib/agents/registry.ts +109 -0
  23. package/lib/agents/types.ts +133 -0
  24. package/lib/attachments.ts +46 -0
  25. package/lib/cloud-state.ts +56 -0
  26. package/lib/command-db.ts +278 -0
  27. package/lib/db.ts +68 -0
  28. package/lib/env.ts +140 -0
  29. package/lib/git-diff.ts +370 -0
  30. package/lib/git-identity.ts +65 -0
  31. package/lib/github-tokens.ts +321 -0
  32. package/lib/orchestrator.ts +975 -0
  33. package/lib/preview-ports.ts +85 -0
  34. package/lib/repo-clone.ts +127 -0
  35. package/lib/runtime-state.ts +120 -0
  36. package/lib/secrets.ts +71 -0
  37. package/lib/ssh.ts +186 -0
  38. package/lib/standard-image.ts +152 -0
  39. package/lib/task-diff.ts +113 -0
  40. package/lib/task-status.ts +46 -0
  41. package/lib/transcript.ts +30 -0
  42. package/lib/ulid.ts +7 -0
  43. package/package.json +85 -0
  44. package/scripts/agent/_common.sh +248 -0
  45. package/scripts/agent/task-down.sh +113 -0
  46. package/scripts/agent/task-status.sh +54 -0
  47. package/scripts/agent/task-up.sh +457 -0
  48. package/scripts/install/darwin.ts +167 -0
  49. package/scripts/install/linux.ts +115 -0
  50. package/scripts/install/types.ts +35 -0
  51. package/scripts/install/util.ts +39 -0
  52. package/scripts/install/win.ts +130 -0
  53. package/src/cli.ts +445 -0
  54. package/src/index.ts +375 -0
  55. package/src/load-env.ts +52 -0
  56. package/src/main.ts +1156 -0
  57. package/src/paths.ts +64 -0
  58. package/src/protocol.ts +413 -0
  59. package/src/ui/server.ts +343 -0
  60. package/src/ui/types.ts +78 -0
  61. package/ui/app.js +264 -0
  62. package/ui/index.html +55 -0
  63. package/ui/style.css +359 -0
  64. package/ui/uai-logo-black.svg +9 -0
@@ -0,0 +1,522 @@
1
+ /**
2
+ * CodexSession — a real AgentSession backed by `codex app-server`, run
3
+ * inside the task container (ADR-010):
4
+ *
5
+ * docker exec -i task-<id>-app-1 codex app-server
6
+ *
7
+ * JSON-RPC 2.0 over stdio, newline-delimited. The protocol below is
8
+ * verified against `codex app-server generate-json-schema` (the
9
+ * generated schema, not guesswork).
10
+ *
11
+ * Lifecycle:
12
+ * initialize (request) → server result
13
+ * initialized (notification) → sent by us
14
+ * thread/start (request) → result `{ thread: { id, … } }`
15
+ * turn/start (request) → output streams as notifications;
16
+ * the response itself is ignored
17
+ *
18
+ * Streaming notifications consumed (`mapCodexNotification`):
19
+ * item/agentMessage/delta → message_delta
20
+ * item/completed (agentMessage) → message_complete
21
+ * item/completed (tool item) → tool_call
22
+ * turn/completed → turn_complete (+ error if failed)
23
+ * error → error
24
+ *
25
+ * The thread runs with `approvalPolicy: "never"` and
26
+ * `sandbox: "danger-full-access"` — the task container is the isolation
27
+ * boundary (ADR-011), and codex's bubblewrap sandbox can't create user
28
+ * namespaces inside it anyway.
29
+ */
30
+
31
+ import { newId } from "../ulid";
32
+ import { dockerExecArgs, LineProcess } from "./proc";
33
+ import { register } from "./registry";
34
+ import type {
35
+ AgentEvent,
36
+ AgentEventHandler,
37
+ AgentKind,
38
+ AgentSession,
39
+ RosterAgent,
40
+ } from "./types";
41
+
42
+ // Codex model slugs accepted via `-c model=<slug>`. Hardcoded: Codex has no
43
+ // machine-readable "list models" command — the canonical list is the
44
+ // interactive `codex` model picker (and `codex -m <slug>` / config.toml).
45
+ // Sourced from that picker; UPDATE WHEN MODELS CHANGE. Order = display order
46
+ // in the cloud picker. Legacy models remain reachable via config.toml.
47
+ const CODEX_MODELS = [
48
+ "gpt-5.5",
49
+ "gpt-5.4",
50
+ "gpt-5.4-mini",
51
+ "gpt-5.3-codex",
52
+ "gpt-5.3-codex-spark",
53
+ "gpt-5.2",
54
+ ];
55
+
56
+ // gpt-5.5 is Codex's current frontier coding model. Update alongside
57
+ // CODEX_MODELS. When an agent's model is null the adapter omits the override
58
+ // entirely and Codex uses the user's own configured default.
59
+ const CODEX_DEFAULT_MODEL = "gpt-5.5";
60
+
61
+ // Reasoning levels set via `-c model_reasoning_effort=<level>`. "xhigh" is the
62
+ // picker's "Extra high". UPDATE WHEN CODEX CHANGES its reasoning levels.
63
+ const CODEX_EFFORTS = ["low", "medium", "high", "xhigh"];
64
+
65
+ // medium is Codex's default reasoning level. Update alongside CODEX_EFFORTS.
66
+ const CODEX_DEFAULT_EFFORT = "medium";
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // Pure protocol mapping — JSON-RPC notification → AgentEvent[].
70
+ // ---------------------------------------------------------------------------
71
+
72
+ function isObj(v: unknown): v is Record<string, unknown> {
73
+ return typeof v === "object" && v !== null;
74
+ }
75
+
76
+ function str(v: unknown, fallback = ""): string {
77
+ return typeof v === "string" ? v : fallback;
78
+ }
79
+
80
+ /** `item/completed` item types rendered as tool-call cards. */
81
+ const TOOL_ITEM_TYPES = new Set([
82
+ "commandExecution",
83
+ "fileChange",
84
+ "mcpToolCall",
85
+ "dynamicToolCall",
86
+ "webSearch",
87
+ ]);
88
+
89
+ /**
90
+ * Map one Codex JSON-RPC *notification* (method + params) to AgentEvents.
91
+ * Pure and total — an unrecognised method yields [].
92
+ */
93
+ export function mapCodexNotification(
94
+ method: string,
95
+ params: unknown,
96
+ ): AgentEvent[] {
97
+ const p = isObj(params) ? params : {};
98
+
99
+ if (method === "item/agentMessage/delta") {
100
+ const text = str(p.delta);
101
+ return text ? [{ type: "message_delta", text }] : [];
102
+ }
103
+
104
+ if (method === "item/completed" && isObj(p.item)) {
105
+ const item = p.item;
106
+ const itemType = str(item.type);
107
+ if (itemType === "agentMessage") {
108
+ return [{ type: "message_complete", text: str(item.text) }];
109
+ }
110
+ if (TOOL_ITEM_TYPES.has(itemType)) {
111
+ return [
112
+ {
113
+ type: "tool_call",
114
+ id: str(item.id) || newId(),
115
+ title: codexToolTitle(itemType, item),
116
+ detail: codexToolDetail(item),
117
+ },
118
+ ];
119
+ }
120
+ return [];
121
+ }
122
+
123
+ if (method === "turn/completed") {
124
+ const turn = isObj(p.turn) ? p.turn : {};
125
+ if (turn.status === "failed" && isObj(turn.error)) {
126
+ return [
127
+ { type: "error", message: str(turn.error.message, "codex turn failed") },
128
+ { type: "turn_complete" },
129
+ ];
130
+ }
131
+ return [{ type: "turn_complete" }];
132
+ }
133
+
134
+ if (method === "error") {
135
+ const message = isObj(p.error)
136
+ ? str(p.error.message, "codex error")
137
+ : "codex error";
138
+ return [{ type: "error", message }];
139
+ }
140
+
141
+ return [];
142
+ }
143
+
144
+ function codexToolTitle(
145
+ itemType: string,
146
+ item: Record<string, unknown>,
147
+ ): string {
148
+ if (itemType === "commandExecution") {
149
+ const c = item.command;
150
+ const cmd = Array.isArray(c)
151
+ ? c.map((x) => String(x)).join(" ")
152
+ : str(c, "command");
153
+ return `$ ${cmd}`;
154
+ }
155
+ if (itemType === "fileChange") {
156
+ const n = Array.isArray(item.changes) ? item.changes.length : 0;
157
+ return `Edit ${n} file${n === 1 ? "" : "s"}`;
158
+ }
159
+ if (itemType === "mcpToolCall") {
160
+ return `MCP ${str(item.server)}/${str(item.tool)}`;
161
+ }
162
+ if (itemType === "dynamicToolCall") return `Tool ${str(item.tool)}`;
163
+ if (itemType === "webSearch") return `Search ${str(item.query)}`;
164
+ return itemType;
165
+ }
166
+
167
+ function codexToolDetail(item: Record<string, unknown>): string {
168
+ for (const key of ["aggregatedOutput", "result", "query"]) {
169
+ const v = item[key];
170
+ if (typeof v === "string" && v.length > 0) return v;
171
+ }
172
+ try {
173
+ return JSON.stringify(item, null, 2);
174
+ } catch {
175
+ return "";
176
+ }
177
+ }
178
+
179
+ // ---------------------------------------------------------------------------
180
+ // JSON-RPC framing.
181
+ // ---------------------------------------------------------------------------
182
+
183
+ /** Server→client requests we recognise as approval prompts. */
184
+ const APPROVAL_METHODS = new Set([
185
+ "item/commandExecution/requestApproval",
186
+ "item/fileChange/requestApproval",
187
+ "item/permissions/requestApproval",
188
+ "applyPatchApproval",
189
+ "execCommandApproval",
190
+ ]);
191
+
192
+ /** Bounds the handshake so a wrong-shape protocol surfaces as an error
193
+ * rather than an endless "thinking…". `turn/start` is exempt — turns
194
+ * can legitimately run for minutes. */
195
+ const RPC_TIMEOUT_MS = 30_000;
196
+
197
+ // ---------------------------------------------------------------------------
198
+ // The session.
199
+ // ---------------------------------------------------------------------------
200
+
201
+ export class CodexSession implements AgentSession {
202
+ readonly agentId: string;
203
+ readonly kind: AgentKind = "codex";
204
+
205
+ private readonly proc: LineProcess;
206
+ private readonly handlers = new Set<AgentEventHandler>();
207
+ private readonly systemPreamble: string;
208
+ private closed = false;
209
+ private handshakeOk = false;
210
+
211
+ private nextRpcId = 1;
212
+ private readonly pending = new Map<
213
+ number,
214
+ { resolve: (v: unknown) => void; reject: (e: Error) => void }
215
+ >();
216
+ /** permission_request id → the JSON-RPC request id to answer with. */
217
+ private readonly approvals = new Map<string, number | string>();
218
+
219
+ private threadId: string | null = null;
220
+ private readonly ready: Promise<void>;
221
+
222
+ constructor(args: {
223
+ agent: RosterAgent;
224
+ containerName: string;
225
+ systemPreamble: string;
226
+ }) {
227
+ this.agentId = args.agent.id;
228
+ this.systemPreamble = args.systemPreamble;
229
+
230
+ // The agent's model / effort (when set) are selected via config overrides
231
+ // (`-c model=<model>`, `-c model_reasoning_effort=<effort>`) on the
232
+ // app-server process (ADR-021). All `-c` overrides go BEFORE the
233
+ // `app-server` subcommand. Without them codex uses its configured defaults.
234
+ const codexArgs: string[] = [];
235
+ if (args.agent.model) {
236
+ codexArgs.push("-c", `model=${args.agent.model}`);
237
+ }
238
+ if (args.agent.effort) {
239
+ codexArgs.push("-c", `model_reasoning_effort=${args.agent.effort}`);
240
+ }
241
+ codexArgs.push("app-server");
242
+ const { command, args: argv } = dockerExecArgs(
243
+ args.containerName,
244
+ "codex",
245
+ codexArgs,
246
+ );
247
+ this.proc = new LineProcess({
248
+ command,
249
+ args: argv,
250
+ debugLabel: `codex:${this.agentId}`,
251
+ });
252
+ this.proc.onLine((line) => this.onLine(line));
253
+ this.proc.onExit((code) => {
254
+ if (this.closed) return;
255
+ if (code !== 0) {
256
+ const tail = this.proc.stderrTail.trim();
257
+ this.emit({
258
+ type: "error",
259
+ message:
260
+ `codex process exited (${code ?? "spawn failed"})` +
261
+ (tail
262
+ ? `:\n${tail}`
263
+ : " — is the codex CLI installed in the task container, and is the container running?"),
264
+ });
265
+ }
266
+ this.closed = true;
267
+ for (const waiter of this.pending.values()) {
268
+ waiter.reject(new Error("codex process exited"));
269
+ }
270
+ this.pending.clear();
271
+ this.emit({ type: "exit", code: code ?? -1 });
272
+ });
273
+
274
+ this.ready = this.handshake().catch((err: unknown) => {
275
+ this.emit({
276
+ type: "error",
277
+ message: `codex handshake failed: ${
278
+ err instanceof Error ? err.message : String(err)
279
+ }`,
280
+ });
281
+ });
282
+ }
283
+
284
+ // -- JSON-RPC plumbing ----------------------------------------------------
285
+
286
+ private onLine(line: string): void {
287
+ let msg: unknown;
288
+ try {
289
+ msg = JSON.parse(line);
290
+ } catch {
291
+ return;
292
+ }
293
+ if (!isObj(msg)) return;
294
+
295
+ const hasId = "id" in msg && (typeof msg.id === "number" || typeof msg.id === "string");
296
+
297
+ // Response to one of our requests (id, no method).
298
+ if (hasId && !("method" in msg)) {
299
+ const id = typeof msg.id === "number" ? msg.id : -1;
300
+ const waiter = this.pending.get(id);
301
+ if (waiter) {
302
+ this.pending.delete(id);
303
+ if (isObj(msg.error)) {
304
+ waiter.reject(new Error(str(msg.error.message, "rpc error")));
305
+ } else {
306
+ waiter.resolve(msg.result);
307
+ }
308
+ }
309
+ return;
310
+ }
311
+
312
+ // Server→client request (id + method).
313
+ if (hasId && "method" in msg) {
314
+ this.onServerRequest(
315
+ msg.id as number | string,
316
+ str(msg.method),
317
+ msg.params,
318
+ );
319
+ return;
320
+ }
321
+
322
+ // Notification (method, no id).
323
+ if ("method" in msg) {
324
+ const method = str(msg.method);
325
+ // The thread id arrives on `thread/started`; also captured from
326
+ // the `thread/start` response in handshake().
327
+ if (method === "thread/started" && isObj(msg.params)) {
328
+ const thread = msg.params.thread;
329
+ if (isObj(thread) && typeof thread.id === "string") {
330
+ this.threadId = thread.id;
331
+ }
332
+ }
333
+ for (const ev of mapCodexNotification(method, msg.params)) {
334
+ this.emit(ev);
335
+ }
336
+ }
337
+ }
338
+
339
+ private onServerRequest(
340
+ id: number | string,
341
+ method: string,
342
+ params: unknown,
343
+ ): void {
344
+ if (APPROVAL_METHODS.has(method)) {
345
+ const permId = newId();
346
+ this.approvals.set(permId, id);
347
+ const p = isObj(params) ? params : {};
348
+ this.emit({
349
+ type: "permission_request",
350
+ id: permId,
351
+ title: method.includes("fileChange")
352
+ ? "Allow file changes?"
353
+ : `Allow: ${str(p.command, method)}`,
354
+ detail: codexToolDetail(p),
355
+ });
356
+ return;
357
+ }
358
+ // Anything else (tool calls, elicitation, token refresh) — we can't
359
+ // service it; reply with an error so codex doesn't block waiting.
360
+ this.respondError(id, `uai does not handle "${method}"`);
361
+ }
362
+
363
+ /** Send a JSON-RPC request, resolving with its result; rejects after
364
+ * RPC_TIMEOUT_MS. For short calls (initialize, thread/start). */
365
+ private request(method: string, params: unknown): Promise<unknown> {
366
+ const id = this.nextRpcId++;
367
+ return new Promise<unknown>((resolve, reject) => {
368
+ const timer = setTimeout(() => {
369
+ if (this.pending.delete(id)) {
370
+ reject(
371
+ new Error(
372
+ `codex did not answer "${method}" within ${
373
+ RPC_TIMEOUT_MS / 1000
374
+ }s`,
375
+ ),
376
+ );
377
+ }
378
+ }, RPC_TIMEOUT_MS);
379
+ this.pending.set(id, {
380
+ resolve: (v) => {
381
+ clearTimeout(timer);
382
+ resolve(v);
383
+ },
384
+ reject: (e) => {
385
+ clearTimeout(timer);
386
+ reject(e);
387
+ },
388
+ });
389
+ this.proc.writeLine({ jsonrpc: "2.0", id, method, params });
390
+ });
391
+ }
392
+
393
+ /** Send a JSON-RPC request without awaiting it — for `turn/start`,
394
+ * whose output streams as notifications and whose response can take
395
+ * minutes. The eventual response is ignored (no pending waiter). */
396
+ private fireRequest(method: string, params: unknown): void {
397
+ this.proc.writeLine({
398
+ jsonrpc: "2.0",
399
+ id: this.nextRpcId++,
400
+ method,
401
+ params,
402
+ });
403
+ }
404
+
405
+ private notify(method: string, params: unknown): void {
406
+ this.proc.writeLine({ jsonrpc: "2.0", method, params });
407
+ }
408
+
409
+ private respond(id: number | string, result: unknown): void {
410
+ this.proc.writeLine({ jsonrpc: "2.0", id, result });
411
+ }
412
+
413
+ private respondError(id: number | string, message: string): void {
414
+ this.proc.writeLine({
415
+ jsonrpc: "2.0",
416
+ id,
417
+ error: { code: -32601, message },
418
+ });
419
+ }
420
+
421
+ private async handshake(): Promise<void> {
422
+ await this.request("initialize", {
423
+ clientInfo: { name: "uai", version: "0.2" },
424
+ });
425
+ this.notify("initialized", {});
426
+ // The uai channel briefing (how to @-mention, the roster, the
427
+ // project's defaultPrompt) goes in as developer instructions so it
428
+ // applies to every turn on the thread.
429
+ const threadParams: Record<string, unknown> = {
430
+ approvalPolicy: "never",
431
+ sandbox: "danger-full-access",
432
+ };
433
+ if (this.systemPreamble.trim().length > 0) {
434
+ threadParams.developerInstructions = this.systemPreamble;
435
+ }
436
+ const result = await this.request("thread/start", threadParams);
437
+ if (isObj(result) && isObj(result.thread)) {
438
+ this.threadId = str(result.thread.id) || this.threadId;
439
+ }
440
+ this.handshakeOk = true;
441
+ }
442
+
443
+ // -- AgentSession ---------------------------------------------------------
444
+
445
+ onEvent(handler: AgentEventHandler): () => void {
446
+ this.handlers.add(handler);
447
+ return () => this.handlers.delete(handler);
448
+ }
449
+
450
+ private emit(event: AgentEvent): void {
451
+ if (this.closed && event.type !== "exit") return;
452
+ for (const h of this.handlers) h(event);
453
+ }
454
+
455
+ async send(text: string): Promise<void> {
456
+ if (this.closed) return;
457
+ await this.ready;
458
+ if (this.closed) return;
459
+ if (!this.handshakeOk || !this.threadId) {
460
+ this.emit({
461
+ type: "error",
462
+ message:
463
+ "codex is unavailable — the app-server handshake did not complete.",
464
+ });
465
+ return;
466
+ }
467
+
468
+ this.fireRequest("turn/start", {
469
+ threadId: this.threadId,
470
+ input: [{ type: "text", text }],
471
+ });
472
+ }
473
+
474
+ async interrupt(): Promise<void> {
475
+ if (this.closed || !this.threadId) return;
476
+ // Cancel the thread's in-flight turn (codex's ESC). The app-server ends the
477
+ // current turn and emits a `turn/completed`. No-op when nothing is running.
478
+ // ⚠️ VERIFY-ON-MAC: confirm the codex interrupt method/params.
479
+ this.fireRequest("turn/interrupt", { threadId: this.threadId });
480
+ }
481
+
482
+ async resolvePermission(
483
+ requestId: string,
484
+ decision: "accept" | "decline",
485
+ ): Promise<void> {
486
+ if (this.closed) return;
487
+ const rpcId = this.approvals.get(requestId);
488
+ if (rpcId === undefined) return;
489
+ this.approvals.delete(requestId);
490
+ this.respond(rpcId, {
491
+ decision: decision === "accept" ? "accept" : "decline",
492
+ });
493
+ }
494
+
495
+ async close(): Promise<void> {
496
+ if (this.closed) {
497
+ await this.proc.close();
498
+ return;
499
+ }
500
+ this.closed = true;
501
+ for (const waiter of this.pending.values()) {
502
+ waiter.reject(new Error("session closed"));
503
+ }
504
+ this.pending.clear();
505
+ await this.proc.close();
506
+ this.emit({ type: "exit", code: 0 });
507
+ this.handlers.clear();
508
+ }
509
+ }
510
+
511
+ // Register the Codex adapter at module load (ADR-021). The `-c model=` config
512
+ // override above means `model` flows from the roster entry to the app-server.
513
+ register({
514
+ kind: "codex",
515
+ label: "Codex",
516
+ supportedModels: () => [...CODEX_MODELS],
517
+ defaultModel: CODEX_DEFAULT_MODEL,
518
+ supportedEfforts: () => [...CODEX_EFFORTS],
519
+ defaultEffort: CODEX_DEFAULT_EFFORT,
520
+ create: async ({ agent, containerName, systemPreamble }) =>
521
+ new CodexSession({ agent, containerName, systemPreamble }),
522
+ });
@@ -0,0 +1,34 @@
1
+ /**
2
+ * realAgentFactory — builds a real AgentSession per roster agent, resolving
3
+ * the adapter from the plug-in registry by `agent.kind` (ADR-021).
4
+ *
5
+ * Importing this module pulls in `./claude` and `./codex` purely for their
6
+ * side effect: each calls `register()` at load, populating the registry.
7
+ * Adding a kind is then "drop a module + import it for its side effect" — no
8
+ * change here. Unknown kinds fail loudly so a typo in a roster surfaces as an
9
+ * error rather than silently falling back to the wrong CLI.
10
+ *
11
+ * Both adapters run their CLI inside the task container via `docker exec`
12
+ * (ADR-010); the agent's `model` (if set) is passed through. Swapping mock ⇄
13
+ * real is changing which factory the orchestrator is constructed with — see
14
+ * `getOrchestrator`.
15
+ */
16
+
17
+ // Side-effect imports: register the built-in adapters with the registry.
18
+ import "./claude";
19
+ import "./codex";
20
+
21
+ import { factoryFor } from "./registry";
22
+ import type { AgentSession, AgentSessionFactory } from "./types";
23
+
24
+ export const realAgentFactory: AgentSessionFactory = {
25
+ create: async (args): Promise<AgentSession> => {
26
+ const factory = factoryFor(args.agent.kind);
27
+ if (!factory) {
28
+ throw new Error(
29
+ `no agent adapter registered for kind "${args.agent.kind}"`,
30
+ );
31
+ }
32
+ return factory.create(args);
33
+ },
34
+ };
@@ -0,0 +1,133 @@
1
+ /**
2
+ * MockAgentSession — a fake AgentSession for building and testing the
3
+ * chat pipeline without Docker or a real CLI.
4
+ *
5
+ * On send(), it streams a few `message_delta` chunks, sometimes a
6
+ * `tool_call` card, then `message_complete` + `turn_complete`. Enough
7
+ * shape that the orchestrator, SSE stream, and chat UI can all be
8
+ * exercised end to end. The real ClaudeSession / CodexSession adapters
9
+ * replace this behind the identical interface.
10
+ */
11
+
12
+ import { newId } from "../ulid";
13
+ import type {
14
+ AgentEvent,
15
+ AgentEventHandler,
16
+ AgentKind,
17
+ AgentSession,
18
+ AgentSessionFactory,
19
+ RosterAgent,
20
+ } from "./types";
21
+
22
+ /** Tunable so tests can run instantly while dev feels lifelike. */
23
+ const DELTA_INTERVAL_MS = Number(process.env.UAI_MOCK_DELTA_MS ?? 60);
24
+
25
+ export class MockAgentSession implements AgentSession {
26
+ readonly agentId: string;
27
+ readonly kind: AgentKind;
28
+
29
+ private readonly handlers = new Set<AgentEventHandler>();
30
+ private readonly timers = new Set<ReturnType<typeof setTimeout>>();
31
+ private closed = false;
32
+ private turn = 0;
33
+
34
+ constructor(args: { agent: RosterAgent; systemPreamble: string }) {
35
+ this.agentId = args.agent.id;
36
+ this.kind = args.agent.kind;
37
+ }
38
+
39
+ onEvent(handler: AgentEventHandler): () => void {
40
+ this.handlers.add(handler);
41
+ return () => this.handlers.delete(handler);
42
+ }
43
+
44
+ private emit(event: AgentEvent): void {
45
+ if (this.closed) return;
46
+ for (const h of this.handlers) h(event);
47
+ }
48
+
49
+ /** schedule(fn, ms) — like setTimeout but cleaned up on close(). */
50
+ private schedule(fn: () => void, ms: number): void {
51
+ const t = setTimeout(() => {
52
+ this.timers.delete(t);
53
+ if (!this.closed) fn();
54
+ }, ms);
55
+ this.timers.add(t);
56
+ }
57
+
58
+ async send(text: string): Promise<void> {
59
+ if (this.closed) return;
60
+ this.turn += 1;
61
+
62
+ // Compose a canned reply that echoes a little of the input so the
63
+ // feed visibly reflects what was sent.
64
+ const trimmed = text.trim().replace(/\s+/g, " ");
65
+ const echo = trimmed.length > 60 ? `${trimmed.slice(0, 57)}…` : trimmed;
66
+ const reply =
67
+ `[mock ${this.agentId}] received: "${echo}". ` +
68
+ `This is turn ${this.turn}. Wiring up the real ${this.kind} ` +
69
+ `adapter is the next step — until then I just echo.`;
70
+
71
+ const words = reply.split(" ");
72
+ let cursor = 0;
73
+ let assembled = "";
74
+
75
+ // Stream the reply word-by-word.
76
+ const streamNext = (): void => {
77
+ if (cursor >= words.length) {
78
+ this.emit({ type: "message_complete", text: assembled });
79
+ // Halfway through the conversation, fake a tool call so the
80
+ // card rendering path gets exercised.
81
+ if (this.turn === 1) {
82
+ this.schedule(() => {
83
+ this.emit({
84
+ type: "tool_call",
85
+ id: newId(),
86
+ title: "Read",
87
+ detail: "README.md (mock)",
88
+ });
89
+ this.schedule(() => this.emit({ type: "turn_complete" }), 200);
90
+ }, 150);
91
+ } else {
92
+ this.emit({ type: "turn_complete" });
93
+ }
94
+ return;
95
+ }
96
+ const chunk = (cursor === 0 ? "" : " ") + words[cursor];
97
+ assembled += chunk;
98
+ cursor += 1;
99
+ this.emit({ type: "message_delta", text: chunk });
100
+ this.schedule(streamNext, DELTA_INTERVAL_MS);
101
+ };
102
+
103
+ // Small initial "thinking" delay before the first token.
104
+ this.schedule(streamNext, DELTA_INTERVAL_MS * 2);
105
+ }
106
+
107
+ async interrupt(): Promise<void> {
108
+ if (this.closed) return;
109
+ // Stop any in-flight streaming and end the turn — the mock's ESC.
110
+ for (const t of this.timers) clearTimeout(t);
111
+ this.timers.clear();
112
+ this.emit({ type: "turn_complete" });
113
+ }
114
+
115
+ async resolvePermission(): Promise<void> {
116
+ // The mock never raises permission requests, so nothing to resolve.
117
+ }
118
+
119
+ async close(): Promise<void> {
120
+ if (this.closed) return;
121
+ this.closed = true;
122
+ for (const t of this.timers) clearTimeout(t);
123
+ this.timers.clear();
124
+ this.emit({ type: "exit", code: 0 });
125
+ this.handlers.clear();
126
+ }
127
+ }
128
+
129
+ /** Factory that hands back MockAgentSessions. */
130
+ export const mockAgentFactory: AgentSessionFactory = {
131
+ create: async ({ agent, systemPreamble }) =>
132
+ new MockAgentSession({ agent, systemPreamble }),
133
+ };