talon-agent 3.33.3 → 3.34.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 (75) hide show
  1. package/package.json +6 -5
  2. package/src/app.ts +16 -8
  3. package/src/backend/claude-sdk/stream.ts +76 -53
  4. package/src/backend/remote-server/turn.ts +58 -55
  5. package/src/bootstrap.ts +120 -79
  6. package/src/cli/setup.ts +375 -349
  7. package/src/core/background/cron-spec.ts +273 -0
  8. package/src/core/background/heartbeat/agent.ts +167 -104
  9. package/src/core/engine/backend-controller/pool.ts +2 -0
  10. package/src/core/engine/backend-controller/state.ts +25 -12
  11. package/src/core/engine/gateway-actions/cron.ts +28 -292
  12. package/src/core/engine/gateway-routes.ts +239 -0
  13. package/src/core/engine/gateway.ts +66 -238
  14. package/src/core/vfs/mounts/files.ts +128 -115
  15. package/src/core/weaver/shuttle.ts +29 -2
  16. package/src/core/weaver/weaver.ts +37 -6
  17. package/src/frontend/discord/callbacks/components/agent-buttons.ts +82 -0
  18. package/src/frontend/discord/callbacks/components/backend-select.ts +149 -0
  19. package/src/frontend/discord/callbacks/components/effort.ts +72 -0
  20. package/src/frontend/discord/callbacks/components/index.ts +120 -0
  21. package/src/frontend/discord/callbacks/components/metrics.ts +24 -0
  22. package/src/frontend/discord/callbacks/components/model-nav.ts +93 -0
  23. package/src/frontend/discord/callbacks/components/model-select.ts +118 -0
  24. package/src/frontend/discord/callbacks/components/model.ts +33 -0
  25. package/src/frontend/discord/callbacks/components/pulse.ts +93 -0
  26. package/src/frontend/discord/callbacks/components/settings.ts +243 -0
  27. package/src/frontend/discord/callbacks/components/types.ts +34 -0
  28. package/src/frontend/discord/callbacks/index.ts +4 -4
  29. package/src/frontend/discord/connection.ts +36 -0
  30. package/src/frontend/discord/diagnostics.ts +62 -0
  31. package/src/frontend/discord/guild-policy.ts +89 -0
  32. package/src/frontend/discord/index.ts +44 -305
  33. package/src/frontend/discord/outbound.ts +61 -0
  34. package/src/frontend/discord/ready.ts +73 -0
  35. package/src/frontend/discord/runtime.ts +55 -0
  36. package/src/frontend/native/chat-lifecycle.ts +39 -0
  37. package/src/frontend/native/chat-wire.ts +69 -0
  38. package/src/frontend/native/context.ts +106 -0
  39. package/src/frontend/native/control.ts +78 -0
  40. package/src/frontend/native/emit.ts +167 -0
  41. package/src/frontend/native/empty-chat-sweep.ts +50 -0
  42. package/src/frontend/native/handlers.ts +121 -0
  43. package/src/frontend/native/history.ts +101 -0
  44. package/src/frontend/native/index.ts +90 -1293
  45. package/src/frontend/native/media.ts +43 -0
  46. package/src/frontend/native/models.ts +221 -0
  47. package/src/frontend/native/queue.ts +47 -0
  48. package/src/frontend/native/reset.ts +50 -0
  49. package/src/frontend/native/routes/chats.ts +115 -0
  50. package/src/frontend/native/routes/daemon.ts +70 -0
  51. package/src/frontend/native/routes/host.ts +151 -0
  52. package/src/frontend/native/routes/index.ts +22 -0
  53. package/src/frontend/native/routes/mesh.ts +72 -0
  54. package/src/frontend/native/routes/models.ts +54 -0
  55. package/src/frontend/native/routes/params.ts +29 -0
  56. package/src/frontend/native/routes/pre-auth.ts +94 -0
  57. package/src/frontend/native/routes/table.ts +92 -0
  58. package/src/frontend/native/runtime.ts +109 -0
  59. package/src/frontend/native/server.ts +30 -555
  60. package/src/frontend/native/status.ts +26 -0
  61. package/src/frontend/native/tool-result.ts +48 -0
  62. package/src/frontend/native/turn.ts +341 -0
  63. package/src/frontend/whatsapp/access.ts +67 -0
  64. package/src/frontend/whatsapp/connection.ts +280 -0
  65. package/src/frontend/whatsapp/inbound.ts +327 -0
  66. package/src/frontend/whatsapp/index.ts +28 -599
  67. package/src/frontend/whatsapp/runtime.ts +74 -0
  68. package/src/plugins/github/provision.ts +1 -1
  69. package/src/storage/metrics.ts +18 -0
  70. package/src/storage/session-record.ts +29 -0
  71. package/src/storage/sessions.ts +24 -0
  72. package/src/util/boot-timer.ts +31 -0
  73. package/src/util/concurrency.ts +28 -0
  74. package/src/frontend/discord/callbacks/components.ts +0 -793
  75. package/src/frontend/discord/callbacks/shared.ts +0 -22
@@ -8,11 +8,7 @@
8
8
  * No platform-specific imports — frontends register their handler at startup.
9
9
  */
10
10
 
11
- import {
12
- createServer,
13
- type IncomingMessage,
14
- type ServerResponse,
15
- } from "node:http";
11
+ import { createServer } from "node:http";
16
12
  import pRetry, { AbortError } from "p-retry";
17
13
  import { classify } from "../errors.js";
18
14
  import { getActiveCount } from "./dispatcher.js";
@@ -26,14 +22,13 @@ import {
26
22
  isChatFreeAction,
27
23
  } from "./gateway-actions/index.js";
28
24
  import { registerCrossSendTarget } from "./gateway-actions/cross-send.js";
25
+ import { getHubSessionCount } from "../mcp-hub/index.js";
29
26
  import {
30
- handleHubRequest,
31
- getHubSessionCount,
32
- HUB_PATH_PREFIX,
33
- } from "../mcp-hub/index.js";
27
+ dispatchGatewayRoute,
28
+ listenWithRetry,
29
+ type GatewayRouteHost,
30
+ } from "./gateway-routes.js";
34
31
  import { handlePluginAction } from "../plugin/index.js";
35
- import { bus } from "../bus/index.js";
36
- import { taskTable } from "../tasks/index.js";
37
32
  import type { FrontendActionHandler } from "../types.js";
38
33
  import { BOT_MESSAGE_ACTIONS, noteBotMessage } from "../soul/taps.js";
39
34
  import type { Backend } from "../agent-runtime/capabilities.js";
@@ -362,236 +357,69 @@ export class Gateway {
362
357
 
363
358
  async start(port = 19876): Promise<number> {
364
359
  if (this.server) return this.port;
365
-
366
- const httpServer = createServer(
367
- async (req: IncomingMessage, res: ServerResponse) => {
368
- if (req.method === "GET" && req.url === "/health") {
369
- const w = getHealthStatus();
370
- res.writeHead(200, { "Content-Type": "application/json" });
371
- res.end(
372
- JSON.stringify({
373
- // Identity fields — daemon discovery matches on these to
374
- // distinguish a Talon daemon from chat sessions and
375
- // unrelated localhost services.
376
- app: "talon",
377
- mode: this.mode,
378
- pid: process.pid,
379
- port: this.port,
380
- startedAt: this.startedAt,
381
- ok: w.healthy,
382
- uptime: Math.round(process.uptime()),
383
- memory: Math.round(process.memoryUsage().heapUsed / 1024 / 1024),
384
- bridge: {
385
- activeChats: this.loom.activeContextCount(),
386
- threads: this.loom.size(),
387
- hubSessions: getHubSessionCount(),
388
- },
389
- queue: getActiveCount(),
390
- sessions: getActiveSessionCount(),
391
- messages: w.totalMessagesProcessed,
392
- errors: w.recentErrorCount,
393
- lastActivity:
394
- w.msSinceLastMessage < 60000
395
- ? "just now"
396
- : `${Math.round(w.msSinceLastMessage / 60000)}m ago`,
397
- }),
398
- );
399
- return;
400
- }
401
-
402
- if (req.method === "POST" && req.url === "/shutdown") {
403
- // Graceful stop for `talon stop`/`talon restart`. Bound to
404
- // 127.0.0.1 like everything else here. Respond before
405
- // triggering so the client isn't cut off mid-request; the
406
- // shutdown path takes seconds, so the reply flushes safely.
407
- if (!this.shutdownHandler) {
408
- res.writeHead(501, { "Content-Type": "application/json" });
409
- res.end(
410
- JSON.stringify({
411
- ok: false,
412
- error: "Shutdown not supported by this process",
413
- }),
414
- );
415
- return;
416
- }
417
- res.writeHead(200, { "Content-Type": "application/json" });
418
- res.end(JSON.stringify({ ok: true }));
419
- const handler = this.shutdownHandler;
420
- setImmediate(() => handler("gateway /shutdown"));
421
- return;
422
- }
423
-
424
- if (req.method === "GET" && req.url?.startsWith("/events/recent")) {
425
- // Bus tail — recent events, optionally after a cursor. Read by
426
- // `talon events`. Same 127.0.0.1 trust boundary as /action.
427
- const url = new URL(req.url, "http://gateway");
428
- const since = Number(url.searchParams.get("since") ?? "0");
429
- res.writeHead(200, { "Content-Type": "application/json" });
430
- res.end(
431
- JSON.stringify({
432
- ok: true,
433
- events: bus.recent(
434
- Number.isInteger(since) && since > 0 ? since : 0,
435
- ),
436
- }),
437
- );
438
- return;
439
- }
440
-
441
- if (req.method === "GET" && req.url === "/tasks") {
442
- // The task table — every live/recent unit of agent work. Read by
443
- // `talon ps`. Same 127.0.0.1 trust boundary as /action.
444
- res.writeHead(200, { "Content-Type": "application/json" });
445
- res.end(JSON.stringify({ ok: true, tasks: taskTable.list() }));
446
- return;
447
- }
448
-
449
- if (req.method === "POST" && req.url === "/tasks/kill") {
450
- // Abort one killable task by id — the transport for `talon kill`.
451
- const chunks: Buffer[] = [];
452
- for await (const chunk of req) chunks.push(chunk as Buffer);
453
- let id: unknown;
454
- try {
455
- const body = JSON.parse(Buffer.concat(chunks).toString("utf-8"));
456
- id = (body as { id?: unknown }).id;
457
- } catch {
458
- res.writeHead(400, { "Content-Type": "application/json" });
459
- res.end(JSON.stringify({ ok: false, error: "Invalid JSON" }));
460
- return;
461
- }
462
- if (typeof id !== "number" || !Number.isInteger(id)) {
463
- res.writeHead(400, { "Content-Type": "application/json" });
464
- res.end(
465
- JSON.stringify({ ok: false, error: "id must be an integer" }),
466
- );
467
- return;
468
- }
469
- res.writeHead(200, { "Content-Type": "application/json" });
470
- res.end(JSON.stringify(taskTable.kill(id)));
471
- return;
472
- }
473
-
474
- if (req.method === "POST" && req.url === "/plugins/reload") {
475
- // Hot-reload plugins from config — the transport for
476
- // `talon plugin install/enable/disable`, which has no chat
477
- // context and so cannot use the reload_plugins action. Same
478
- // 127.0.0.1 trust boundary as /action.
479
- try {
480
- const { performPluginReload } =
481
- await import("./gateway-actions/plugins.js");
482
- const { names } = await performPluginReload(this.backend);
483
- log("gateway", `/plugins/reload: ${names.length} plugins loaded`);
484
- res.writeHead(200, { "Content-Type": "application/json" });
485
- res.end(JSON.stringify({ ok: true, loaded: names }));
486
- } catch (err) {
487
- res.writeHead(200, { "Content-Type": "application/json" });
488
- res.end(
489
- JSON.stringify({
490
- ok: false,
491
- error: `Plugin reload failed: ${err instanceof Error ? err.message : err}`,
492
- }),
493
- );
494
- }
495
- return;
496
- }
497
-
498
- if (req.url?.startsWith(HUB_PATH_PREFIX)) {
499
- // MCP hub — daemon-hosted MCP-over-HTTP endpoints for every
500
- // backend (see core/mcp-hub). Same 127.0.0.1 trust boundary
501
- // as /action.
502
- await handleHubRequest(req, res, `http://127.0.0.1:${this.port}`);
503
- return;
504
- }
505
-
506
- if (req.method !== "POST" || req.url !== "/action") {
507
- res.writeHead(404);
508
- res.end("Not found");
509
- return;
510
- }
511
-
512
- try {
513
- const chunks: Buffer[] = [];
514
- for await (const chunk of req) chunks.push(chunk as Buffer);
515
- let body: Record<string, unknown>;
516
- try {
517
- body = JSON.parse(Buffer.concat(chunks).toString("utf-8"));
518
- } catch {
519
- res.writeHead(400, { "Content-Type": "application/json" });
520
- res.end(JSON.stringify({ ok: false, error: "Invalid JSON" }));
521
- return;
522
- }
523
- const result = await this.handleAction(body);
524
- const json = JSON.stringify(result);
525
- res.writeHead(200, { "Content-Type": "application/json" });
526
- res.end(json);
527
- } catch (err) {
528
- if (res.headersSent) return;
529
- // Log full error (incl. stack via logError's structured `stack`
530
- // field) on the server; return a generic message to the client so
531
- // we don't leak implementation details.
532
- // CodeQL: js/stack-trace-exposure (alert #4).
533
- logError(
534
- "gateway",
535
- `Unhandled error on ${req.method} ${req.url}`,
536
- err,
537
- );
538
- res.writeHead(500, { "Content-Type": "application/json" });
539
- res.end(
540
- JSON.stringify({ ok: false, error: "Internal server error" }),
541
- );
542
- }
360
+ const host: GatewayRouteHost = {
361
+ healthSnapshot: () => this.healthSnapshot(),
362
+ requestShutdown: () => {
363
+ const handler = this.shutdownHandler;
364
+ if (!handler) return false;
365
+ setImmediate(() => handler("gateway /shutdown"));
366
+ return true;
543
367
  },
368
+ reloadPlugins: async () => {
369
+ const { performPluginReload } =
370
+ await import("./gateway-actions/plugins.js");
371
+ return (await performPluginReload(this.backend)).names;
372
+ },
373
+ hubOrigin: () => `http://127.0.0.1:${this.port}`,
374
+ handleAction: (body) => this.handleAction(body),
375
+ };
376
+ const httpServer = createServer(
377
+ (req, res) => void dispatchGatewayRoute(req, res, host),
544
378
  );
379
+ const bound = await listenWithRetry(httpServer, port);
380
+ this.server = httpServer;
381
+ this.port = bound;
382
+ log("gateway", `Action gateway on :${this.port}`);
383
+ for (const cb of this.startedListeners.splice(0)) {
384
+ try {
385
+ cb(this.port);
386
+ } catch (err) {
387
+ logError("gateway", "onStarted listener failed", err);
388
+ }
389
+ }
390
+ return this.port;
391
+ }
545
392
 
546
- return new Promise<number>((resolve, reject) => {
547
- let attempt = 0;
548
- const tryPort = (p: number) => {
549
- httpServer.once("error", (err: NodeJS.ErrnoException) => {
550
- if (err.code === "EADDRINUSE" && attempt < 5) {
551
- attempt++;
552
- httpServer.removeAllListeners("error");
553
- // The failed listen() left its one-shot 'listening' success
554
- // callback registered (Node attaches it via once('listening')).
555
- // Drop it before retrying — otherwise every stale callback also
556
- // fires when a later port finally binds, running the startup body
557
- // (and logging "Action gateway on :…") once per attempted port.
558
- httpServer.removeAllListeners("listening");
559
- tryPort(p + 1);
560
- } else {
561
- reject(err);
562
- }
563
- });
564
- httpServer.listen(p, "127.0.0.1", () => {
565
- this.server = httpServer;
566
- // When the caller asks for port 0 the OS assigns a random free
567
- // port — read the actual port off the listening socket instead
568
- // of saving the requested 0.
569
- const addr = httpServer.address();
570
- this.port =
571
- typeof addr === "object" && addr !== null
572
- ? (addr as { port: number }).port
573
- : p;
574
- log("gateway", `Action gateway on :${this.port}`);
575
- // Replace the one-shot startup error listener with a persistent
576
- // handler. Without this, once the `once("error")` from port-binding
577
- // fires (and auto-removes itself), any subsequent server-level error
578
- // has no listener and crashes the process via uncaught 'error' event.
579
- httpServer.removeAllListeners("error");
580
- httpServer.on("error", (err) =>
581
- logError("gateway", "HTTP server error", err),
582
- );
583
- for (const cb of this.startedListeners.splice(0)) {
584
- try {
585
- cb(this.port);
586
- } catch (err) {
587
- logError("gateway", "onStarted listener failed", err);
588
- }
589
- }
590
- resolve(this.port);
591
- });
592
- };
593
- tryPort(port);
594
- });
393
+ /**
394
+ * The /health body. Identity fields first — daemon discovery matches on
395
+ * them to tell a Talon daemon from chat sessions and unrelated localhost
396
+ * services — then live counters.
397
+ */
398
+ private healthSnapshot(): Record<string, unknown> {
399
+ const w = getHealthStatus();
400
+ return {
401
+ app: "talon",
402
+ mode: this.mode,
403
+ pid: process.pid,
404
+ port: this.port,
405
+ startedAt: this.startedAt,
406
+ ok: w.healthy,
407
+ uptime: Math.round(process.uptime()),
408
+ memory: Math.round(process.memoryUsage().heapUsed / 1024 / 1024),
409
+ bridge: {
410
+ activeChats: this.loom.activeContextCount(),
411
+ threads: this.loom.size(),
412
+ hubSessions: getHubSessionCount(),
413
+ },
414
+ queue: getActiveCount(),
415
+ sessions: getActiveSessionCount(),
416
+ messages: w.totalMessagesProcessed,
417
+ errors: w.recentErrorCount,
418
+ lastActivity:
419
+ w.msSinceLastMessage < 60000
420
+ ? "just now"
421
+ : `${Math.round(w.msSinceLastMessage / 60000)}m ago`,
422
+ };
595
423
  }
596
424
 
597
425
  async stop(): Promise<void> {
@@ -20,134 +20,147 @@ import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
20
20
  import type { VfsMount, VfsResult, VfsStat } from "../types.js";
21
21
  import { VFS_MAX_READ_BYTES, vfsError, vfsOk } from "../types.js";
22
22
 
23
- export function createFileMount(options: {
23
+ type FileMountOptions = {
24
24
  root: string;
25
25
  description: string;
26
26
  writable: boolean;
27
- }): VfsMount {
28
- const root = resolve(options.root);
27
+ };
29
28
 
30
- /** Absolute path for `rel`, or undefined when it would escape the root. */
31
- function realPath(rel: string): string | undefined {
32
- const abs = resolve(root, ...rel.split("/"));
33
- const offset = relative(root, abs);
34
- if (
35
- offset.startsWith(`..${sep}`) ||
36
- offset === ".." ||
37
- isAbsolute(offset)
38
- ) {
39
- return undefined;
40
- }
41
- return abs;
29
+ /** Absolute path for `rel` under `root`, or undefined when it would escape. */
30
+ function realPath(root: string, rel: string): string | undefined {
31
+ const abs = resolve(root, ...rel.split("/"));
32
+ const offset = relative(root, abs);
33
+ if (offset.startsWith(`..${sep}`) || offset === ".." || isAbsolute(offset)) {
34
+ return undefined;
42
35
  }
36
+ return abs;
37
+ }
43
38
 
44
- function statAt(rel: string, abs: string): VfsResult<VfsStat> {
45
- try {
46
- const stats = statSync(abs);
47
- const name = rel === "" ? "" : rel.split("/").at(-1)!;
48
- return vfsOk({
49
- path: rel,
50
- name,
51
- kind: stats.isDirectory() ? ("dir" as const) : ("file" as const),
52
- ...(stats.isFile() ? { size: stats.size } : {}),
53
- modifiedAt: Math.floor(stats.mtimeMs),
54
- writable: options.writable,
55
- osPath: abs,
56
- });
57
- } catch {
58
- return vfsError("not-found");
59
- }
39
+ function statAt(
40
+ rel: string,
41
+ abs: string,
42
+ writable: boolean,
43
+ ): VfsResult<VfsStat> {
44
+ try {
45
+ const stats = statSync(abs);
46
+ const name = rel === "" ? "" : rel.split("/").at(-1)!;
47
+ return vfsOk({
48
+ path: rel,
49
+ name,
50
+ kind: stats.isDirectory() ? ("dir" as const) : ("file" as const),
51
+ ...(stats.isFile() ? { size: stats.size } : {}),
52
+ modifiedAt: Math.floor(stats.mtimeMs),
53
+ writable,
54
+ osPath: abs,
55
+ });
56
+ } catch {
57
+ return vfsError("not-found");
60
58
  }
59
+ }
61
60
 
62
- return {
63
- description: options.description,
64
- writable: options.writable,
65
- osRoot: root,
61
+ function statMount(
62
+ root: string,
63
+ writable: boolean,
64
+ rel: string,
65
+ ): VfsResult<VfsStat> {
66
+ const abs = realPath(root, rel);
67
+ if (!abs) return vfsError("invalid-path");
68
+ if (rel === "" && !existsSync(abs)) {
69
+ return vfsOk({ path: "", name: "", kind: "dir", writable, osPath: abs });
70
+ }
71
+ return statAt(rel, abs, writable);
72
+ }
66
73
 
67
- stat(rel) {
68
- const abs = realPath(rel);
69
- if (!abs) return vfsError("invalid-path");
70
- if (rel === "" && !existsSync(abs)) {
71
- return vfsOk({
72
- path: "",
73
- name: "",
74
- kind: "dir",
75
- writable: options.writable,
76
- osPath: abs,
77
- });
78
- }
79
- return statAt(rel, abs);
80
- },
74
+ function listMount(
75
+ root: string,
76
+ writable: boolean,
77
+ rel: string,
78
+ ): VfsResult<VfsStat[]> {
79
+ const abs = realPath(root, rel);
80
+ if (!abs) return vfsError("invalid-path");
81
+ if (rel === "" && !existsSync(abs)) return vfsOk([]);
82
+ let stats;
83
+ try {
84
+ stats = statSync(abs);
85
+ } catch {
86
+ return vfsError("not-found");
87
+ }
88
+ if (!stats.isDirectory()) return vfsError("not-a-directory");
89
+ const entries = readdirSync(abs, { withFileTypes: true })
90
+ .map((entry) => {
91
+ const childRel = rel === "" ? entry.name : `${rel}/${entry.name}`;
92
+ const child = statAt(childRel, resolve(abs, entry.name), writable);
93
+ return child.ok ? child.value : undefined;
94
+ })
95
+ .filter((entry): entry is VfsStat => entry !== undefined)
96
+ .sort(
97
+ (a, b) =>
98
+ Number(a.kind === "file") - Number(b.kind === "file") ||
99
+ a.name.localeCompare(b.name),
100
+ );
101
+ return vfsOk(entries);
102
+ }
81
103
 
82
- list(rel) {
83
- const abs = realPath(rel);
84
- if (!abs) return vfsError("invalid-path");
85
- if (rel === "" && !existsSync(abs)) return vfsOk([]);
86
- let stats;
87
- try {
88
- stats = statSync(abs);
89
- } catch {
90
- return vfsError("not-found");
91
- }
92
- if (!stats.isDirectory()) return vfsError("not-a-directory");
93
- const entries = readdirSync(abs, { withFileTypes: true })
94
- .map((entry) => {
95
- const childRel = rel === "" ? entry.name : `${rel}/${entry.name}`;
96
- const child = statAt(childRel, resolve(abs, entry.name));
97
- return child.ok ? child.value : undefined;
98
- })
99
- .filter((entry): entry is VfsStat => entry !== undefined)
100
- .sort(
101
- (a, b) =>
102
- Number(a.kind === "file") - Number(b.kind === "file") ||
103
- a.name.localeCompare(b.name),
104
- );
105
- return vfsOk(entries);
106
- },
104
+ function readMount(root: string, rel: string): VfsResult<string> {
105
+ const abs = realPath(root, rel);
106
+ if (!abs) return vfsError("invalid-path");
107
+ let stats;
108
+ try {
109
+ stats = statSync(abs);
110
+ } catch {
111
+ return vfsError("not-found");
112
+ }
113
+ if (stats.isDirectory()) return vfsError("is-a-directory");
114
+ if (stats.size > VFS_MAX_READ_BYTES) {
115
+ return vfsError(
116
+ "too-large",
117
+ `${stats.size} bytes (cap ${VFS_MAX_READ_BYTES}) — on disk at ${abs}`,
118
+ );
119
+ }
120
+ const buffer = readFileSync(abs);
121
+ if (buffer.includes(0)) {
122
+ return vfsError("binary-file", "Content contains null bytes");
123
+ }
124
+ return vfsOk(buffer.toString("utf-8"));
125
+ }
107
126
 
108
- read(rel) {
109
- const abs = realPath(rel);
110
- if (!abs) return vfsError("invalid-path");
111
- let stats;
112
- try {
113
- stats = statSync(abs);
114
- } catch {
115
- return vfsError("not-found");
116
- }
117
- if (stats.isDirectory()) return vfsError("is-a-directory");
118
- if (stats.size > VFS_MAX_READ_BYTES) {
119
- return vfsError(
120
- "too-large",
121
- `${stats.size} bytes (cap ${VFS_MAX_READ_BYTES}) — on disk at ${abs}`,
122
- );
123
- }
124
- const buffer = readFileSync(abs);
125
- if (buffer.includes(0)) {
126
- return vfsError("binary-file", "Content contains null bytes");
127
- }
128
- return vfsOk(buffer.toString("utf-8"));
129
- },
127
+ function writeMount(
128
+ root: string,
129
+ rel: string,
130
+ content: string,
131
+ ): VfsResult<VfsStat> {
132
+ if (rel === "") return vfsError("is-a-directory");
133
+ const abs = realPath(root, rel);
134
+ if (!abs) return vfsError("invalid-path");
135
+ if (existsSync(abs) && statSync(abs).isDirectory()) {
136
+ return vfsError("is-a-directory");
137
+ }
138
+ try {
139
+ mkdirSync(dirname(abs), { recursive: true });
140
+ writeFileSync(abs, content, "utf-8");
141
+ } catch (err) {
142
+ return vfsError(
143
+ "io-error",
144
+ err instanceof Error ? err.message : String(err),
145
+ );
146
+ }
147
+ return statAt(rel, abs, true);
148
+ }
130
149
 
131
- ...(options.writable
150
+ export function createFileMount(options: FileMountOptions): VfsMount {
151
+ const root = resolve(options.root);
152
+ const { writable } = options;
153
+ return {
154
+ description: options.description,
155
+ writable,
156
+ osRoot: root,
157
+ stat: (rel) => statMount(root, writable, rel),
158
+ list: (rel) => listMount(root, writable, rel),
159
+ read: (rel) => readMount(root, rel),
160
+ ...(writable
132
161
  ? {
133
- write(rel: string, content: string): VfsResult<VfsStat> {
134
- if (rel === "") return vfsError("is-a-directory");
135
- const abs = realPath(rel);
136
- if (!abs) return vfsError("invalid-path");
137
- if (existsSync(abs) && statSync(abs).isDirectory()) {
138
- return vfsError("is-a-directory");
139
- }
140
- try {
141
- mkdirSync(dirname(abs), { recursive: true });
142
- writeFileSync(abs, content, "utf-8");
143
- } catch (err) {
144
- return vfsError(
145
- "io-error",
146
- err instanceof Error ? err.message : String(err),
147
- );
148
- }
149
- return statAt(rel, abs);
150
- },
162
+ write: (rel: string, content: string) =>
163
+ writeMount(root, rel, content),
151
164
  }
152
165
  : {}),
153
166
  };
@@ -29,6 +29,18 @@ import {
29
29
 
30
30
  export type EventSink = (event: AgentEvent) => void | Promise<void>;
31
31
 
32
+ /**
33
+ * Wall-clock the shuttle observes while carrying one stream: when the
34
+ * first event arrived (`undefined` if the stream ended empty) and how many
35
+ * ms were spent awaiting the frontend's sink in total.
36
+ */
37
+ export type ShuttleTiming = {
38
+ firstEventAt?: number;
39
+ deliveryMs: number;
40
+ };
41
+
42
+ export const startShuttleTiming = (): ShuttleTiming => ({ deliveryMs: 0 });
43
+
32
44
  /**
33
45
  * Pump the stream to completion. Returns the `completed` event's
34
46
  * result (if the backend emitted one); throws `AgentRunError` when the
@@ -37,16 +49,31 @@ export type EventSink = (event: AgentEvent) => void | Promise<void>;
37
49
  export async function carryTurnEvents(
38
50
  stream: AsyncIterable<AgentEvent>,
39
51
  onEvent?: EventSink,
52
+ timing?: ShuttleTiming,
40
53
  ): Promise<AgentResult | undefined> {
41
54
  let agentResult: AgentResult | undefined;
55
+ const sink: EventSink | undefined =
56
+ onEvent && timing
57
+ ? async (event) => {
58
+ const t0 = Date.now();
59
+ try {
60
+ await onEvent(event);
61
+ } finally {
62
+ timing.deliveryMs += Date.now() - t0;
63
+ }
64
+ }
65
+ : onEvent;
42
66
  for await (const event of stream) {
67
+ if (timing && timing.firstEventAt === undefined) {
68
+ timing.firstEventAt = Date.now();
69
+ }
43
70
  if (event.type === "completed") {
44
71
  agentResult = event.result;
45
72
  }
46
73
 
47
74
  if (event.type === "assistant_message" && event.deliveryAck) {
48
75
  try {
49
- await onEvent?.(event);
76
+ await sink?.(event);
50
77
  event.deliveryAck.resolve();
51
78
  } catch (err) {
52
79
  event.deliveryAck.reject(err);
@@ -54,7 +81,7 @@ export async function carryTurnEvents(
54
81
  continue;
55
82
  }
56
83
 
57
- await onEvent?.(event);
84
+ await sink?.(event);
58
85
  if (event.type === "error") {
59
86
  throw new AgentRunError(event.error);
60
87
  }