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.
- package/package.json +6 -5
- package/src/app.ts +16 -8
- package/src/backend/claude-sdk/stream.ts +76 -53
- package/src/backend/remote-server/turn.ts +58 -55
- package/src/bootstrap.ts +120 -79
- package/src/cli/setup.ts +375 -349
- package/src/core/background/cron-spec.ts +273 -0
- package/src/core/background/heartbeat/agent.ts +167 -104
- package/src/core/engine/backend-controller/pool.ts +2 -0
- package/src/core/engine/backend-controller/state.ts +25 -12
- package/src/core/engine/gateway-actions/cron.ts +28 -292
- package/src/core/engine/gateway-routes.ts +239 -0
- package/src/core/engine/gateway.ts +66 -238
- package/src/core/vfs/mounts/files.ts +128 -115
- package/src/core/weaver/shuttle.ts +29 -2
- package/src/core/weaver/weaver.ts +37 -6
- package/src/frontend/discord/callbacks/components/agent-buttons.ts +82 -0
- package/src/frontend/discord/callbacks/components/backend-select.ts +149 -0
- package/src/frontend/discord/callbacks/components/effort.ts +72 -0
- package/src/frontend/discord/callbacks/components/index.ts +120 -0
- package/src/frontend/discord/callbacks/components/metrics.ts +24 -0
- package/src/frontend/discord/callbacks/components/model-nav.ts +93 -0
- package/src/frontend/discord/callbacks/components/model-select.ts +118 -0
- package/src/frontend/discord/callbacks/components/model.ts +33 -0
- package/src/frontend/discord/callbacks/components/pulse.ts +93 -0
- package/src/frontend/discord/callbacks/components/settings.ts +243 -0
- package/src/frontend/discord/callbacks/components/types.ts +34 -0
- package/src/frontend/discord/callbacks/index.ts +4 -4
- package/src/frontend/discord/connection.ts +36 -0
- package/src/frontend/discord/diagnostics.ts +62 -0
- package/src/frontend/discord/guild-policy.ts +89 -0
- package/src/frontend/discord/index.ts +44 -305
- package/src/frontend/discord/outbound.ts +61 -0
- package/src/frontend/discord/ready.ts +73 -0
- package/src/frontend/discord/runtime.ts +55 -0
- package/src/frontend/native/chat-lifecycle.ts +39 -0
- package/src/frontend/native/chat-wire.ts +69 -0
- package/src/frontend/native/context.ts +106 -0
- package/src/frontend/native/control.ts +78 -0
- package/src/frontend/native/emit.ts +167 -0
- package/src/frontend/native/empty-chat-sweep.ts +50 -0
- package/src/frontend/native/handlers.ts +121 -0
- package/src/frontend/native/history.ts +101 -0
- package/src/frontend/native/index.ts +90 -1293
- package/src/frontend/native/media.ts +43 -0
- package/src/frontend/native/models.ts +221 -0
- package/src/frontend/native/queue.ts +47 -0
- package/src/frontend/native/reset.ts +50 -0
- package/src/frontend/native/routes/chats.ts +115 -0
- package/src/frontend/native/routes/daemon.ts +70 -0
- package/src/frontend/native/routes/host.ts +151 -0
- package/src/frontend/native/routes/index.ts +22 -0
- package/src/frontend/native/routes/mesh.ts +72 -0
- package/src/frontend/native/routes/models.ts +54 -0
- package/src/frontend/native/routes/params.ts +29 -0
- package/src/frontend/native/routes/pre-auth.ts +94 -0
- package/src/frontend/native/routes/table.ts +92 -0
- package/src/frontend/native/runtime.ts +109 -0
- package/src/frontend/native/server.ts +30 -555
- package/src/frontend/native/status.ts +26 -0
- package/src/frontend/native/tool-result.ts +48 -0
- package/src/frontend/native/turn.ts +341 -0
- package/src/frontend/whatsapp/access.ts +67 -0
- package/src/frontend/whatsapp/connection.ts +280 -0
- package/src/frontend/whatsapp/inbound.ts +327 -0
- package/src/frontend/whatsapp/index.ts +28 -599
- package/src/frontend/whatsapp/runtime.ts +74 -0
- package/src/plugins/github/provision.ts +1 -1
- package/src/storage/metrics.ts +18 -0
- package/src/storage/session-record.ts +29 -0
- package/src/storage/sessions.ts +24 -0
- package/src/util/boot-timer.ts +31 -0
- package/src/util/concurrency.ts +28 -0
- package/src/frontend/discord/callbacks/components.ts +0 -793
- 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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
} from "
|
|
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
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
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
|
-
|
|
23
|
+
type FileMountOptions = {
|
|
24
24
|
root: string;
|
|
25
25
|
description: string;
|
|
26
26
|
writable: boolean;
|
|
27
|
-
}
|
|
28
|
-
const root = resolve(options.root);
|
|
27
|
+
};
|
|
29
28
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
})
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
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)
|
|
134
|
-
|
|
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
|
|
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
|
|
84
|
+
await sink?.(event);
|
|
58
85
|
if (event.type === "error") {
|
|
59
86
|
throw new AgentRunError(event.error);
|
|
60
87
|
}
|