@qwen-code/arena-agent 0.0.1

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/.env.example +52 -0
  2. package/README.md +436 -0
  3. package/dist/agent.d.ts +33 -0
  4. package/dist/agent.js +366 -0
  5. package/dist/agent.js.map +1 -0
  6. package/dist/attachments.d.ts +29 -0
  7. package/dist/attachments.js +222 -0
  8. package/dist/attachments.js.map +1 -0
  9. package/dist/cli.d.ts +2 -0
  10. package/dist/cli.js +256 -0
  11. package/dist/cli.js.map +1 -0
  12. package/dist/compaction.d.ts +15 -0
  13. package/dist/compaction.js +176 -0
  14. package/dist/compaction.js.map +1 -0
  15. package/dist/config.d.ts +23 -0
  16. package/dist/config.js +61 -0
  17. package/dist/config.js.map +1 -0
  18. package/dist/document-reader.d.ts +18 -0
  19. package/dist/document-reader.js +423 -0
  20. package/dist/document-reader.js.map +1 -0
  21. package/dist/fetch-page.d.ts +25 -0
  22. package/dist/fetch-page.js +428 -0
  23. package/dist/fetch-page.js.map +1 -0
  24. package/dist/history.d.ts +5 -0
  25. package/dist/history.js +31 -0
  26. package/dist/history.js.map +1 -0
  27. package/dist/mock.d.ts +4 -0
  28. package/dist/mock.js +151 -0
  29. package/dist/mock.js.map +1 -0
  30. package/dist/protocol.d.ts +23 -0
  31. package/dist/protocol.js +84 -0
  32. package/dist/protocol.js.map +1 -0
  33. package/dist/provider.d.ts +31 -0
  34. package/dist/provider.js +308 -0
  35. package/dist/provider.js.map +1 -0
  36. package/dist/sdk.d.ts +181 -0
  37. package/dist/sdk.js +674 -0
  38. package/dist/sdk.js.map +1 -0
  39. package/dist/server.d.ts +15 -0
  40. package/dist/server.js +1433 -0
  41. package/dist/server.js.map +1 -0
  42. package/dist/spec.d.ts +11 -0
  43. package/dist/spec.js +198 -0
  44. package/dist/spec.js.map +1 -0
  45. package/dist/tools.d.ts +38 -0
  46. package/dist/tools.js +992 -0
  47. package/dist/tools.js.map +1 -0
  48. package/dist/tui.d.ts +10 -0
  49. package/dist/tui.js +350 -0
  50. package/dist/tui.js.map +1 -0
  51. package/dist/types.d.ts +146 -0
  52. package/dist/types.js +2 -0
  53. package/dist/types.js.map +1 -0
  54. package/dist/web/app.d.ts +1 -0
  55. package/dist/web/app.js +1082 -0
  56. package/dist/web/app.js.map +1 -0
  57. package/docs/SDK.md +521 -0
  58. package/package.json +59 -0
  59. package/spec/message.json +150 -0
  60. package/spec/tools.json +418 -0
  61. package/web/arena-logo.svg +11 -0
  62. package/web/fonts.css +67 -0
  63. package/web/index.html +285 -0
  64. package/web/styles.css +1798 -0
package/dist/server.js ADDED
@@ -0,0 +1,1433 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import { cp, mkdir, readFile, readdir, realpath, rename, stat, writeFile, } from "node:fs/promises";
4
+ import http from "node:http";
5
+ import path from "node:path";
6
+ import { Agent } from "./agent.js";
7
+ import { attachmentMimeType, attachmentUserMessage, maxAttachmentBytesPerMessage, maxAttachmentsPerMessage, maxUploadBytes, mimeTypes, saveAttachment, uploadsDirectory, } from "./attachments.js";
8
+ import { configFromEnv } from "./config.js";
9
+ import { loadStoredHistory, saveStoredHistory, sessionStorageRoot, } from "./history.js";
10
+ import { EventSink } from "./protocol.js";
11
+ import { projectRoot } from "./spec.js";
12
+ import { ToolExecutor } from "./tools.js";
13
+ const sessionSeedExcludes = new Set([
14
+ ".arena",
15
+ ".cache",
16
+ ".git",
17
+ ".mypy_cache",
18
+ ".next",
19
+ ".nox",
20
+ ".npm",
21
+ ".nuxt",
22
+ ".output",
23
+ ".parcel-cache",
24
+ ".pytest_cache",
25
+ ".ruff_cache",
26
+ ".svelte-kit",
27
+ ".tox",
28
+ ".turbo",
29
+ ".venv",
30
+ ".vite",
31
+ "__pycache__",
32
+ "build",
33
+ "coverage",
34
+ "dist",
35
+ "node_modules",
36
+ "out",
37
+ "target",
38
+ ]);
39
+ function errorMessage(error) {
40
+ return error instanceof Error ? error.message : String(error);
41
+ }
42
+ function corsHeaders() {
43
+ return {
44
+ "access-control-allow-origin": "*",
45
+ "access-control-allow-methods": "GET,POST,OPTIONS",
46
+ "access-control-allow-headers": "authorization,content-type,last-event-id,x-file-name,x-part-id,x-trigger-source",
47
+ };
48
+ }
49
+ function writeJson(response, statusCode, value, headers = {}) {
50
+ response.writeHead(statusCode, {
51
+ ...corsHeaders(),
52
+ "content-type": "application/json; charset=utf-8",
53
+ "cache-control": "no-store",
54
+ ...headers,
55
+ });
56
+ response.end(JSON.stringify(value));
57
+ }
58
+ async function readRequestBytes(request, limit) {
59
+ const declaredLength = Number(request.headers["content-length"] || 0);
60
+ if (declaredLength > limit) {
61
+ throw new Error(`request body exceeds ${limit} bytes`);
62
+ }
63
+ const chunks = [];
64
+ let size = 0;
65
+ for await (const chunk of request) {
66
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
67
+ size += bytes.length;
68
+ if (size > limit)
69
+ throw new Error(`request body exceeds ${limit} bytes`);
70
+ chunks.push(bytes);
71
+ }
72
+ return Buffer.concat(chunks);
73
+ }
74
+ async function readBody(request) {
75
+ return (await readRequestBytes(request, 1024 * 1024)).toString("utf8");
76
+ }
77
+ async function readJson(request) {
78
+ return JSON.parse((await readBody(request)) || "{}");
79
+ }
80
+ function validSessionId(sessionId) {
81
+ return (typeof sessionId === "string" &&
82
+ /^[A-Za-z0-9-]{1,128}$/.test(sessionId));
83
+ }
84
+ function validTenantId(tenantId) {
85
+ return (typeof tenantId === "string" &&
86
+ /^[A-Za-z0-9_-]{1,64}$/.test(tenantId));
87
+ }
88
+ function validClientDate(value) {
89
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(value))
90
+ return false;
91
+ const [year, month, day] = value.split("-").map(Number);
92
+ const parsed = new Date(Date.UTC(year || 0, (month || 0) - 1, day || 0));
93
+ return (parsed.getUTCFullYear() === year &&
94
+ parsed.getUTCMonth() + 1 === month &&
95
+ parsed.getUTCDate() === day);
96
+ }
97
+ function validTimezone(value) {
98
+ if (!value || value.length > 100)
99
+ return false;
100
+ try {
101
+ new Intl.DateTimeFormat("en", { timeZone: value }).format();
102
+ return true;
103
+ }
104
+ catch {
105
+ return false;
106
+ }
107
+ }
108
+ function dateInTimezone(timezone) {
109
+ const parts = new Intl.DateTimeFormat("en-CA", {
110
+ timeZone: validTimezone(timezone) ? timezone : "UTC",
111
+ year: "numeric",
112
+ month: "2-digit",
113
+ day: "2-digit",
114
+ }).formatToParts(new Date());
115
+ const value = (type) => parts.find((part) => part.type === type)?.value || "";
116
+ return `${value("year")}-${value("month")}-${value("day")}`;
117
+ }
118
+ function clientLocation(value) {
119
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
120
+ throw new Error("location must be an object");
121
+ }
122
+ const source = value;
123
+ const result = {};
124
+ for (const key of ["city", "region", "country"]) {
125
+ const item = source[key];
126
+ if (item === undefined || item === null || item === "")
127
+ continue;
128
+ if (typeof item !== "string" ||
129
+ item.length > 160 ||
130
+ /[\u0000-\u001f\u007f]/.test(item)) {
131
+ throw new Error(`location.${key} must be a single-line string`);
132
+ }
133
+ const normalized = item.trim();
134
+ if (normalized)
135
+ result[key] = normalized;
136
+ }
137
+ return Object.keys(result).length ? result : undefined;
138
+ }
139
+ function sessionRoot(context, sessionId) {
140
+ return sessionStorageRoot(context.workspace, context.config.tenantId, sessionId);
141
+ }
142
+ function sessionPaths(context, sessionId) {
143
+ const root = sessionRoot(context, sessionId);
144
+ return {
145
+ root,
146
+ workspace: path.join(root, "workspace"),
147
+ artifacts: path.join(root, "artifacts"),
148
+ history: path.join(root, "history.json"),
149
+ metadata: path.join(root, "session.json"),
150
+ };
151
+ }
152
+ function sessionHistoryPath(session) {
153
+ return path.join(session.root, "history.json");
154
+ }
155
+ function sessionMetadataPath(session) {
156
+ return path.join(session.root, "session.json");
157
+ }
158
+ function hashToken(token) {
159
+ return crypto.createHash("sha256").update(token).digest("hex");
160
+ }
161
+ function tokenMatches(session, token) {
162
+ if (!token)
163
+ return false;
164
+ const presented = Buffer.from(hashToken(token), "hex");
165
+ const expected = Buffer.from(session.tokenHash, "hex");
166
+ return (presented.length === expected.length &&
167
+ crypto.timingSafeEqual(presented, expected));
168
+ }
169
+ function sessionCookieName(sessionId) {
170
+ return `arena_session_${sessionId.replaceAll("-", "_")}`;
171
+ }
172
+ function sessionCookie(session, token) {
173
+ const maxAge = Math.max(0, Math.ceil((session.tokenExpiresAt - Date.now()) / 1000));
174
+ return `${sessionCookieName(session.id)}=${encodeURIComponent(token)}; ` +
175
+ `Path=/api/chat/sessions/${session.id}/; HttpOnly; SameSite=Strict; ` +
176
+ `Max-Age=${maxAge}`;
177
+ }
178
+ function cookieToken(request, sessionId) {
179
+ const target = sessionCookieName(sessionId);
180
+ for (const item of (request.headers.cookie || "").split(";")) {
181
+ const [name, ...parts] = item.trim().split("=");
182
+ if (name === target)
183
+ return decodeURIComponent(parts.join("="));
184
+ }
185
+ return "";
186
+ }
187
+ function presentedToken(request, sessionId) {
188
+ return bearerToken(request) || cookieToken(request, sessionId);
189
+ }
190
+ async function saveSessionMetadata(session) {
191
+ const metadata = {
192
+ version: 1,
193
+ id: session.id,
194
+ tenantId: session.tenantId,
195
+ currentDate: session.currentDate,
196
+ timezone: session.timezone,
197
+ ...(session.location ? { location: session.location } : {}),
198
+ tokenHash: session.tokenHash,
199
+ tokenExpiresAt: session.tokenExpiresAt,
200
+ createdAt: session.createdAt,
201
+ touchedAt: session.touchedAt,
202
+ pendingInterruption: session.pendingInterruption,
203
+ interruptionGeneration: session.interruptionGeneration,
204
+ };
205
+ const target = sessionMetadataPath(session);
206
+ const temporary = `${target}.${crypto.randomUUID()}.tmp`;
207
+ await writeFile(temporary, JSON.stringify(metadata), "utf8");
208
+ await rename(temporary, target);
209
+ }
210
+ async function seedSessionWorkspace(context, target) {
211
+ await mkdir(target, { recursive: true });
212
+ if (process.env.ARENA_SESSION_WORKSPACE_MODE === "empty") {
213
+ return;
214
+ }
215
+ const shouldCopy = (source) => {
216
+ const relative = path.relative(context.workspace, source);
217
+ const parts = relative.split(path.sep);
218
+ if (parts.some((part) => sessionSeedExcludes.has(part)))
219
+ return false;
220
+ const name = parts.at(-1) || "";
221
+ return name === ".env.example" || !/^\.env(?:\.|$)/.test(name);
222
+ };
223
+ const entries = await readdir(context.workspace, { withFileTypes: true });
224
+ await Promise.all(entries
225
+ .filter((entry) => shouldCopy(path.join(context.workspace, entry.name)))
226
+ .map((entry) => cp(path.join(context.workspace, entry.name), path.join(target, entry.name), {
227
+ recursive: true,
228
+ mode: fs.constants.COPYFILE_FICLONE,
229
+ filter: shouldCopy,
230
+ verbatimSymlinks: true,
231
+ })));
232
+ }
233
+ async function createSession(context, clientContext) {
234
+ const id = crypto.randomUUID();
235
+ const paths = sessionPaths(context, id);
236
+ const token = crypto.randomBytes(32).toString("base64url");
237
+ const now = Date.now();
238
+ const session = {
239
+ id,
240
+ tenantId: context.config.tenantId,
241
+ currentDate: clientContext.currentDate,
242
+ timezone: clientContext.timezone,
243
+ ...(clientContext.location ? { location: clientContext.location } : {}),
244
+ tokenHash: hashToken(token),
245
+ tokenExpiresAt: now + 60 * 60 * 1000,
246
+ createdAt: now,
247
+ root: paths.root,
248
+ workspace: paths.workspace,
249
+ artifacts: paths.artifacts,
250
+ events: [],
251
+ nextEventId: 1,
252
+ subscribers: new Set(),
253
+ running: null,
254
+ pendingInput: null,
255
+ pendingInterruption: false,
256
+ interruptionGeneration: 0,
257
+ turnStartEventId: "0",
258
+ activePrompt: null,
259
+ touchedAt: now,
260
+ };
261
+ await mkdir(session.root, { recursive: true });
262
+ await Promise.all([
263
+ seedSessionWorkspace(context, session.workspace),
264
+ mkdir(session.artifacts, { recursive: true }),
265
+ ]);
266
+ await saveSessionMetadata(session);
267
+ context.sessions.set(id, session);
268
+ return { session, token };
269
+ }
270
+ async function getSession(context, sessionId) {
271
+ const existing = context.sessions.get(sessionId);
272
+ if (existing)
273
+ return existing;
274
+ if (!validSessionId(sessionId))
275
+ return undefined;
276
+ const paths = sessionPaths(context, sessionId);
277
+ try {
278
+ const metadata = JSON.parse(await readFile(paths.metadata, "utf8"));
279
+ if (metadata.version !== 1 ||
280
+ metadata.id !== sessionId ||
281
+ metadata.tenantId !== context.config.tenantId ||
282
+ typeof metadata.timezone !== "string" ||
283
+ typeof metadata.tokenHash !== "string" ||
284
+ !/^[a-f0-9]{64}$/.test(metadata.tokenHash) ||
285
+ typeof metadata.tokenExpiresAt !== "number") {
286
+ throw new Error("invalid persisted session metadata");
287
+ }
288
+ const storedLocation = metadata.location === undefined
289
+ ? undefined
290
+ : clientLocation(metadata.location);
291
+ const loaded = {
292
+ id: sessionId,
293
+ tenantId: metadata.tenantId,
294
+ currentDate: typeof metadata.currentDate === "string" &&
295
+ validClientDate(metadata.currentDate)
296
+ ? metadata.currentDate
297
+ : dateInTimezone(metadata.timezone),
298
+ timezone: metadata.timezone,
299
+ ...(storedLocation ? { location: storedLocation } : {}),
300
+ tokenHash: metadata.tokenHash,
301
+ tokenExpiresAt: metadata.tokenExpiresAt,
302
+ createdAt: typeof metadata.createdAt === "number"
303
+ ? metadata.createdAt
304
+ : Date.now(),
305
+ root: paths.root,
306
+ workspace: paths.workspace,
307
+ artifacts: paths.artifacts,
308
+ events: [],
309
+ nextEventId: 1,
310
+ subscribers: new Set(),
311
+ running: null,
312
+ pendingInput: null,
313
+ pendingInterruption: metadata.pendingInterruption === true,
314
+ interruptionGeneration: typeof metadata.interruptionGeneration === "number" &&
315
+ Number.isSafeInteger(metadata.interruptionGeneration) &&
316
+ metadata.interruptionGeneration >= 0
317
+ ? metadata.interruptionGeneration
318
+ : metadata.pendingInterruption === true
319
+ ? 1
320
+ : 0,
321
+ turnStartEventId: "0",
322
+ activePrompt: null,
323
+ touchedAt: typeof metadata.touchedAt === "number"
324
+ ? metadata.touchedAt
325
+ : Date.now(),
326
+ };
327
+ await Promise.all([
328
+ mkdir(loaded.workspace, { recursive: true }),
329
+ mkdir(loaded.artifacts, { recursive: true }),
330
+ ]);
331
+ const raced = context.sessions.get(sessionId);
332
+ if (raced)
333
+ return raced;
334
+ context.sessions.set(sessionId, loaded);
335
+ return loaded;
336
+ }
337
+ catch (error) {
338
+ if (error.code === "ENOENT")
339
+ return undefined;
340
+ throw error;
341
+ }
342
+ }
343
+ function bearerToken(request) {
344
+ const authorization = request.headers.authorization || "";
345
+ return authorization.startsWith("Bearer ") ? authorization.slice(7) : "";
346
+ }
347
+ function authorize(request, response, session) {
348
+ if (!session) {
349
+ writeJson(response, 404, { error: "realtime session not found" });
350
+ return false;
351
+ }
352
+ if (!tokenMatches(session, presentedToken(request, session.id)) ||
353
+ session.tokenExpiresAt < Date.now()) {
354
+ writeJson(response, 401, { error: "invalid or expired realtime token" });
355
+ return false;
356
+ }
357
+ session.touchedAt = Date.now();
358
+ return true;
359
+ }
360
+ function emit(session, data) {
361
+ const event = { id: String(session.nextEventId++), data };
362
+ session.events.push(event);
363
+ if (session.events.length > 1000)
364
+ session.events.shift();
365
+ const frame = `id: ${event.id}\ndata: ${JSON.stringify(data)}\n\n`;
366
+ for (const response of session.subscribers)
367
+ response.write(frame);
368
+ return event;
369
+ }
370
+ function openEventStream(request, response, session) {
371
+ response.writeHead(200, {
372
+ ...corsHeaders(),
373
+ "content-type": "text/event-stream; charset=utf-8",
374
+ "cache-control": "no-cache, no-transform",
375
+ connection: "keep-alive",
376
+ "x-accel-buffering": "no",
377
+ });
378
+ response.flushHeaders();
379
+ response.write(": connected\n\n");
380
+ const lastEventId = Number(request.headers["last-event-id"] || 0);
381
+ for (const event of session.events) {
382
+ if (Number(event.id) > lastEventId) {
383
+ response.write(`id: ${event.id}\ndata: ${JSON.stringify(event.data)}\n\n`);
384
+ }
385
+ }
386
+ session.subscribers.add(response);
387
+ const heartbeat = setInterval(() => response.write(": heartbeat\n\n"), 15_000);
388
+ const close = () => {
389
+ clearInterval(heartbeat);
390
+ session.subscribers.delete(response);
391
+ };
392
+ request.on("close", close);
393
+ response.on("close", close);
394
+ }
395
+ function parseJson(value) {
396
+ if (typeof value !== "string")
397
+ return value;
398
+ try {
399
+ return JSON.parse(value);
400
+ }
401
+ catch {
402
+ return value;
403
+ }
404
+ }
405
+ function normalizedWorkspacePath(candidate) {
406
+ const workspacePath = candidate
407
+ .replace(/^\/home\/user\/?/, "")
408
+ .replace(/^~\/?/, "")
409
+ .replaceAll("\\", "/")
410
+ .replace(/^\.\/+/, "");
411
+ const normalized = path.posix.normalize(workspacePath);
412
+ if (!normalized ||
413
+ normalized === "." ||
414
+ normalized.startsWith("/") ||
415
+ normalized.startsWith("../") ||
416
+ normalized
417
+ .split("/")
418
+ .some((part) => !part || part === ".." || part.startsWith("."))) {
419
+ return null;
420
+ }
421
+ return normalized;
422
+ }
423
+ function headerText(value) {
424
+ return Array.isArray(value) ? value[0] || "" : value || "";
425
+ }
426
+ function decodedUploadName(value) {
427
+ try {
428
+ return decodeURIComponent(value);
429
+ }
430
+ catch {
431
+ throw new Error("x-file-name must be URI encoded");
432
+ }
433
+ }
434
+ async function resolveSessionAttachment(session, part) {
435
+ const nested = part.attachment && typeof part.attachment === "object"
436
+ ? part.attachment
437
+ : part;
438
+ const requestedPath = typeof nested.path === "string" ? normalizedWorkspacePath(nested.path) : null;
439
+ if (!requestedPath ||
440
+ path.posix.dirname(requestedPath) !== "uploads") {
441
+ throw new Error("attachment path must reference a file in /home/user/uploads/");
442
+ }
443
+ const uploads = await uploadsDirectory(session.workspace, false);
444
+ const candidate = path.join(session.workspace, ...requestedPath.split("/"));
445
+ const fileReal = await realpath(candidate);
446
+ const relative = path.relative(uploads, fileReal);
447
+ if (!relative ||
448
+ relative.startsWith("..") ||
449
+ path.isAbsolute(relative) ||
450
+ relative.includes(path.sep)) {
451
+ throw new Error("attachment path is outside /home/user/uploads/");
452
+ }
453
+ const info = await stat(fileReal);
454
+ if (!info.isFile())
455
+ throw new Error("attachment is not a regular file");
456
+ if (info.size > maxUploadBytes) {
457
+ throw new Error(`attachment exceeds ${maxUploadBytes} bytes`);
458
+ }
459
+ const bytes = await readFile(fileReal);
460
+ const type = attachmentMimeType(relative, bytes);
461
+ return {
462
+ name: relative,
463
+ path: `uploads/${relative}`,
464
+ size: info.size,
465
+ bytes,
466
+ ...type,
467
+ };
468
+ }
469
+ function workspaceEvents(session, output, sourceTool = "", input = {}) {
470
+ if (!output || typeof output !== "object")
471
+ return [];
472
+ const value = output;
473
+ if (value.status && value.status !== "success")
474
+ return [];
475
+ const candidates = [value];
476
+ if (Array.isArray(value.images))
477
+ candidates.push(...value.images);
478
+ if (["edit_file", "present_file", "write_file"].includes(sourceTool) &&
479
+ input &&
480
+ typeof input === "object") {
481
+ const inputPath = input.path;
482
+ if (typeof inputPath === "string")
483
+ candidates.push({ path: inputPath });
484
+ }
485
+ const seen = new Set();
486
+ const events = [];
487
+ for (const candidateValue of candidates) {
488
+ if (!candidateValue || typeof candidateValue !== "object")
489
+ continue;
490
+ const candidateRecord = candidateValue;
491
+ const candidate = candidateRecord.path || candidateRecord.file_path;
492
+ if (typeof candidate !== "string")
493
+ continue;
494
+ const normalized = normalizedWorkspacePath(candidate);
495
+ if (!normalized || seen.has(normalized))
496
+ continue;
497
+ seen.add(normalized);
498
+ events.push({
499
+ type: "data-workspace",
500
+ data: {
501
+ path: normalized,
502
+ url: `/api/chat/sessions/${session.id}/workspace/${normalized
503
+ .split("/")
504
+ .map(encodeURIComponent)
505
+ .join("/")}`,
506
+ sourceTool,
507
+ },
508
+ });
509
+ }
510
+ return events;
511
+ }
512
+ function createUiStream(session, messageId) {
513
+ const state = {
514
+ messageId,
515
+ textId: `text-${messageId}`,
516
+ reasoningId: `reasoning-${messageId}`,
517
+ textStarted: false,
518
+ reasoningStarted: false,
519
+ toolCalls: new Map(),
520
+ toolParts: new Map(),
521
+ completed: false,
522
+ };
523
+ const closeOpenParts = () => {
524
+ if (state.reasoningStarted) {
525
+ emit(session, { type: "reasoning-end", id: state.reasoningId });
526
+ state.reasoningStarted = false;
527
+ }
528
+ if (state.textStarted) {
529
+ emit(session, { type: "text-end", id: state.textId });
530
+ state.textStarted = false;
531
+ }
532
+ };
533
+ const rememberToolId = (part) => {
534
+ if (part.id)
535
+ state.toolCalls.set(part.id, part);
536
+ };
537
+ const ensureToolStarted = (part) => {
538
+ if (part.started)
539
+ return;
540
+ part.uiId = part.id || `tool-${messageId}-${part.index}`;
541
+ part.started = true;
542
+ emit(session, {
543
+ type: "tool-input-start",
544
+ toolCallId: part.uiId,
545
+ toolName: part.name || "tool",
546
+ });
547
+ };
548
+ emit(session, { type: "start", messageId });
549
+ return {
550
+ consume(event) {
551
+ if (event.type === "model.start") {
552
+ state.toolParts = new Map();
553
+ emit(session, {
554
+ type: "data-agent-status",
555
+ data: {
556
+ phase: "model",
557
+ step: event.step,
558
+ label: event.step === 1 ? "Thinking…" : "Generating response…",
559
+ },
560
+ });
561
+ return;
562
+ }
563
+ if (event.type === "model.complete") {
564
+ emit(session, {
565
+ type: "data-agent-status",
566
+ data: {
567
+ phase: "model-complete",
568
+ step: event.step,
569
+ label: event.finish_reason === "tool_calls"
570
+ ? "Preparing tool…"
571
+ : "Finalizing…",
572
+ },
573
+ });
574
+ return;
575
+ }
576
+ if (event.type === "assistant.delta") {
577
+ const delta = event.delta || {};
578
+ if (delta.reasoning_content) {
579
+ if (!state.reasoningStarted) {
580
+ emit(session, { type: "reasoning-start", id: state.reasoningId });
581
+ state.reasoningStarted = true;
582
+ }
583
+ emit(session, {
584
+ type: "reasoning-delta",
585
+ id: state.reasoningId,
586
+ delta: delta.reasoning_content,
587
+ });
588
+ }
589
+ if (delta.content) {
590
+ if (!state.textStarted) {
591
+ emit(session, { type: "text-start", id: state.textId });
592
+ state.textStarted = true;
593
+ }
594
+ emit(session, {
595
+ type: "text-delta",
596
+ id: state.textId,
597
+ delta: delta.content,
598
+ });
599
+ }
600
+ const fragments = delta.tool_calls || [];
601
+ if (fragments.length)
602
+ closeOpenParts();
603
+ for (const [position, fragment] of fragments.entries()) {
604
+ const index = Number(fragment.index ?? position);
605
+ const part = state.toolParts.get(index) ||
606
+ {
607
+ index,
608
+ id: "",
609
+ name: "",
610
+ arguments: "",
611
+ uiId: "",
612
+ started: false,
613
+ };
614
+ part.id += fragment.id || "";
615
+ part.name += fragment.function?.name || "";
616
+ part.arguments += fragment.function?.arguments || "";
617
+ state.toolParts.set(index, part);
618
+ rememberToolId(part);
619
+ ensureToolStarted(part);
620
+ emit(session, {
621
+ type: "tool-input-delta",
622
+ toolCallId: part.uiId,
623
+ toolName: part.name || "tool",
624
+ inputTextDelta: fragment.function?.arguments || "",
625
+ });
626
+ }
627
+ return;
628
+ }
629
+ if (event.type === "message" && event.message?.role === "assistant") {
630
+ const calls = event.message.tool_calls || [];
631
+ if (calls.length)
632
+ closeOpenParts();
633
+ for (const [index, call] of calls.entries()) {
634
+ const part = state.toolParts.get(index) ||
635
+ {
636
+ index,
637
+ id: call.id,
638
+ name: call.function?.name || "tool",
639
+ arguments: call.function?.arguments || "",
640
+ uiId: "",
641
+ started: false,
642
+ };
643
+ part.id = call.id;
644
+ part.name = call.function?.name || "tool";
645
+ part.arguments = call.function?.arguments || "";
646
+ state.toolParts.set(index, part);
647
+ rememberToolId(part);
648
+ ensureToolStarted(part);
649
+ emit(session, {
650
+ type: "tool-input-available",
651
+ toolCallId: part.uiId,
652
+ toolName: part.name,
653
+ input: parseJson(part.arguments || "{}"),
654
+ });
655
+ }
656
+ return;
657
+ }
658
+ if (event.type === "tool.start") {
659
+ const part = state.toolCalls.get(event.tool_call_id || "");
660
+ emit(session, {
661
+ type: "data-agent-status",
662
+ data: {
663
+ phase: "tool-running",
664
+ label: `Running ${event.tool_name}…`,
665
+ toolCallId: part?.uiId || event.tool_call_id,
666
+ toolName: event.tool_name,
667
+ },
668
+ });
669
+ return;
670
+ }
671
+ if (event.type === "input.required") {
672
+ const part = state.toolCalls.get(event.tool_call_id || "");
673
+ emit(session, {
674
+ type: "data-user-input-request",
675
+ data: {
676
+ requestId: event.request_id || event.tool_call_id,
677
+ toolCallId: part?.uiId || event.tool_call_id,
678
+ questions: event.questions || [],
679
+ },
680
+ });
681
+ emit(session, {
682
+ type: "data-agent-status",
683
+ data: {
684
+ phase: "waiting-for-input",
685
+ label: "Waiting for your answer…",
686
+ toolCallId: part?.uiId || event.tool_call_id,
687
+ toolName: "ask_user",
688
+ },
689
+ });
690
+ return;
691
+ }
692
+ if (event.type === "message" && event.message?.role === "tool") {
693
+ const part = state.toolCalls.get(event.message.tool_call_id);
694
+ const toolCallId = part?.uiId || event.message.tool_call_id;
695
+ const output = parseJson(event.message.content);
696
+ emit(session, {
697
+ type: "tool-output-available",
698
+ toolCallId,
699
+ output,
700
+ });
701
+ const files = workspaceEvents(session, output, part?.name, parseJson(part?.arguments || "{}"));
702
+ for (const file of files) {
703
+ emit(session, file);
704
+ }
705
+ const presented = files[0];
706
+ if (part?.name === "present_file" && presented) {
707
+ emit(session, {
708
+ type: "data-present-file",
709
+ data: presented.data,
710
+ });
711
+ }
712
+ return;
713
+ }
714
+ if (event.type === "tool.complete") {
715
+ const part = state.toolCalls.get(event.tool_call_id || "");
716
+ emit(session, {
717
+ type: "data-agent-status",
718
+ data: {
719
+ phase: "tool-complete",
720
+ label: `${event.tool_name} complete`,
721
+ toolCallId: part?.uiId || event.tool_call_id,
722
+ toolName: event.tool_name,
723
+ },
724
+ });
725
+ return;
726
+ }
727
+ if (event.type === "result") {
728
+ closeOpenParts();
729
+ emit(session, {
730
+ type: "data-messages",
731
+ data: { messages: event.messages || [] },
732
+ });
733
+ emit(session, {
734
+ type: "finish",
735
+ finishReason: event.status === "success" ? "stop" : "error",
736
+ });
737
+ emit(session, {
738
+ type: "trigger:turn-complete",
739
+ status: event.status,
740
+ error: event.error,
741
+ });
742
+ state.completed = true;
743
+ }
744
+ },
745
+ fail(error) {
746
+ if (state.completed)
747
+ return;
748
+ closeOpenParts();
749
+ emit(session, { type: "error", errorText: errorMessage(error) });
750
+ emit(session, { type: "finish", finishReason: "error" });
751
+ emit(session, {
752
+ type: "trigger:turn-complete",
753
+ status: "error",
754
+ error: errorMessage(error),
755
+ });
756
+ state.completed = true;
757
+ },
758
+ };
759
+ }
760
+ async function readHistory(session) {
761
+ return loadStoredHistory(sessionHistoryPath(session));
762
+ }
763
+ async function saveHistory(session, messages) {
764
+ await saveStoredHistory(sessionHistoryPath(session), messages);
765
+ }
766
+ async function runAgent(context, session, prompt, messageId, interruptionGeneration, userMessage) {
767
+ const ui = createUiStream(session, messageId);
768
+ const controller = new AbortController();
769
+ session.running = { controller };
770
+ let pendingResult = null;
771
+ let sink;
772
+ const askUserHandler = (toolCallId, questions) => new Promise((resolve, reject) => {
773
+ if (controller.signal.aborted) {
774
+ reject(controller.signal.reason || new Error("operation cancelled"));
775
+ return;
776
+ }
777
+ session.pendingInput = {
778
+ requestId: toolCallId,
779
+ toolCallId,
780
+ questions,
781
+ resolve,
782
+ reject,
783
+ };
784
+ sink.inputRequired(session.id, toolCallId, questions);
785
+ });
786
+ sink = new EventSink({
787
+ format: "silent",
788
+ onEvent: (event) => {
789
+ if (event.type === "result") {
790
+ pendingResult = event;
791
+ }
792
+ else {
793
+ ui.consume(event);
794
+ }
795
+ },
796
+ });
797
+ const sessionConfig = {
798
+ ...context.config,
799
+ workspace: session.workspace,
800
+ };
801
+ const tools = new ToolExecutor(sessionConfig, {
802
+ askUserHandler,
803
+ signal: controller.signal,
804
+ });
805
+ try {
806
+ const history = await readHistory(session);
807
+ const result = await new Agent(sessionConfig, sink, {
808
+ tools,
809
+ sessionId: session.id,
810
+ signal: controller.signal,
811
+ onCheckpoint: (messages) => saveHistory(session, messages),
812
+ clientContext: {
813
+ currentDate: session.currentDate,
814
+ timezone: session.timezone,
815
+ ...(session.location ? { location: session.location } : {}),
816
+ },
817
+ }).run(prompt, history, {
818
+ interrupted: interruptionGeneration !== undefined,
819
+ userMessage,
820
+ });
821
+ const shouldPersistHistory = result.status === "success" || controller.signal.aborted;
822
+ if (shouldPersistHistory) {
823
+ await saveHistory(session, result.messages);
824
+ if (interruptionGeneration !== undefined &&
825
+ session.interruptionGeneration === interruptionGeneration) {
826
+ session.pendingInterruption = false;
827
+ }
828
+ }
829
+ session.touchedAt = Date.now();
830
+ await saveSessionMetadata(session);
831
+ ui.consume(pendingResult || result);
832
+ }
833
+ catch (error) {
834
+ session.touchedAt = Date.now();
835
+ try {
836
+ await saveSessionMetadata(session);
837
+ }
838
+ catch {
839
+ // Preserve the original failure in the UI.
840
+ }
841
+ ui.fail(error);
842
+ }
843
+ finally {
844
+ session.running = null;
845
+ session.pendingInput = null;
846
+ session.activePrompt = null;
847
+ session.touchedAt = Date.now();
848
+ }
849
+ }
850
+ async function triggerSession(context, request, response) {
851
+ const body = await readJson(request);
852
+ if (body.sessionId !== undefined) {
853
+ writeJson(response, 400, {
854
+ error: "sessionId is generated by the server",
855
+ });
856
+ return;
857
+ }
858
+ const currentDate = typeof body.currentDate === "string" ? body.currentDate.trim() : "";
859
+ const timezone = typeof body.timezone === "string" ? body.timezone.trim() : "";
860
+ if (!validClientDate(currentDate)) {
861
+ writeJson(response, 400, {
862
+ error: "currentDate must be a valid date in YYYY-MM-DD format",
863
+ });
864
+ return;
865
+ }
866
+ if (!validTimezone(timezone)) {
867
+ writeJson(response, 400, {
868
+ error: "timezone must be a valid IANA time zone",
869
+ });
870
+ return;
871
+ }
872
+ let location;
873
+ if (body.location !== undefined) {
874
+ try {
875
+ location = clientLocation(body.location);
876
+ }
877
+ catch (error) {
878
+ writeJson(response, 400, { error: errorMessage(error) });
879
+ return;
880
+ }
881
+ }
882
+ const { session, token } = await createSession(context, {
883
+ currentDate,
884
+ timezone,
885
+ ...(location ? { location } : {}),
886
+ });
887
+ writeJson(response, 200, {
888
+ sessionId: session.id,
889
+ publicAccessToken: token,
890
+ expiresAt: new Date(session.tokenExpiresAt).toISOString(),
891
+ tenantId: session.tenantId,
892
+ }, {
893
+ "set-cookie": sessionCookie(session, token),
894
+ });
895
+ }
896
+ async function triggerToken(context, request, response) {
897
+ const body = await readJson(request);
898
+ const session = typeof body.sessionId === "string"
899
+ ? await getSession(context, body.sessionId)
900
+ : undefined;
901
+ if (!session) {
902
+ writeJson(response, 404, { error: "realtime session not found" });
903
+ return;
904
+ }
905
+ if (!tokenMatches(session, presentedToken(request, session.id))) {
906
+ writeJson(response, 401, { error: "invalid session credential" });
907
+ return;
908
+ }
909
+ const token = crypto.randomBytes(32).toString("base64url");
910
+ session.tokenHash = hashToken(token);
911
+ session.tokenExpiresAt = Date.now() + 60 * 60 * 1000;
912
+ session.touchedAt = Date.now();
913
+ await saveSessionMetadata(session);
914
+ writeJson(response, 200, {
915
+ token,
916
+ expiresAt: new Date(session.tokenExpiresAt).toISOString(),
917
+ }, {
918
+ "set-cookie": sessionCookie(session, token),
919
+ });
920
+ }
921
+ async function uploadAttachment(request, response, session) {
922
+ if (!authorize(request, response, session))
923
+ return;
924
+ if (session.running) {
925
+ writeJson(response, 409, {
926
+ error: "attachments cannot be uploaded while a turn is running",
927
+ });
928
+ return;
929
+ }
930
+ const requestedName = headerText(request.headers["x-file-name"]);
931
+ if (!requestedName) {
932
+ writeJson(response, 400, {
933
+ error: "x-file-name is required and must be URI encoded",
934
+ });
935
+ return;
936
+ }
937
+ const bytes = await readRequestBytes(request, maxUploadBytes);
938
+ const attachment = await saveAttachment(session.workspace, decodedUploadName(requestedName), bytes, headerText(request.headers["content-type"]));
939
+ session.touchedAt = Date.now();
940
+ writeJson(response, 201, {
941
+ attachment: {
942
+ name: attachment.name,
943
+ path: attachment.path,
944
+ mimeType: attachment.mimeType,
945
+ size: attachment.size,
946
+ url: `/api/chat/sessions/${session.id}/workspace/` +
947
+ attachment.path
948
+ .split("/")
949
+ .map(encodeURIComponent)
950
+ .join("/"),
951
+ },
952
+ });
953
+ }
954
+ async function appendMessage(context, request, response, session) {
955
+ if (!authorize(request, response, session))
956
+ return;
957
+ if (session.running) {
958
+ writeJson(response, 409, { error: "a turn is already running" });
959
+ return;
960
+ }
961
+ const body = await readJson(request);
962
+ const payload = body.payload;
963
+ const message = payload?.message;
964
+ if (body.kind !== "message" || !payload || !message) {
965
+ writeJson(response, 400, {
966
+ error: "expected kind=message with payload.message",
967
+ });
968
+ return;
969
+ }
970
+ if (payload.chatId && payload.chatId !== session.id) {
971
+ writeJson(response, 400, { error: "payload.chatId does not match session" });
972
+ return;
973
+ }
974
+ const parts = Array.isArray(message.parts) ? message.parts : [];
975
+ const attachmentParts = parts.filter((part) => Boolean(part) &&
976
+ typeof part === "object" &&
977
+ ["file", "attachment"].includes(String(part.type || "")));
978
+ if (attachmentParts.length > maxAttachmentsPerMessage) {
979
+ writeJson(response, 400, {
980
+ error: `a message may include at most ${maxAttachmentsPerMessage} attachments`,
981
+ });
982
+ return;
983
+ }
984
+ const attachments = await Promise.all(attachmentParts.map((part) => resolveSessionAttachment(session, part)));
985
+ const totalAttachmentBytes = attachments.reduce((total, attachment) => total + attachment.size, 0);
986
+ if (totalAttachmentBytes > maxAttachmentBytesPerMessage) {
987
+ writeJson(response, 400, {
988
+ error: `message attachments exceed ${maxAttachmentBytesPerMessage} bytes`,
989
+ });
990
+ return;
991
+ }
992
+ const prompt = parts
993
+ .filter((part) => Boolean(part) &&
994
+ typeof part === "object" &&
995
+ part.type === "text")
996
+ .map((part) => part.text || "")
997
+ .join("\n")
998
+ .trim();
999
+ if (!prompt && !attachments.length) {
1000
+ writeJson(response, 400, {
1001
+ error: "message must contain text or at least one attachment",
1002
+ });
1003
+ return;
1004
+ }
1005
+ const userMessage = attachments.length
1006
+ ? attachmentUserMessage(prompt, attachments)
1007
+ : undefined;
1008
+ const messageId = String(payload.messageId || message.id || crypto.randomUUID());
1009
+ const interruptionGeneration = session.pendingInterruption
1010
+ ? session.interruptionGeneration
1011
+ : undefined;
1012
+ session.turnStartEventId = session.events.at(-1)?.id || "0";
1013
+ session.activePrompt = prompt;
1014
+ writeJson(response, 202, {
1015
+ accepted: true,
1016
+ sessionId: session.id,
1017
+ messageId,
1018
+ attachments: attachments.map((attachment) => ({
1019
+ name: attachment.name,
1020
+ path: attachment.path,
1021
+ mimeType: attachment.mimeType,
1022
+ size: attachment.size,
1023
+ })),
1024
+ partId: request.headers["x-part-id"] || null,
1025
+ });
1026
+ queueMicrotask(() => {
1027
+ void runAgent(context, session, prompt, messageId, interruptionGeneration, userMessage);
1028
+ });
1029
+ }
1030
+ async function sessionMessages(context, request, response, session) {
1031
+ if (!authorize(request, response, session))
1032
+ return;
1033
+ const messages = (await readHistory(session)) || [];
1034
+ writeJson(response, 200, {
1035
+ messages,
1036
+ lastEventId: session.running
1037
+ ? session.turnStartEventId
1038
+ : session.events.at(-1)?.id || "0",
1039
+ activePrompt: session.activePrompt,
1040
+ });
1041
+ }
1042
+ function normalizeInputAnswers(pending, submitted) {
1043
+ if (!Array.isArray(submitted))
1044
+ throw new Error("answers must be an array");
1045
+ const byQuestion = new Map();
1046
+ for (const value of submitted) {
1047
+ if (!value ||
1048
+ typeof value !== "object" ||
1049
+ typeof value.questionId !== "string") {
1050
+ throw new Error("each answer requires a questionId");
1051
+ }
1052
+ const answer = value;
1053
+ const questionId = answer.questionId;
1054
+ if (byQuestion.has(questionId)) {
1055
+ throw new Error(`duplicate answer for ${questionId}`);
1056
+ }
1057
+ byQuestion.set(questionId, answer);
1058
+ }
1059
+ if (byQuestion.size !== pending.questions.length) {
1060
+ throw new Error("every pending question must be answered exactly once");
1061
+ }
1062
+ return pending.questions.map((question) => {
1063
+ const answer = byQuestion.get(question.id);
1064
+ if (!answer)
1065
+ throw new Error(`missing answer for ${question.id}`);
1066
+ if (typeof answer.optionId === "string" && answer.optionId) {
1067
+ const option = question.options.find((candidate) => candidate.id === answer.optionId);
1068
+ if (!option)
1069
+ throw new Error(`invalid option for ${question.id}`);
1070
+ return {
1071
+ question_id: question.id,
1072
+ question: question.question,
1073
+ option_id: option.id,
1074
+ answer: option.label,
1075
+ };
1076
+ }
1077
+ const customResponse = typeof answer.customResponse === "string"
1078
+ ? answer.customResponse.trim()
1079
+ : "";
1080
+ if (!customResponse || question.allowCustomResponse === false) {
1081
+ throw new Error(`a valid option or custom response is required for ${question.id}`);
1082
+ }
1083
+ return {
1084
+ question_id: question.id,
1085
+ question: question.question,
1086
+ custom_response: customResponse,
1087
+ answer: customResponse,
1088
+ };
1089
+ });
1090
+ }
1091
+ async function respondToInput(request, response, session) {
1092
+ if (!authorize(request, response, session))
1093
+ return;
1094
+ if (!session.running || !session.pendingInput) {
1095
+ writeJson(response, 409, { error: "there is no pending user input" });
1096
+ return;
1097
+ }
1098
+ const body = await readJson(request);
1099
+ if (body.requestId !== session.pendingInput.requestId) {
1100
+ writeJson(response, 409, {
1101
+ error: "input request is no longer pending",
1102
+ });
1103
+ return;
1104
+ }
1105
+ const pending = session.pendingInput;
1106
+ const answers = normalizeInputAnswers(pending, body.answers);
1107
+ session.pendingInput = null;
1108
+ pending.resolve({ status: "success", answers });
1109
+ emit(session, {
1110
+ type: "data-user-input-submitted",
1111
+ data: {
1112
+ requestId: pending.requestId,
1113
+ toolCallId: pending.toolCallId,
1114
+ answers,
1115
+ },
1116
+ });
1117
+ writeJson(response, 202, {
1118
+ accepted: true,
1119
+ requestId: pending.requestId,
1120
+ });
1121
+ }
1122
+ async function cancelTurn(request, response, session) {
1123
+ if (!authorize(request, response, session))
1124
+ return;
1125
+ if (!session.running) {
1126
+ writeJson(response, 200, { cancelled: false });
1127
+ return;
1128
+ }
1129
+ session.pendingInterruption = true;
1130
+ session.interruptionGeneration += 1;
1131
+ session.pendingInput?.resolve({ answers: [], skipped: true });
1132
+ session.pendingInput = null;
1133
+ session.running.controller.abort(new Error("operation cancelled"));
1134
+ session.touchedAt = Date.now();
1135
+ await saveSessionMetadata(session);
1136
+ writeJson(response, 202, { cancelled: true });
1137
+ }
1138
+ async function query(context, request, response, url) {
1139
+ let prompt = url.searchParams.get("q") || url.searchParams.get("prompt") || "";
1140
+ if (request.method === "POST") {
1141
+ const body = await readBody(request);
1142
+ if ((request.headers["content-type"] || "").includes("application/json")) {
1143
+ const parsed = JSON.parse(body || "{}");
1144
+ prompt = parsed.prompt || parsed.query || "";
1145
+ }
1146
+ else {
1147
+ prompt = body;
1148
+ }
1149
+ }
1150
+ if (!prompt.trim()) {
1151
+ writeJson(response, 400, {
1152
+ error: "Provide a prompt as POST text/JSON or GET /query?q=...",
1153
+ });
1154
+ return;
1155
+ }
1156
+ response.writeHead(200, {
1157
+ ...corsHeaders(),
1158
+ "content-type": "application/x-ndjson; charset=utf-8",
1159
+ "cache-control": "no-cache, no-transform",
1160
+ "x-accel-buffering": "no",
1161
+ });
1162
+ response.flushHeaders();
1163
+ const controller = new AbortController();
1164
+ request.on("aborted", () => {
1165
+ controller.abort(new Error("client disconnected"));
1166
+ });
1167
+ const sink = new EventSink({
1168
+ format: "stream-json",
1169
+ write: (value) => response.write(value),
1170
+ });
1171
+ const result = await new Agent(context.config, sink, {
1172
+ sessionId: crypto.randomUUID().replaceAll("-", ""),
1173
+ signal: controller.signal,
1174
+ }).run(prompt);
1175
+ if (!response.writableEnded) {
1176
+ if (result.status === "error" && controller.signal.aborted) {
1177
+ response.destroy();
1178
+ }
1179
+ else {
1180
+ response.end();
1181
+ }
1182
+ }
1183
+ }
1184
+ async function safeWorkspaceFile(session, encodedPath) {
1185
+ let decoded;
1186
+ try {
1187
+ decoded = encodedPath
1188
+ .split("/")
1189
+ .map((part) => decodeURIComponent(part))
1190
+ .join("/");
1191
+ }
1192
+ catch {
1193
+ return null;
1194
+ }
1195
+ const relative = normalizedWorkspacePath(decoded);
1196
+ if (!relative)
1197
+ return null;
1198
+ const resolved = path.resolve(session.workspace, relative);
1199
+ const confined = path.relative(session.workspace, resolved);
1200
+ if (confined.startsWith("..") || path.isAbsolute(confined))
1201
+ return null;
1202
+ try {
1203
+ const [workspaceReal, fileReal] = await Promise.all([
1204
+ realpath(session.workspace),
1205
+ realpath(resolved),
1206
+ ]);
1207
+ const realRelative = path.relative(workspaceReal, fileReal);
1208
+ return realRelative.startsWith("..") || path.isAbsolute(realRelative)
1209
+ ? null
1210
+ : fileReal;
1211
+ }
1212
+ catch (error) {
1213
+ if (error.code === "ENOENT")
1214
+ return resolved;
1215
+ throw error;
1216
+ }
1217
+ }
1218
+ async function serveFile(response, filePath, cache = false, isolated = false) {
1219
+ const info = await stat(filePath);
1220
+ if (!info.isFile()) {
1221
+ const error = new Error("not found");
1222
+ error.code = "ENOENT";
1223
+ throw error;
1224
+ }
1225
+ response.writeHead(200, {
1226
+ ...corsHeaders(),
1227
+ "content-type": mimeTypes[path.extname(filePath).toLowerCase()] ||
1228
+ "application/octet-stream",
1229
+ "content-length": info.size,
1230
+ "cache-control": cache ? "public, max-age=300" : "no-cache",
1231
+ "x-content-type-options": "nosniff",
1232
+ ...(isolated
1233
+ ? {
1234
+ "content-security-policy": "default-src 'none'; img-src data: blob:; media-src data: blob:; " +
1235
+ "font-src data:; style-src 'unsafe-inline'; script-src 'unsafe-inline'",
1236
+ }
1237
+ : {}),
1238
+ });
1239
+ fs.createReadStream(filePath).pipe(response);
1240
+ }
1241
+ function sessionRoute(pathname, suffix) {
1242
+ return pathname.match(new RegExp(`^/ai-proxy/realtime/v1/sessions/([A-Za-z0-9-]{1,128})/${suffix}$`));
1243
+ }
1244
+ function apiSessionRoute(pathname, suffix) {
1245
+ return pathname.match(new RegExp(`^/api/chat/sessions/([A-Za-z0-9-]{1,128})/${suffix}$`));
1246
+ }
1247
+ function workspaceRoute(pathname) {
1248
+ return pathname.match(/^\/api\/chat\/sessions\/([A-Za-z0-9-]{1,128})\/workspace\/(.+)$/);
1249
+ }
1250
+ function createRequestHandler(context) {
1251
+ return async (request, response) => {
1252
+ if (request.method === "OPTIONS") {
1253
+ response.writeHead(204, corsHeaders());
1254
+ response.end();
1255
+ return;
1256
+ }
1257
+ const url = new URL(request.url || "/", `http://${request.headers.host || context.host}`);
1258
+ const outputMatch = sessionRoute(url.pathname, "out");
1259
+ const appendMatch = sessionRoute(url.pathname, "in/append");
1260
+ const messagesMatch = apiSessionRoute(url.pathname, "messages");
1261
+ const cancelMatch = apiSessionRoute(url.pathname, "cancel");
1262
+ const inputMatch = apiSessionRoute(url.pathname, "input-response");
1263
+ const uploadMatch = apiSessionRoute(url.pathname, "uploads");
1264
+ const workspaceMatch = workspaceRoute(url.pathname);
1265
+ try {
1266
+ if (url.pathname === "/health" && request.method === "GET") {
1267
+ writeJson(response, 200, {
1268
+ status: "ok",
1269
+ sessions: context.sessions.size,
1270
+ protocol: "arena-trigger-realtime-v1",
1271
+ });
1272
+ }
1273
+ else if (url.pathname === "/api/config" && request.method === "GET") {
1274
+ writeJson(response, 200, {
1275
+ model: context.config.model.replace(/^[^/]+\//, ""),
1276
+ protocol: "arena-trigger-realtime-v1",
1277
+ });
1278
+ }
1279
+ else if (url.pathname === "/api/chat/trigger-session" &&
1280
+ request.method === "POST") {
1281
+ await triggerSession(context, request, response);
1282
+ }
1283
+ else if (url.pathname === "/api/chat/trigger-token" &&
1284
+ request.method === "POST") {
1285
+ await triggerToken(context, request, response);
1286
+ }
1287
+ else if (appendMatch && request.method === "POST") {
1288
+ await appendMessage(context, request, response, await getSession(context, appendMatch[1] || ""));
1289
+ }
1290
+ else if (outputMatch && request.method === "GET") {
1291
+ const session = await getSession(context, outputMatch[1] || "");
1292
+ if (authorize(request, response, session)) {
1293
+ openEventStream(request, response, session);
1294
+ }
1295
+ }
1296
+ else if (messagesMatch && request.method === "GET") {
1297
+ await sessionMessages(context, request, response, await getSession(context, messagesMatch[1] || ""));
1298
+ }
1299
+ else if (cancelMatch && request.method === "POST") {
1300
+ await cancelTurn(request, response, await getSession(context, cancelMatch[1] || ""));
1301
+ }
1302
+ else if (inputMatch && request.method === "POST") {
1303
+ await respondToInput(request, response, await getSession(context, inputMatch[1] || ""));
1304
+ }
1305
+ else if (uploadMatch && request.method === "POST") {
1306
+ await uploadAttachment(request, response, await getSession(context, uploadMatch[1] || ""));
1307
+ }
1308
+ else if (url.pathname === "/query" &&
1309
+ ["GET", "POST"].includes(request.method || "")) {
1310
+ await query(context, request, response, url);
1311
+ }
1312
+ else if (workspaceMatch && request.method === "GET") {
1313
+ const session = await getSession(context, workspaceMatch[1] || "");
1314
+ if (!authorize(request, response, session))
1315
+ return;
1316
+ const file = await safeWorkspaceFile(session, workspaceMatch[2] || "");
1317
+ if (!file) {
1318
+ writeJson(response, 403, {
1319
+ error: "workspace path is not allowed",
1320
+ });
1321
+ }
1322
+ else {
1323
+ await serveFile(response, file, false, true);
1324
+ }
1325
+ }
1326
+ else if (["/", "/index.html"].includes(url.pathname) ||
1327
+ /^\/agent\/[A-Za-z0-9-]{1,128}$/.test(url.pathname)) {
1328
+ await serveFile(response, path.join(context.webRoot, "index.html"));
1329
+ }
1330
+ else if (url.pathname === "/app.js") {
1331
+ await serveFile(response, context.appScript, true);
1332
+ }
1333
+ else if (url.pathname === "/sdk.js") {
1334
+ await serveFile(response, path.join(projectRoot(), "dist", "sdk.js"), true);
1335
+ }
1336
+ else if (["/styles.css", "/fonts.css", "/arena-logo.svg"].includes(url.pathname)) {
1337
+ await serveFile(response, path.join(context.webRoot, url.pathname.slice(1)), true);
1338
+ }
1339
+ else {
1340
+ writeJson(response, 404, { error: "not found" });
1341
+ }
1342
+ }
1343
+ catch (error) {
1344
+ if (response.headersSent) {
1345
+ response.end();
1346
+ }
1347
+ else if (error.code === "ENOENT") {
1348
+ writeJson(response, 404, { error: "not found" });
1349
+ }
1350
+ else {
1351
+ writeJson(response, 400, { error: errorMessage(error) });
1352
+ }
1353
+ }
1354
+ };
1355
+ }
1356
+ export async function startServer(options = {}) {
1357
+ const host = options.host || "127.0.0.1";
1358
+ const workspace = path.resolve(options.workspace || process.cwd());
1359
+ const root = projectRoot();
1360
+ const config = configFromEnv(workspace);
1361
+ if (!validTenantId(config.tenantId)) {
1362
+ throw new Error("ARENA_TENANT_ID must contain only letters, numbers, underscores, or hyphens");
1363
+ }
1364
+ const context = {
1365
+ host,
1366
+ port: options.port ?? 3000,
1367
+ workspace,
1368
+ config,
1369
+ webRoot: path.join(root, "web"),
1370
+ appScript: path.join(root, "dist", "web", "app.js"),
1371
+ sessions: new Map(),
1372
+ };
1373
+ await mkdir(workspace, { recursive: true });
1374
+ const server = http.createServer(createRequestHandler(context));
1375
+ await new Promise((resolve, reject) => {
1376
+ server.once("error", reject);
1377
+ server.listen(context.port, host, () => {
1378
+ server.off("error", reject);
1379
+ const address = server.address();
1380
+ if (address && typeof address === "object")
1381
+ context.port = address.port;
1382
+ resolve();
1383
+ });
1384
+ });
1385
+ const cleanup = setInterval(() => {
1386
+ const cutoff = Date.now() - 6 * 60 * 60 * 1000;
1387
+ for (const [sessionId, session] of context.sessions) {
1388
+ if (!session.running &&
1389
+ !session.subscribers.size &&
1390
+ session.touchedAt < cutoff) {
1391
+ context.sessions.delete(sessionId);
1392
+ }
1393
+ }
1394
+ }, 60 * 60 * 1000);
1395
+ cleanup.unref();
1396
+ let closing = null;
1397
+ const close = () => {
1398
+ closing ??= new Promise((resolve) => {
1399
+ clearInterval(cleanup);
1400
+ for (const session of context.sessions.values()) {
1401
+ session.pendingInput?.reject(new Error("server shutting down"));
1402
+ session.running?.controller.abort(new Error("server shutting down"));
1403
+ for (const subscriber of session.subscribers)
1404
+ subscriber.end();
1405
+ session.subscribers.clear();
1406
+ }
1407
+ server.close(() => resolve());
1408
+ server.closeAllConnections?.();
1409
+ });
1410
+ return closing;
1411
+ };
1412
+ if (options.registerSignalHandlers !== false) {
1413
+ let shuttingDown = false;
1414
+ const shutdown = () => {
1415
+ if (shuttingDown) {
1416
+ process.exitCode = 1;
1417
+ return;
1418
+ }
1419
+ shuttingDown = true;
1420
+ const forceExit = setTimeout(() => process.exit(1), 3000);
1421
+ forceExit.unref();
1422
+ void close().then(() => {
1423
+ clearTimeout(forceExit);
1424
+ process.exit(0);
1425
+ });
1426
+ };
1427
+ process.once("SIGINT", shutdown);
1428
+ process.once("SIGTERM", shutdown);
1429
+ }
1430
+ console.log(`arena serve listening on http://${host}:${context.port}`);
1431
+ return { server, host, port: context.port, close };
1432
+ }
1433
+ //# sourceMappingURL=server.js.map