@flue/sdk 0.3.5 → 0.3.6

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/README.md CHANGED
@@ -4,11 +4,13 @@
4
4
 
5
5
  # Flue
6
6
 
7
- Flue is **The Sandbox Agent Framework.** If you know how to use Claude Code (or OpenCode, Codex, Gemini, etc)... then you already know the basics of how to build agents with Flue.
7
+ Flue is **The Agent Harness Framework.** If you know how to use Claude Code (or OpenCode, Codex, Gemini, etc)... then you already know the basics of how to build agents with Flue.
8
8
 
9
- A [Sandbox Agent](https://developers.openai.com/api/docs/guides/agents/sandboxes) pairs an **agent harness** (like Claude Code) with a secure, isolated container workspace. Sandbox Agents can edit files, write and execute code, spin up subagents, run terminal commands, and drive themselves autonomously to solve any given task. This pattern unlocks more powerful, intelligent agents that traditional AI frameworks wouldn't otherwise let you build.
9
+ Flue is a TypeScript framework for building the next generation of agents, designed around a built-in **agent harness**. It's like Claude Code, but 100% headless and programmable. There's no baked-in assumption like requiring a human operator to function. No TUI. No GUI. Just TypeScript.
10
10
 
11
- Our take is that 1) any agent can be represented as a Sandbox Agent, and 2) any agent is _best_ represented as a Sandbox Agent. So we designed Flue to deliver on this vision.
11
+ But using Flue feels like using Claude Code. The agents you build act autonomously to solve problems and complete tasks. They require very little code to run most of the "logic" lives in Markdown: skills, context, and `AGENTS.md`.
12
+
13
+ Flue isn't another AI SDK. It's a proper runtime-agnostic framework — think Astro or Next.js, but for agents. Write once, build, and deploy your agents anywhere (Node.js, Cloudflare, GitHub Actions, GitLab CI/CD, etc).
12
14
 
13
15
  ## Packages
14
16
 
@@ -287,7 +287,7 @@ function createBashTool(env) {
287
287
  }),
288
288
  async execute(_toolCallId, params, signal) {
289
289
  throwIfAborted(signal);
290
- return formatBashResult(await env.exec(params.command), params.command);
290
+ return formatBashResult(await env.exec(params.command, { timeout: params.timeout }), params.command);
291
291
  }
292
292
  };
293
293
  }
package/dist/client.d.mts CHANGED
@@ -1,5 +1,5 @@
1
- import { A as TaskOptions, C as SessionEnv, D as ShellResult, E as ShellOptions, M as ToolParameters, S as SessionData, T as SessionStore, _ as FlueSessions, a as BashLike, d as FileStat, f as FlueAgent, g as FlueSession, h as FlueEventCallback, i as BashFactory, j as ToolDef, k as SkillOptions, l as Command, m as FlueEvent, p as FlueContext, r as AgentInit, t as AgentConfig, v as PromptOptions, w as SessionOptions, x as SandboxFactory, y as PromptResponse } from "./types-T8pE1xIS.mjs";
2
- import { i as connectMcpServer, n as McpServerOptions, r as McpTransport, t as McpServerConnection } from "./mcp-BVF-sOBZ.mjs";
1
+ import { A as TaskOptions, C as SessionEnv, D as ShellResult, E as ShellOptions, M as ToolParameters, S as SessionData, T as SessionStore, _ as FlueSessions, a as BashLike, d as FileStat, f as FlueAgent, g as FlueSession, h as FlueEventCallback, i as BashFactory, j as ToolDef, k as SkillOptions, l as Command, m as FlueEvent, p as FlueContext, r as AgentInit, t as AgentConfig, v as PromptOptions, w as SessionOptions, x as SandboxFactory, y as PromptResponse } from "./types-CItTrBsU.mjs";
2
+ import { i as connectMcpServer, n as McpServerOptions, r as McpTransport, t as McpServerConnection } from "./mcp-EZy-Vb5M.mjs";
3
3
  import { Type } from "@mariozechner/pi-ai";
4
4
 
5
5
  //#region src/client.d.ts
package/dist/client.mjs CHANGED
@@ -1,7 +1,7 @@
1
- import { r as discoverSessionContext } from "./agent-BB4lwAd5.mjs";
2
- import { a as assertRoleExists } from "./session-DukL3zwF.mjs";
1
+ import { r as discoverSessionContext } from "./agent-BTB0809P.mjs";
2
+ import { a as assertRoleExists } from "./session-BaaSQTWS.mjs";
3
3
  import { bashFactoryToSessionEnv, createCwdSessionEnv } from "./sandbox.mjs";
4
- import { n as AgentClient, t as connectMcpServer } from "./mcp-DOgMtp8y.mjs";
4
+ import { n as AgentClient, t as connectMcpServer } from "./mcp-DTFRe9vh.mjs";
5
5
  import { Type } from "@mariozechner/pi-ai";
6
6
 
7
7
  //#region src/client.ts
@@ -1,5 +1,5 @@
1
- import { C as SessionEnv, T as SessionStore, l as Command } from "../types-T8pE1xIS.mjs";
2
- import { t as CommandExecutor } from "../command-helpers-DdAfbnom.mjs";
1
+ import { C as SessionEnv, T as SessionStore, l as Command } from "../types-CItTrBsU.mjs";
2
+ import { t as CommandExecutor } from "../command-helpers-CXzopT_-.mjs";
3
3
 
4
4
  //#region src/cloudflare/virtual-sandbox.d.ts
5
5
  interface VirtualSandboxOptions {
@@ -1,5 +1,5 @@
1
- import "../agent-BB4lwAd5.mjs";
2
- import "../session-DukL3zwF.mjs";
1
+ import "../agent-BTB0809P.mjs";
2
+ import "../session-BaaSQTWS.mjs";
3
3
  import { createSandboxSessionEnv } from "../sandbox.mjs";
4
4
  import { t as normalizeExecutor } from "../command-helpers-hTZKWK13.mjs";
5
5
  import { Workspace, WorkspaceFileSystem } from "@cloudflare/shell";
@@ -195,9 +195,11 @@ async function cfSandboxToSessionEnv(sandbox, cwd = "/workspace") {
195
195
  } else await sandbox.deleteFile(path);
196
196
  },
197
197
  async exec(command, execOpts) {
198
+ const timeoutMs = typeof execOpts?.timeout === "number" ? execOpts.timeout * 1e3 : void 0;
198
199
  const result = await sandbox.exec(command, {
199
200
  cwd: execOpts?.cwd,
200
- env: execOpts?.env
201
+ env: execOpts?.env,
202
+ timeout: timeoutMs
201
203
  });
202
204
  return {
203
205
  stdout: result.stdout ?? "",
@@ -1,4 +1,4 @@
1
- import { D as ShellResult } from "./types-T8pE1xIS.mjs";
1
+ import { D as ShellResult } from "./types-CItTrBsU.mjs";
2
2
 
3
3
  //#region src/command-helpers.d.ts
4
4
  /**
package/dist/index.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { A as TaskOptions, C as SessionEnv, D as ShellResult, E as ShellOptions, M as ToolParameters, O as Skill, S as SessionData, T as SessionStore, _ as FlueSessions, a as BashLike, b as Role, c as BuildPlugin, d as FileStat, f as FlueAgent, g as FlueSession, h as FlueEventCallback, i as BashFactory, j as ToolDef, k as SkillOptions, l as Command, m as FlueEvent, n as AgentInfo, o as BuildContext, p as FlueContext, r as AgentInit, s as BuildOptions, t as AgentConfig, u as CommandDef, v as PromptOptions, w as SessionOptions, x as SandboxFactory, y as PromptResponse } from "./types-T8pE1xIS.mjs";
1
+ import { A as TaskOptions, C as SessionEnv, D as ShellResult, E as ShellOptions, M as ToolParameters, O as Skill, S as SessionData, T as SessionStore, _ as FlueSessions, a as BashLike, b as Role, c as BuildPlugin, d as FileStat, f as FlueAgent, g as FlueSession, h as FlueEventCallback, i as BashFactory, j as ToolDef, k as SkillOptions, l as Command, m as FlueEvent, n as AgentInfo, o as BuildContext, p as FlueContext, r as AgentInit, s as BuildOptions, t as AgentConfig, u as CommandDef, v as PromptOptions, w as SessionOptions, x as SandboxFactory, y as PromptResponse } from "./types-CItTrBsU.mjs";
2
2
  import { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core";
3
3
 
4
4
  //#region src/build.d.ts
package/dist/index.mjs CHANGED
@@ -1,9 +1,10 @@
1
- import { a as parseFrontmatterFile, n as createTools, t as BUILTIN_TOOL_NAMES } from "./agent-BB4lwAd5.mjs";
1
+ import { a as parseFrontmatterFile, n as createTools, t as BUILTIN_TOOL_NAMES } from "./agent-BTB0809P.mjs";
2
2
  import * as esbuild from "esbuild";
3
3
  import * as fs from "node:fs";
4
4
  import * as path from "node:path";
5
5
  import { packageUpSync } from "package-up";
6
6
  import { spawn } from "node:child_process";
7
+ import { randomUUID } from "node:crypto";
7
8
  import { parseEnv } from "node:util";
8
9
 
9
10
  //#region src/cloudflare-wrangler-merge.ts
@@ -267,11 +268,11 @@ function stripNoisyWranglerDefaults(merged) {
267
268
  }
268
269
  /**
269
270
  * Return the list of `class_name`s declared in the user's wrangler
270
- * `durable_objects.bindings` that contain the literal substring `Sandbox`
271
+ * `durable_objects.bindings` that end with the literal suffix `Sandbox`
271
272
  * (case-sensitive).
272
273
  *
273
274
  * This is Flue's convention for wiring `@cloudflare/sandbox`: any DO binding
274
- * whose class name contains `Sandbox` triggers an automatic re-export in the
275
+ * whose class name ends with `Sandbox` triggers an automatic re-export in the
275
276
  * generated Worker entry:
276
277
  *
277
278
  * export { Sandbox as <class_name> } from '@cloudflare/sandbox';
@@ -281,6 +282,13 @@ function stripNoisyWranglerDefaults(merged) {
281
282
  * `@cloudflare/sandbox` package. Each distinct `class_name` can be paired with
282
283
  * a different container image in the user's `containers[]` config.
283
284
  *
285
+ * The match is intentionally a suffix (not substring) so that user-defined
286
+ * classes whose names merely contain "Sandbox" mid-word — e.g. `MySandboxV2`,
287
+ * `MySandboxedAgent`, `LegacySandboxedThing` — are not silently overridden
288
+ * by the `@cloudflare/sandbox` re-export. Note that classes whose names
289
+ * still end in `Sandbox` (e.g. `MockSandbox`, `NotASandbox`) will match;
290
+ * to opt out, rename the class to not end in `Sandbox`.
291
+ *
284
292
  * Returns unique, sorted class names. Non-object bindings or bindings without
285
293
  * a string `class_name` are ignored.
286
294
  */
@@ -294,7 +302,7 @@ function detectSandboxBindings(userConfig) {
294
302
  if (typeof entry !== "object" || entry === null) continue;
295
303
  const className = entry.class_name;
296
304
  if (typeof className !== "string") continue;
297
- if (className.includes("Sandbox")) found.add(className);
305
+ if (className.endsWith("Sandbox")) found.add(className);
298
306
  }
299
307
  return Array.from(found).sort();
300
308
  }
@@ -328,7 +336,7 @@ function assertSandboxPackageInstalled(sandboxClassNames, searchDirs) {
328
336
  current = path.dirname(current);
329
337
  }
330
338
  }
331
- throw new Error(`[flue] Your wrangler config declares DO binding(s) whose class_name contains "Sandbox" (${sandboxClassNames.join(", ")}), but @cloudflare/sandbox is not in your package.json. Install it: \`npm install @cloudflare/sandbox\`.`);
339
+ throw new Error(`[flue] Your wrangler config declares DO binding(s) whose class_name ends with "Sandbox" (${sandboxClassNames.join(", ")}), but @cloudflare/sandbox is not in your package.json. Install it: \`npm install @cloudflare/sandbox\`.`);
332
340
  }
333
341
  /**
334
342
  * Write the wrangler deploy-redirect file at `<outputDir>/.wrangler/deploy/config.json`
@@ -396,6 +404,7 @@ var CloudflarePlugin = class {
396
404
  }`;
397
405
  }).join("\n\n");
398
406
  const { config: userConfig } = await this.getUserConfig(ctx.outputDir);
407
+ const sandboxReExports = detectSandboxBindings(userConfig).map((name) => `export { Sandbox as ${name} } from '@cloudflare/sandbox';`).join("\n");
399
408
  return `
400
409
  // Auto-generated by @flue/sdk build (cloudflare)
401
410
  import { Agent, routeAgentRequest } from 'agents';
@@ -405,6 +414,13 @@ import {
405
414
  InMemorySessionStore,
406
415
  bashFactoryToSessionEnv,
407
416
  resolveModel,
417
+ parseJsonBody,
418
+ toHttpResponse,
419
+ toSseData,
420
+ AgentNotFoundError,
421
+ MethodNotAllowedError,
422
+ RouteNotFoundError,
423
+ InvalidRequestError,
408
424
  } from '@flue/sdk/internal';
409
425
  import { runWithCloudflareContext, cfSandboxToSessionEnv } from '@flue/sdk/cloudflare';
410
426
 
@@ -417,6 +433,12 @@ const skills = {};
417
433
  const systemPrompt = '';
418
434
  const manifest = ${manifest};
419
435
 
436
+ // Set of webhook-accessible agent names (raw form, as used in URL segments).
437
+ // Used by the worker fetch handler to pre-route requests and reject unknown
438
+ // agents with a JSON 404 envelope before the request hits the partyserver
439
+ // dispatcher (which would otherwise return text/plain "Invalid request").
440
+ const webhookAgentNames = new Set(${JSON.stringify(webhookAgents.map((a) => a.name))});
441
+
420
442
  // ─── Infrastructure ─────────────────────────────────────────────────────────
421
443
 
422
444
  // No build-time model default. The user sets model at runtime via
@@ -597,19 +619,16 @@ async function handleAgentRequest(request, doInstance, agentName, handler) {
597
619
  // Agent id is the DO "room name" set by routeAgentRequest
598
620
  const id = doInstance.name;
599
621
 
600
- // Parse payload
601
- let payload;
602
622
  try {
603
- payload = await request.json();
604
- } catch {
605
- payload = {};
606
- }
623
+ // Parse the request body. Throws on invalid Content-Type or malformed
624
+ // JSON; returns {} for genuinely empty bodies (so no-payload agents
625
+ // still work).
626
+ const payload = await parseJsonBody(request);
607
627
 
608
- const accept = request.headers.get('accept') || '';
609
- const isWebhook = request.headers.get('x-webhook') === 'true';
610
- const isSSE = accept.includes('text/event-stream') && !isWebhook;
628
+ const accept = request.headers.get('accept') || '';
629
+ const isWebhook = request.headers.get('x-webhook') === 'true';
630
+ const isSSE = accept.includes('text/event-stream') && !isWebhook;
611
631
 
612
- try {
613
632
  // Fire-and-forget (webhook mode)
614
633
  if (isWebhook) {
615
634
  const requestId = crypto.randomUUID();
@@ -628,7 +647,13 @@ async function handleAgentRequest(request, doInstance, agentName, handler) {
628
647
  });
629
648
  }
630
649
 
631
- // SSE streaming mode
650
+ // SSE streaming mode. Two error regimes meet here:
651
+ // - Pre-stream errors (body parsing, etc.) have already thrown above
652
+ // and are caught by the outer try/catch — rendered as plain HTTP
653
+ // responses by toHttpResponse, since headers haven't been sent yet.
654
+ // - Errors during agent execution surface as in-stream \`error\`
655
+ // events with the canonical envelope (via toSseData), since by
656
+ // then the 200 + text/event-stream headers are already on the wire.
632
657
  if (isSSE) {
633
658
  const { readable, writable } = new TransformStream();
634
659
  const writer = writable.getWriter();
@@ -640,7 +665,7 @@ async function handleAgentRequest(request, doInstance, agentName, handler) {
640
665
  const lines = [];
641
666
  if (event) lines.push('event: ' + event);
642
667
  lines.push('id: ' + eventId++);
643
- lines.push('data: ' + JSON.stringify(data));
668
+ lines.push('data: ' + (typeof data === 'string' ? data : JSON.stringify(data)));
644
669
  lines.push('', '');
645
670
  await writer.write(encoder.encode(lines.join('\\n')));
646
671
  };
@@ -662,10 +687,7 @@ async function handleAgentRequest(request, doInstance, agentName, handler) {
662
687
  'result',
663
688
  );
664
689
  } catch (err) {
665
- await writeSSE(
666
- { type: 'error', error: String(err) },
667
- 'error',
668
- );
690
+ await writeSSE(toSseData(err), 'error');
669
691
  if (!isIdle) {
670
692
  await writeSSE({ type: 'idle' }, 'idle');
671
693
  }
@@ -696,11 +718,10 @@ async function handleAgentRequest(request, doInstance, agentName, handler) {
696
718
  ctx.setEventCallback(undefined);
697
719
  }
698
720
  } catch (err) {
699
- console.error('[flue] Agent error:', agentName, err);
700
- return new Response(
701
- JSON.stringify({ error: String(err) }),
702
- { status: 500, headers: { 'content-type': 'application/json' } },
703
- );
721
+ // toHttpResponse logs unknowns via flueLog.error no extra console.error
722
+ // needed here. The agentName tag is captured in the wrapped error's
723
+ // server-side log line via flueLog's prefix.
724
+ return toHttpResponse(err);
704
725
  }
705
726
  }
706
727
 
@@ -710,38 +731,77 @@ ${agentClasses}
710
731
 
711
732
  // ─── User-declared Sandbox re-exports ──────────────────────────────────────
712
733
  // One line per DO binding in the user's wrangler.jsonc whose class_name
713
- // contains "Sandbox". Flue aliases the single \`Sandbox\` class shipped by
734
+ // ends with "Sandbox". Flue aliases the single \`Sandbox\` class shipped by
714
735
  // \`@cloudflare/sandbox\` so each user-chosen class_name resolves at the
715
736
  // bundle's top level. The binding + container image configuration is owned
716
737
  // by the user's wrangler.jsonc.
717
- ${detectSandboxBindings(userConfig).map((name) => `export { Sandbox as ${name} } from '@cloudflare/sandbox';`).join("\n")}
738
+ ${sandboxReExports}
718
739
 
719
740
  // ─── Worker Fetch Handler ───────────────────────────────────────────────────
720
741
 
721
742
  export default {
722
743
  async fetch(request, env) {
723
- const url = new URL(request.url);
744
+ try {
745
+ const url = new URL(request.url);
746
+ const method = request.method;
724
747
 
725
- // Health check
726
- if (url.pathname === '/health') {
727
- return new Response(JSON.stringify({ status: 'ok' }), {
728
- headers: { 'content-type': 'application/json' },
729
- });
730
- }
748
+ // Health check
749
+ if (url.pathname === '/health') {
750
+ return new Response(JSON.stringify({ status: 'ok' }), {
751
+ headers: { 'content-type': 'application/json' },
752
+ });
753
+ }
731
754
 
732
- // Agent manifest
733
- if (url.pathname === '/agents' && request.method === 'GET') {
734
- return new Response(JSON.stringify(manifest), {
735
- headers: { 'content-type': 'application/json' },
736
- });
737
- }
755
+ // Agent manifest
756
+ if (url.pathname === '/agents' && method === 'GET') {
757
+ return new Response(JSON.stringify(manifest), {
758
+ headers: { 'content-type': 'application/json' },
759
+ });
760
+ }
738
761
 
739
- // Route to per-agent DOs via the Agents SDK
740
- // URL: /agents/<agent-name>/<id>
741
- const response = await routeAgentRequest(request, env);
742
- if (response) return response;
762
+ // Webhook agent route: /agents/<name>/<id>
763
+ //
764
+ // We pre-check method and agent registration here, BEFORE delegating to
765
+ // routeAgentRequest. Without this, partyserver (the transitive
766
+ // dispatcher behind routeAgentRequest) returns a text/plain
767
+ // "Invalid request" 400 for unknown agent namespaces — visibly
768
+ // inconsistent with the rest of the API and with the Node target.
769
+ // Pre-routing means every error path in the agent route flows through
770
+ // the same JSON envelope.
771
+ const agentRouteMatch = url.pathname.match(/^\\/agents\\/([^/]+)\\/([^/]+)\\/?$/);
772
+ if (agentRouteMatch) {
773
+ if (method !== 'POST') {
774
+ throw new MethodNotAllowedError({ method, allowed: ['POST'] });
775
+ }
776
+ const name = decodeURIComponent(agentRouteMatch[1]);
777
+ const id = decodeURIComponent(agentRouteMatch[2]);
778
+ if (name.trim() === '' || id.trim() === '') {
779
+ throw new InvalidRequestError({
780
+ reason: 'Webhook URLs must have the shape /agents/<name>/<id> with non-empty segments.',
781
+ });
782
+ }
783
+ if (!webhookAgentNames.has(name)) {
784
+ throw new AgentNotFoundError({
785
+ name,
786
+ available: Array.from(webhookAgentNames),
787
+ });
788
+ }
789
+ // All gating passed. Delegate to the Agents SDK / partyserver, which
790
+ // dispatches into the per-agent DO's onRequest → handleAgentRequest.
791
+ // routeAgentRequest may still return null for shape mismatches we
792
+ // didn't anticipate; treat that as a route_not_found with a hint.
793
+ const response = await routeAgentRequest(request, env);
794
+ if (response) return response;
795
+ throw new RouteNotFoundError({ method, path: url.pathname });
796
+ }
743
797
 
744
- return new Response('Not found', { status: 404 });
798
+ // Anything else: canonical 404 envelope.
799
+ throw new RouteNotFoundError({ method, path: url.pathname });
800
+ } catch (err) {
801
+ // toHttpResponse logs unknowns via flueLog.error — no extra
802
+ // console.error needed at this layer.
803
+ return toHttpResponse(err);
804
+ }
745
805
  },
746
806
  };
747
807
  `;
@@ -766,7 +826,7 @@ export default {
766
826
  const sandboxClassNames = detectSandboxBindings(userConfig);
767
827
  if (sandboxClassNames.length > 0) {
768
828
  assertSandboxPackageInstalled(sandboxClassNames, [ctx.outputDir, ctx.workspaceDir]);
769
- for (const className of sandboxClassNames) console.log(`[flue] Detected Sandbox-named DO binding "${className}" re-exporting from @cloudflare/sandbox.`);
829
+ for (const className of sandboxClassNames) console.log(`[flue] Auto-wiring DO binding "${className}" to @cloudflare/sandbox's Sandbox class.`);
770
830
  }
771
831
  const merged = mergeFlueAdditions(userConfig, additions);
772
832
  stripNoisyWranglerDefaults(merged);
@@ -810,6 +870,11 @@ import {
810
870
  InMemorySessionStore,
811
871
  bashFactoryToSessionEnv,
812
872
  resolveModel,
873
+ parseJsonBody,
874
+ validateAgentRequest,
875
+ toHttpResponse,
876
+ toSseData,
877
+ RouteNotFoundError,
813
878
  } from '@flue/sdk/internal';
814
879
  import { randomUUID } from 'node:crypto';
815
880
 
@@ -827,7 +892,10 @@ const handlers = {
827
892
  ${agents.map((a) => ` ${JSON.stringify(a.name)}: ${agentVarName(a.name)},`).join("\n")}
828
893
  };
829
894
 
830
- const webhookAgents = new Set(${JSON.stringify(webhookAgents.map((a) => a.name))});
895
+ // Set of webhook-accessible agent names. Named distinctly from the
896
+ // validateAgentRequest \`webhookAgents\` parameter to keep the call site
897
+ // below readable.
898
+ const webhookAgentSet = new Set(${JSON.stringify(webhookAgents.map((a) => a.name))});
831
899
 
832
900
  // When the CLI starts this server via \`flue run\`, it sets FLUE_MODE=local.
833
901
  // In local mode the HTTP route accepts any registered agent (including
@@ -902,31 +970,29 @@ const app = new Hono();
902
970
  app.get('/health', (c) => c.json({ status: 'ok' }));
903
971
  app.get('/agents', (c) => c.json(manifest));
904
972
 
905
- // Agent id is required in the URL
906
- app.post('/agents/:name', (c) => {
907
- return c.json({
908
- error: 'Agent id is required. Use /agents/:name/:id',
909
- }, 400);
910
- });
911
-
912
- app.post('/agents/:name/:id', async (c) => {
973
+ // Catch any method on the agent route so non-POSTs become 405 (instead of
974
+ // Hono's default 404 for unmatched method). Throws are translated by the
975
+ // onError handler into the canonical error envelope.
976
+ app.all('/agents/:name/:id', async (c) => {
913
977
  const name = c.req.param('name');
914
978
  const id = c.req.param('id');
915
979
 
916
- if (!handlers[name]) {
917
- return c.json({ error: 'Agent not found' }, 404);
918
- }
919
- if (!webhookAgents.has(name) && !isLocalMode) {
920
- return c.json({ error: 'Agent "' + name + '" is not web-accessible (no webhook trigger)' }, 404);
921
- }
980
+ // Validate method, name shape, registration, webhook-accessibility.
981
+ // Throws FlueHttpError on any failure; caught by app.onError below.
982
+ validateAgentRequest({
983
+ method: c.req.method,
984
+ name,
985
+ id,
986
+ registeredAgents: Object.keys(handlers),
987
+ webhookAgents: Array.from(webhookAgentSet),
988
+ allowNonWebhook: isLocalMode,
989
+ });
922
990
 
923
991
  const handler = handlers[name];
924
- let payload;
925
- try {
926
- payload = await c.req.json();
927
- } catch {
928
- payload = {};
929
- }
992
+
993
+ // Parse the request body. Throws on invalid Content-Type or malformed JSON;
994
+ // returns {} for genuinely empty bodies (so no-payload agents still work).
995
+ const payload = await parseJsonBody(c.req.raw);
930
996
 
931
997
  const accept = c.req.header('accept') || '';
932
998
  const isWebhook = c.req.header('x-webhook') === 'true';
@@ -949,7 +1015,13 @@ app.post('/agents/:name/:id', async (c) => {
949
1015
  return c.json({ status: 'accepted', requestId }, 202);
950
1016
  }
951
1017
 
952
- // SSE streaming mode
1018
+ // SSE streaming mode. Two error regimes meet here:
1019
+ // - Pre-stream errors (validation, body parsing, agent lookup) have
1020
+ // already thrown above and are rendered as plain HTTP responses by
1021
+ // app.onError — headers haven't been sent yet, so this works.
1022
+ // - Errors during agent execution surface as in-stream \`error\` events
1023
+ // with the canonical envelope (via toSseData), since by then the
1024
+ // 200 + text/event-stream headers are already on the wire.
953
1025
  if (isSSE) {
954
1026
  return streamSSE(c, async (stream) => {
955
1027
  let eventId = 0;
@@ -973,7 +1045,7 @@ app.post('/agents/:name/:id', async (c) => {
973
1045
  });
974
1046
  } catch (err) {
975
1047
  await stream.writeSSE({
976
- data: JSON.stringify({ type: 'error', error: String(err) }),
1048
+ data: toSseData(err),
977
1049
  event: 'error',
978
1050
  id: String(eventId++),
979
1051
  });
@@ -987,18 +1059,29 @@ app.post('/agents/:name/:id', async (c) => {
987
1059
  });
988
1060
  }
989
1061
 
990
- // Sync mode (default)
1062
+ // Sync mode (default). Errors propagate to app.onError.
1063
+ const ctx = createContextForRequest(id, payload);
991
1064
  try {
992
- const ctx = createContextForRequest(id, payload);
993
1065
  const result = await handler(ctx);
994
- ctx.setEventCallback(undefined);
995
1066
  return c.json({ result: result !== undefined ? result : null });
996
- } catch (err) {
997
- console.error('[flue] Agent error:', name, err);
998
- return c.json({ error: String(err) }, 500);
1067
+ } finally {
1068
+ ctx.setEventCallback(undefined);
999
1069
  }
1000
1070
  });
1001
1071
 
1072
+ // 404 handler — fires for any URL that didn't match a registered route.
1073
+ app.notFound((c) => {
1074
+ // Throw rather than return so the onError handler is the single source of
1075
+ // truth for error-envelope shaping.
1076
+ throw new RouteNotFoundError({ method: c.req.method, path: new URL(c.req.url).pathname });
1077
+ });
1078
+
1079
+ // Single-source-of-truth error renderer. Every thrown FlueError (and every
1080
+ // thrown unknown) is converted to the canonical JSON envelope here.
1081
+ // toHttpResponse takes care of logging unknowns — no extra console.error
1082
+ // needed at this layer.
1083
+ app.onError((err) => toHttpResponse(err));
1084
+
1002
1085
  // ─── Start ──────────────────────────────────────────────────────────────────
1003
1086
 
1004
1087
  const port = parseInt(process.env.PORT || '3000', 10);
@@ -1594,6 +1677,29 @@ var CloudflareReloader = class {
1594
1677
  port;
1595
1678
  configPath;
1596
1679
  envFiles;
1680
+ /**
1681
+ * Stable container build ID for the lifetime of this reloader instance.
1682
+ *
1683
+ * `unstable_startWorker` does NOT default this field — only wrangler's CLI
1684
+ * path does, via `generateContainerBuildId()`. When the user's wrangler
1685
+ * config declares `containers[]` (e.g. via `@cloudflare/sandbox`), the
1686
+ * first `onBundleComplete` calls `getImageNameFromDOClassName(...)` which
1687
+ * asserts that `options.containerBuildId` is set; without this, the
1688
+ * assertion throws, the `ProxyController` never gets `reloadComplete`,
1689
+ * and every request hangs (including `/health`). See issue #22.
1690
+ *
1691
+ * We generate it once per reloader and reuse it across reloads so that
1692
+ * wrangler's container-prep cache hits when nothing about the image
1693
+ * changed. Format matches wrangler's own helper: an 8-char UUID slice.
1694
+ */
1695
+ containerBuildId;
1696
+ /**
1697
+ * Bound listener for `DevEnv` `'error'` events. Stored so we can detach
1698
+ * it on `disposeWorker()` — the underlying `EventEmitter` outlives the
1699
+ * worker handle, so if the listener stayed attached we'd leak (and
1700
+ * double-fire) across reloads.
1701
+ */
1702
+ errorListener = null;
1597
1703
  url;
1598
1704
  constructor(wrangler, opts) {
1599
1705
  this.wrangler = wrangler;
@@ -1601,6 +1707,7 @@ var CloudflareReloader = class {
1601
1707
  this.port = opts.port;
1602
1708
  this.envFiles = opts.envFiles;
1603
1709
  this.configPath = path.join(this.outputDir, "dist", "wrangler.jsonc");
1710
+ this.containerBuildId = randomUUID().slice(0, 8);
1604
1711
  }
1605
1712
  async start() {
1606
1713
  await this.startWorker();
@@ -1659,9 +1766,18 @@ var CloudflareReloader = class {
1659
1766
  port: this.port
1660
1767
  },
1661
1768
  watch: false,
1662
- logLevel: "info"
1769
+ logLevel: "info",
1770
+ containerBuildId: this.containerBuildId
1663
1771
  }
1664
1772
  });
1773
+ this.errorListener = (event) => {
1774
+ const reason = event?.reason ?? "unknown error";
1775
+ const cause = event?.cause;
1776
+ const causeMsg = cause && typeof cause === "object" && "message" in cause ? cause.message : void 0;
1777
+ console.error(`[flue] Wrangler error (${event?.source ?? "unknown"}): ${reason}`);
1778
+ if (causeMsg) console.error(`[flue] ${causeMsg}`);
1779
+ };
1780
+ this.worker.raw.on("error", this.errorListener);
1665
1781
  try {
1666
1782
  this.url = (await this.worker.url).toString().replace(/\/$/, "");
1667
1783
  } catch {
@@ -1670,8 +1786,13 @@ var CloudflareReloader = class {
1670
1786
  }
1671
1787
  async disposeWorker() {
1672
1788
  const worker = this.worker;
1789
+ const listener = this.errorListener;
1673
1790
  this.worker = null;
1791
+ this.errorListener = null;
1674
1792
  if (!worker) return;
1793
+ if (listener) try {
1794
+ worker.raw.off("error", listener);
1795
+ } catch {}
1675
1796
  try {
1676
1797
  await worker.dispose();
1677
1798
  } catch (err) {