@flue/sdk 0.3.11 → 0.4.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.
package/dist/index.mjs CHANGED
@@ -1,88 +1,13 @@
1
- import { a as parseFrontmatterFile, n as createTools, t as BUILTIN_TOOL_NAMES } from "./agent-Cahthgu3.mjs";
1
+ import { c as createTools, d as parseFrontmatterFile, s as BUILTIN_TOOL_NAMES, t as ResultUnavailableError } from "./result-K1IRhWKM.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
- import { packageUpSync } from "package-up";
6
5
  import * as ts from "typescript";
6
+ import { packageUpSync } from "package-up";
7
7
  import { spawn } from "node:child_process";
8
8
  import { randomUUID } from "node:crypto";
9
9
  import { parseEnv } from "node:util";
10
10
 
11
- //#region src/agent-parser.ts
12
- /** Extract static agent metadata at build time without evaluating the agent module. */
13
- function parseAgentFile(filePath) {
14
- return { triggers: parseTriggers(filePath) };
15
- }
16
- function parseTriggers(filePath) {
17
- const source = fs.readFileSync(filePath, "utf-8");
18
- const ast = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true, scriptKindForFile(filePath));
19
- let result;
20
- for (const statement of ast.statements) {
21
- if (isTriggersReExport(statement)) throwUnsupportedTriggers(filePath, "re-exported triggers are not supported");
22
- if (!ts.isVariableStatement(statement) || !hasExportModifier(statement)) continue;
23
- for (const declaration of statement.declarationList.declarations) {
24
- if (!ts.isIdentifier(declaration.name) || declaration.name.text !== "triggers") continue;
25
- if (result) throwUnsupportedTriggers(filePath, "multiple triggers exports were found");
26
- if (!declaration.initializer) throwUnsupportedTriggers(filePath, "missing initializer");
27
- result = parseTriggersInitializer(filePath, declaration.initializer);
28
- }
29
- }
30
- return result ?? {};
31
- }
32
- function scriptKindForFile(filePath) {
33
- if (/\.m?js$/.test(filePath)) return ts.ScriptKind.JS;
34
- return ts.ScriptKind.TS;
35
- }
36
- function hasExportModifier(statement) {
37
- return statement.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword) ?? false;
38
- }
39
- function isTriggersReExport(statement) {
40
- if (!ts.isExportDeclaration(statement) || !statement.exportClause) return false;
41
- if (!ts.isNamedExports(statement.exportClause)) return false;
42
- return statement.exportClause.elements.some((element) => element.name.text === "triggers");
43
- }
44
- function parseTriggersInitializer(filePath, initializer) {
45
- const expr = unwrapExpression(initializer);
46
- if (!ts.isObjectLiteralExpression(expr)) throwUnsupportedTriggers(filePath, "expected a static object literal");
47
- const result = {};
48
- for (const property of expr.properties) {
49
- if (ts.isSpreadAssignment(property)) throwUnsupportedTriggers(filePath, "spread properties are not supported");
50
- if (ts.isShorthandPropertyAssignment(property)) {
51
- const name = property.name.text;
52
- if (name === "webhook") throwUnsupportedTriggers(filePath, `"${name}" must use an explicit static value`);
53
- continue;
54
- }
55
- if (!ts.isPropertyAssignment(property)) {
56
- const name = propertyNameText(filePath, property.name);
57
- if (name === "webhook") throwUnsupportedTriggers(filePath, `"${name}" must use an explicit static value`);
58
- continue;
59
- }
60
- if (propertyNameText(filePath, property.name) === "webhook") {
61
- const value = unwrapExpression(property.initializer);
62
- if (value.kind === ts.SyntaxKind.TrueKeyword) result.webhook = true;
63
- else if (value.kind === ts.SyntaxKind.FalseKeyword) delete result.webhook;
64
- else throwUnsupportedTriggers(filePath, "\"webhook\" must be true or false");
65
- }
66
- }
67
- return result;
68
- }
69
- function unwrapExpression(expr) {
70
- while (ts.isAsExpression(expr) || ts.isSatisfiesExpression(expr) || ts.isTypeAssertionExpression(expr) || ts.isParenthesizedExpression(expr)) expr = expr.expression;
71
- return expr;
72
- }
73
- function propertyNameText(filePath, name) {
74
- if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) return name.text;
75
- if (ts.isComputedPropertyName(name)) {
76
- const expression = unwrapExpression(name.expression);
77
- if (ts.isStringLiteral(expression) || ts.isNoSubstitutionTemplateLiteral(expression)) return expression.text;
78
- throwUnsupportedTriggers(filePath, "computed property names must be static");
79
- }
80
- }
81
- function throwUnsupportedTriggers(filePath, reason) {
82
- throw new Error(`[flue] Unsupported triggers export in ${filePath}: ${reason}. Use a static object literal, for example: export const triggers = { webhook: true }.`);
83
- }
84
-
85
- //#endregion
86
11
  //#region src/cloudflare-wrangler-merge.ts
87
12
  /**
88
13
  * Merge Flue's Cloudflare additions into the user's wrangler config.
@@ -114,7 +39,7 @@ const MIN_COMPATIBILITY_DATE = "2026-04-01";
114
39
  /** compatibility_flag Flue requires for pi-ai's process.env-based API key lookup. */
115
40
  const REQUIRED_COMPAT_FLAG = "nodejs_compat";
116
41
  /**
117
- * Read and normalize the user's wrangler config from `outputDir`.
42
+ * Read and normalize the user's wrangler config from `root`.
118
43
  *
119
44
  * Looks for `wrangler.jsonc`, `wrangler.json`, then `wrangler.toml` (jsonc is
120
45
  * Cloudflare's recommended format for new projects, but all three work).
@@ -136,7 +61,7 @@ const REQUIRED_COMPAT_FLAG = "nodejs_compat";
136
61
  * `dist/wrangler.jsonc` and the benefit is correctness without us reimplementing
137
62
  * wrangler's path-resolution logic.
138
63
  */
139
- async function readUserWranglerConfig(outputDir) {
64
+ async function readUserWranglerConfig(root) {
140
65
  const candidates = [
141
66
  "wrangler.jsonc",
142
67
  "wrangler.json",
@@ -144,7 +69,7 @@ async function readUserWranglerConfig(outputDir) {
144
69
  ];
145
70
  let foundPath = null;
146
71
  for (const name of candidates) {
147
- const candidate = path.join(outputDir, name);
72
+ const candidate = path.join(root, name);
148
73
  if (fs.existsSync(candidate)) {
149
74
  foundPath = candidate;
150
75
  break;
@@ -392,43 +317,48 @@ function detectSandboxBindings(userConfig) {
392
317
  * silently and let esbuild's own error path take over. This avoids false
393
318
  * positives in unusual project layouts.
394
319
  */
395
- function assertSandboxPackageInstalled(sandboxClassNames, searchDirs) {
320
+ function assertSandboxPackageInstalled(sandboxClassNames, root) {
396
321
  if (sandboxClassNames.length === 0) return;
397
- for (const dir of searchDirs) {
398
- let current = dir;
399
- while (current !== path.dirname(current)) {
400
- const pkgPath = path.join(current, "package.json");
401
- if (fs.existsSync(pkgPath)) try {
402
- const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
403
- if ("@cloudflare/sandbox" in {
404
- ...pkg.dependencies ?? {},
405
- ...pkg.devDependencies ?? {},
406
- ...pkg.peerDependencies ?? {},
407
- ...pkg.optionalDependencies ?? {}
408
- }) return;
409
- } catch {
410
- return;
411
- }
412
- current = path.dirname(current);
322
+ let current = root;
323
+ while (current !== path.dirname(current)) {
324
+ const pkgPath = path.join(current, "package.json");
325
+ if (fs.existsSync(pkgPath)) try {
326
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
327
+ if ("@cloudflare/sandbox" in {
328
+ ...pkg.dependencies ?? {},
329
+ ...pkg.devDependencies ?? {},
330
+ ...pkg.peerDependencies ?? {},
331
+ ...pkg.optionalDependencies ?? {}
332
+ }) return;
333
+ } catch {
334
+ return;
413
335
  }
336
+ current = path.dirname(current);
414
337
  }
415
338
  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\`.`);
416
339
  }
417
340
  /**
418
- * Write the wrangler deploy-redirect file at `<outputDir>/.wrangler/deploy/config.json`
419
- * so that `wrangler deploy` run from `outputDir` automatically picks up the
420
- * generated `dist/wrangler.jsonc`.
341
+ * Write the wrangler deploy-redirect file at
342
+ * `<root>/.wrangler/deploy/config.json` so that `wrangler deploy` run from
343
+ * the project root automatically picks up the generated wrangler config at
344
+ * `<output>/wrangler.jsonc`.
421
345
  *
422
346
  * This is wrangler's own native redirection mechanism (the same one Astro's
423
347
  * Cloudflare adapter uses). We only write the file if one doesn't already
424
348
  * exist — if the user has set one up, respect their intent.
349
+ *
350
+ * `output` may be anywhere (typically `<root>/dist`, but the user
351
+ * can redirect it via `--output`). We compute a relative path so the
352
+ * redirect file is portable across machines / repos.
425
353
  */
426
- function writeDeployRedirectIfMissing(outputDir) {
427
- const redirectDir = path.join(outputDir, ".wrangler", "deploy");
354
+ function writeDeployRedirectIfMissing(root, output) {
355
+ const redirectDir = path.join(root, ".wrangler", "deploy");
428
356
  const redirectPath = path.join(redirectDir, "config.json");
429
357
  if (fs.existsSync(redirectPath)) return;
430
358
  fs.mkdirSync(redirectDir, { recursive: true });
431
- fs.writeFileSync(redirectPath, JSON.stringify({ configPath: "../../dist/wrangler.jsonc" }, null, 2) + "\n", "utf-8");
359
+ const targetPath = path.join(output, "wrangler.jsonc");
360
+ const relConfigPath = path.relative(redirectDir, targetPath).split(path.sep).join("/");
361
+ fs.writeFileSync(redirectPath, JSON.stringify({ configPath: relConfigPath }, null, 2) + "\n", "utf-8");
432
362
  }
433
363
 
434
364
  //#endregion
@@ -446,28 +376,30 @@ var CloudflarePlugin = class {
446
376
  * single build.
447
377
  */
448
378
  userConfigCache;
449
- async getUserConfig(outputDir) {
450
- if (!this.userConfigCache) this.userConfigCache = await readUserWranglerConfig(outputDir);
379
+ /**
380
+ * Read the user's wrangler config from `root`. The user's config always
381
+ * lives at the project root, regardless of where the build artifacts get
382
+ * written via `output`. We only re-locate the *generated*
383
+ * `wrangler.jsonc` (the merged one) — never the source one.
384
+ */
385
+ async getUserConfig(root) {
386
+ if (!this.userConfigCache) this.userConfigCache = await readUserWranglerConfig(root);
451
387
  return this.userConfigCache;
452
388
  }
453
389
  async generateEntryPoint(ctx) {
454
- const { agents, roles } = ctx;
390
+ const { agents, roles, appEntry } = ctx;
455
391
  const rolesJson = JSON.stringify(roles);
456
392
  validateCloudflareAgentNames(ctx);
457
393
  const webhookAgents = agents.filter((a) => a.triggers.webhook);
458
394
  const agentImports = agents.map((a, index) => {
459
395
  return `import ${agentVarName$1(a.name, index)} from '${a.filePath.replace(/\\/g, "/")}';`;
460
396
  }).join("\n");
461
- const manifest = JSON.stringify({ agents: agents.map((a) => ({
462
- name: a.name,
463
- triggers: a.triggers
464
- })) }, null, 2);
465
397
  const agentClasses = webhookAgents.map((a) => {
466
398
  const className = agentClassName(a.name);
467
399
  const handlerVar = agentVarName$1(a.name, agents.indexOf(a));
468
400
  return `export class ${className} extends Agent {
469
401
  async onRequest(request) {
470
- return handleAgentRequest(request, this, ${JSON.stringify(a.name)}, ${handlerVar});
402
+ return dispatchAgent(request, this, ${JSON.stringify(a.name)}, ${handlerVar});
471
403
  }
472
404
 
473
405
  async onFiberRecovered(ctx) {
@@ -480,10 +412,11 @@ var CloudflarePlugin = class {
480
412
  }
481
413
  }`;
482
414
  }).join("\n\n");
483
- const { config: userConfig } = await this.getUserConfig(ctx.outputDir);
415
+ const { config: userConfig } = await this.getUserConfig(ctx.root);
484
416
  const sandboxReExports = detectSandboxBindings(userConfig).map((name) => `export { Sandbox as ${name} } from '@cloudflare/sandbox';`).join("\n");
485
417
  return `
486
418
  // Auto-generated by @flue/sdk build (cloudflare)
419
+ import { env } from 'cloudflare:workers';
487
420
  import { Agent, routeAgentRequest } from 'agents';
488
421
  import { Bash, InMemoryFs } from 'just-bash';
489
422
  import {
@@ -491,30 +424,43 @@ import {
491
424
  InMemorySessionStore,
492
425
  bashFactoryToSessionEnv,
493
426
  resolveModel,
494
- parseJsonBody,
495
- toHttpResponse,
496
- toSseData,
497
- AgentNotFoundError,
498
- MethodNotAllowedError,
499
- RouteNotFoundError,
500
- InvalidRequestError,
427
+ handleAgentRequest,
428
+ configureFlueRuntime,
429
+ createDefaultFlueApp,
501
430
  } from '@flue/sdk/internal';
502
- import { runWithCloudflareContext, cfSandboxToSessionEnv } from '@flue/sdk/cloudflare';
431
+ import {
432
+ runWithCloudflareContext,
433
+ cfSandboxToSessionEnv,
434
+ getCloudflareAIBindingApiProvider,
435
+ } from '@flue/sdk/cloudflare';
436
+ import { registerApiProvider, registerProvider } from '@flue/sdk/app';
503
437
 
504
438
  ${agentImports}
505
439
 
440
+ ${appEntry ? `import userApp from '${appEntry.replace(/\\/g, "/")}';` : ""}
441
+
442
+ // ─── Internal provider registrations ────────────────────────────────────────
443
+ // Imports evaluate before this file's top-level body, so these built-ins
444
+ // reserve the \`cloudflare\` prefix after any user app.ts registrations run.
445
+
446
+ // Wire-protocol handler for the cloudflare-ai-binding api.
447
+ registerApiProvider(getCloudflareAIBindingApiProvider());
448
+
449
+ // Capture the binding reference at module init; invoke it only per request.
450
+ registerProvider('cloudflare', {
451
+ api: 'cloudflare-ai-binding',
452
+ binding: env.AI,
453
+ });
454
+
506
455
  // ─── Config ─────────────────────────────────────────────────────────────────
507
456
 
508
457
  const roles = ${rolesJson};
509
458
  const skills = {};
510
459
  const systemPrompt = '';
511
- const manifest = ${manifest};
512
460
 
513
- // Set of webhook-accessible agent names (raw form, as used in URL segments).
514
- // Used by the worker fetch handler to pre-route requests and reject unknown
515
- // agents with a JSON 404 envelope before the request hits the partyserver
516
- // dispatcher (which would otherwise return text/plain "Invalid request").
517
- const webhookAgentNames = new Set(${JSON.stringify(webhookAgents.map((a) => a.name))});
461
+ // Webhook-accessible agent names. Consumed by the seeded flue() runtime
462
+ // for the "is this name registered?" check inside the agent route.
463
+ const webhookAgentNames = ${JSON.stringify(webhookAgents.map((a) => a.name))};
518
464
 
519
465
  // ─── Sandbox Environments ───────────────────────────────────────────────────
520
466
 
@@ -608,7 +554,7 @@ function createDOStore(sql) {
608
554
  };
609
555
  }
610
556
 
611
- function createContextForRequest(id, payload, doInstance) {
557
+ function createContextForRequest(id, payload, doInstance, req) {
612
558
  // Use DO SQLite storage by default, fall back to in-memory
613
559
  const defaultStore = doInstance?.ctx?.storage?.sql
614
560
  ? createDOStore(doInstance.ctx.storage.sql)
@@ -618,6 +564,7 @@ function createContextForRequest(id, payload, doInstance) {
618
564
  id,
619
565
  payload,
620
566
  env: doInstance?.env ?? {},
567
+ req,
621
568
  agentConfig: {
622
569
  systemPrompt, skills, roles, model: undefined, resolveModel,
623
570
  },
@@ -645,154 +592,54 @@ function assertAgentsDurabilityApi(doInstance, method) {
645
592
  }
646
593
  }
647
594
 
648
- function runHandlerWithKeepAlive(doInstance, ctx, handler) {
649
- return runWithInstanceContext(doInstance, () => {
650
- assertAgentsDurabilityApi(doInstance, 'keepAliveWhile');
651
- return doInstance.keepAliveWhile(() => handler(ctx));
652
- });
653
- }
654
-
655
- function startWebhookFiber(doInstance, requestId, agentName, id, payload, handler) {
656
- const run = async (fiber) => {
657
- fiber?.stash?.({
658
- version: 1,
659
- kind: 'webhook',
660
- agentName,
661
- id,
662
- requestId,
663
- phase: 'running',
664
- startedAt: Date.now(),
665
- });
666
-
667
- const ctx = createContextForRequest(id, payload, doInstance);
668
- return runWithInstanceContext(doInstance, async () => {
669
- try {
670
- return await handler(ctx);
671
- } finally {
672
- ctx.setEventCallback(undefined);
673
- }
674
- });
675
- };
676
-
677
- assertAgentsDurabilityApi(doInstance, 'runFiber');
678
- return doInstance.runFiber('flue:webhook:' + requestId, run);
679
- }
680
-
681
595
  async function handleFlueFiberRecovered(ctx, _doInstance, agentName) {
682
596
  if (!ctx.name || !ctx.name.startsWith('flue:')) return;
683
597
  console.warn('[flue] Cloudflare fiber interrupted:', agentName, ctx.name, ctx.snapshot ?? null);
684
598
  }
685
599
 
686
- // ─── Shared Request Handler ────────────────────────────────────────────────
687
-
688
- async function handleAgentRequest(request, doInstance, agentName, handler) {
689
- // Agent id is the DO "room name" set by routeAgentRequest
690
- const id = doInstance.name;
691
-
692
- try {
693
- // Parse the request body. Throws on invalid Content-Type or malformed
694
- // JSON; returns {} for genuinely empty bodies (so no-payload agents
695
- // still work).
696
- const payload = await parseJsonBody(request);
697
-
698
- const accept = request.headers.get('accept') || '';
699
- const isWebhook = request.headers.get('x-webhook') === 'true';
700
- const isSSE = accept.includes('text/event-stream') && !isWebhook;
701
-
702
- // Fire-and-forget (webhook mode)
703
- if (isWebhook) {
704
- const requestId = crypto.randomUUID();
705
- startWebhookFiber(doInstance, requestId, agentName, id, payload, handler).then(
706
- (result) => {
707
- console.log('[flue] Webhook handler complete:', agentName,
708
- result !== undefined ? JSON.stringify(result) : '(no return)');
709
- },
710
- (err) => {
711
- console.error('[flue] Webhook handler error:', agentName, err);
712
- },
713
- );
714
- return new Response(JSON.stringify({ status: 'accepted', requestId }), {
715
- status: 202,
716
- headers: { 'content-type': 'application/json' },
717
- });
718
- }
600
+ // ─── Per-DO Dispatch ───────────────────────────────────────────────────────
719
601
 
720
- // SSE streaming mode. Two error regimes meet here:
721
- // - Pre-stream errors (body parsing, etc.) have already thrown above
722
- // and are caught by the outer try/catch rendered as plain HTTP
723
- // responses by toHttpResponse, since headers haven't been sent yet.
724
- // - Errors during agent execution surface as in-stream \`error\`
725
- // events with the canonical envelope (via toSseData), since by
726
- // then the 200 + text/event-stream headers are already on the wire.
727
- if (isSSE) {
728
- const { readable, writable } = new TransformStream();
729
- const writer = writable.getWriter();
730
- const encoder = new TextEncoder();
731
- let eventId = 0;
732
- let isIdle = false;
602
+ /**
603
+ * Per-DO entry point invoked from each generated agent class's onRequest().
604
+ * Wraps the shared handleAgentRequest with CF-specific bits:
605
+ *
606
+ * - keepAliveWhile around the foreground handler so the DO doesn't
607
+ * hibernate mid-stream.
608
+ * - runFiber for fire-and-forget webhook execution so it survives across
609
+ * hibernation.
610
+ * - runWithCloudflareContext for AsyncLocalStorage-based env propagation
611
+ * (the workers-ai provider reads env.AI through it).
612
+ */
613
+ async function dispatchAgent(request, doInstance, agentName, handler) {
614
+ const id = doInstance.name; // DO room name set by routeAgentRequest
733
615
 
734
- const writeSSE = async (data, event) => {
735
- const lines = [];
736
- if (event) lines.push('event: ' + event);
737
- lines.push('id: ' + eventId++);
738
- lines.push('data: ' + (typeof data === 'string' ? data : JSON.stringify(data)));
739
- lines.push('', '');
740
- await writer.write(encoder.encode(lines.join('\\n')));
616
+ return handleAgentRequest({
617
+ request,
618
+ agentName,
619
+ id,
620
+ handler,
621
+ createContext: (id_, payload, req) => createContextForRequest(id_, payload, doInstance, req),
622
+ startWebhook: (requestId, run) => {
623
+ const wrapped = (fiber) => {
624
+ fiber?.stash?.({
625
+ version: 1,
626
+ kind: 'webhook',
627
+ agentName,
628
+ id,
629
+ requestId,
630
+ phase: 'running',
631
+ startedAt: Date.now(),
632
+ });
633
+ return runWithInstanceContext(doInstance, run);
741
634
  };
742
-
743
- const ctx = createContextForRequest(id, payload, doInstance);
744
- ctx.setEventCallback((event) => {
745
- if (event.type === 'idle') isIdle = true;
746
- writeSSE(event, event.type).catch(() => {});
747
- });
748
-
749
- (async () => {
750
- try {
751
- const result = await runHandlerWithKeepAlive(doInstance, ctx, handler);
752
- if (!isIdle) {
753
- await writeSSE({ type: 'idle' }, 'idle');
754
- }
755
- await writeSSE(
756
- { type: 'result', data: result !== undefined ? result : null },
757
- 'result',
758
- );
759
- } catch (err) {
760
- await writeSSE(toSseData(err), 'error');
761
- if (!isIdle) {
762
- await writeSSE({ type: 'idle' }, 'idle');
763
- }
764
- } finally {
765
- ctx.setEventCallback(undefined);
766
- await writer.close();
767
- }
768
- })();
769
-
770
- return new Response(readable, {
771
- headers: {
772
- 'content-type': 'text/event-stream',
773
- 'cache-control': 'no-cache',
774
- 'connection': 'keep-alive',
775
- },
776
- });
777
- }
778
-
779
- // Sync mode (default)
780
- const ctx = createContextForRequest(id, payload, doInstance);
781
- try {
782
- const result = await runHandlerWithKeepAlive(doInstance, ctx, handler);
783
- return new Response(
784
- JSON.stringify({ result: result !== undefined ? result : null }),
785
- { headers: { 'content-type': 'application/json' } },
786
- );
787
- } finally {
788
- ctx.setEventCallback(undefined);
789
- }
790
- } catch (err) {
791
- // toHttpResponse logs unknowns via flueLog.error — no extra console.error
792
- // needed here. The agentName tag is captured in the wrapped error's
793
- // server-side log line via flueLog's prefix.
794
- return toHttpResponse(err);
795
- }
635
+ assertAgentsDurabilityApi(doInstance, 'runFiber');
636
+ return doInstance.runFiber('flue:webhook:' + requestId, wrapped);
637
+ },
638
+ runHandler: (ctx, h) => runWithInstanceContext(doInstance, () => {
639
+ assertAgentsDurabilityApi(doInstance, 'keepAliveWhile');
640
+ return doInstance.keepAliveWhile(() => h(ctx));
641
+ }),
642
+ });
796
643
  }
797
644
 
798
645
  // ─── Per-Agent Durable Object Classes ──────────────────────────────────────
@@ -807,71 +654,43 @@ ${agentClasses}
807
654
  // by the user's wrangler.jsonc.
808
655
  ${sandboxReExports}
809
656
 
810
- // ─── Worker Fetch Handler ───────────────────────────────────────────────────
811
-
812
- export default {
813
- async fetch(request, env) {
814
- try {
815
- const url = new URL(request.url);
816
- const method = request.method;
817
-
818
- // Health check
819
- if (url.pathname === '/health') {
820
- return new Response(JSON.stringify({ status: 'ok' }), {
821
- headers: { 'content-type': 'application/json' },
822
- });
823
- }
657
+ // ─── Runtime seed ───────────────────────────────────────────────────────────
658
+
659
+ // Seed the public flue() sub-app's runtime. On Cloudflare the agent route
660
+ // forwards to routeAgentRequest() (provided by the Agents SDK), which
661
+ // dispatches into the per-agent DO's onRequest → dispatchAgent above.
662
+ // Validation (method, name, id) happens inside flue() before forwarding,
663
+ // which suppresses partyserver's noisy default text/plain "Invalid
664
+ // request" response for unknown / malformed routes.
665
+ configureFlueRuntime({
666
+ target: 'cloudflare',
667
+ webhookAgents: webhookAgentNames,
668
+ // Cloudflare deploys never run in local mode — the trigger-less agents
669
+ // simply have no DO class to land in.
670
+ allowNonWebhook: false,
671
+ routeAgentRequest: (request, env) => routeAgentRequest(request, env),
672
+ });
824
673
 
825
- // Agent manifest
826
- if (url.pathname === '/agents' && method === 'GET') {
827
- return new Response(JSON.stringify(manifest), {
828
- headers: { 'content-type': 'application/json' },
829
- });
830
- }
674
+ // ─── App composition ────────────────────────────────────────────────────────
831
675
 
832
- // Webhook agent route: /agents/<name>/<id>
833
- //
834
- // We pre-check method and agent registration here, BEFORE delegating to
835
- // routeAgentRequest. Without this, partyserver (the transitive
836
- // dispatcher behind routeAgentRequest) returns a text/plain
837
- // "Invalid request" 400 for unknown agent namespaces — visibly
838
- // inconsistent with the rest of the API and with the Node target.
839
- // Pre-routing means every error path in the agent route flows through
840
- // the same JSON envelope.
841
- const agentRouteMatch = url.pathname.match(/^\\/agents\\/([^/]+)\\/([^/]+)\\/?$/);
842
- if (agentRouteMatch) {
843
- if (method !== 'POST') {
844
- throw new MethodNotAllowedError({ method, allowed: ['POST'] });
845
- }
846
- const name = decodeURIComponent(agentRouteMatch[1]);
847
- const id = decodeURIComponent(agentRouteMatch[2]);
848
- if (name.trim() === '' || id.trim() === '') {
849
- throw new InvalidRequestError({
850
- reason: 'Webhook URLs must have the shape /agents/<name>/<id> with non-empty segments.',
851
- });
852
- }
853
- if (!webhookAgentNames.has(name)) {
854
- throw new AgentNotFoundError({
855
- name,
856
- available: Array.from(webhookAgentNames),
857
- });
858
- }
859
- // All gating passed. Delegate to the Agents SDK / partyserver, which
860
- // dispatches into the per-agent DO's onRequest → handleAgentRequest.
861
- // routeAgentRequest may still return null for shape mismatches we
862
- // didn't anticipate; treat that as a route_not_found with a hint.
863
- const response = await routeAgentRequest(request, env);
864
- if (response) return response;
865
- throw new RouteNotFoundError({ method, path: url.pathname });
866
- }
676
+ ${appEntry ? `// User-supplied app.ts. Their default export owns the entire request
677
+ // pipeline — the worker just verifies a fetch method exists and pipes
678
+ // through. The default flue() handler is available for them to mount
679
+ // however they want; this file does not impose a composition.
680
+ const app = userApp;
681
+ if (!app || typeof app.fetch !== 'function') {
682
+ throw new Error(
683
+ '[flue] app.ts default export must be a Hono app or an object with a fetch(request, env, ctx) method.'
684
+ );
685
+ }` : `// No app.ts: build the default app via the SDK so the generated entry
686
+ // stays \`hono\`-free (users only need hono in their node_modules when
687
+ // they author their own app.ts). The default mounts \`flue()\` at root
688
+ // and renders canonical Flue envelopes for unmatched paths.
689
+ const app = createDefaultFlueApp();`}
867
690
 
868
- // Anything else: canonical 404 envelope.
869
- throw new RouteNotFoundError({ method, path: url.pathname });
870
- } catch (err) {
871
- // toHttpResponse logs unknowns via flueLog.error — no extra
872
- // console.error needed at this layer.
873
- return toHttpResponse(err);
874
- }
691
+ export default {
692
+ fetch(request, env, ctx) {
693
+ return app.fetch(request, env, ctx);
875
694
  },
876
695
  };
877
696
  `;
@@ -883,26 +702,26 @@ export default {
883
702
  name: agentClassName(a.name)
884
703
  }));
885
704
  const flueSqliteClasses = flueBindings.map((b) => b.class_name);
886
- const { config: userConfig, path: userConfigPath } = await this.getUserConfig(ctx.outputDir);
705
+ const { config: userConfig, path: userConfigPath } = await this.getUserConfig(ctx.root);
887
706
  if (userConfigPath) console.log(`[flue] Merging with user wrangler config: ${userConfigPath}`);
888
707
  validateUserWranglerConfig(userConfig);
889
708
  const flueMigrations = computeFlueMigrations(flueSqliteClasses, userConfig.migrations);
890
709
  const additions = {
891
- defaultName: path.basename(ctx.outputDir) || "flue-agents",
710
+ defaultName: path.basename(ctx.root) || "flue-agents",
892
711
  main: "_entry.ts",
893
712
  doBindings: flueBindings,
894
713
  migrations: flueMigrations
895
714
  };
896
715
  const sandboxClassNames = detectSandboxBindings(userConfig);
897
716
  if (sandboxClassNames.length > 0) {
898
- assertSandboxPackageInstalled(sandboxClassNames, [ctx.outputDir, ctx.workspaceDir]);
717
+ assertSandboxPackageInstalled(sandboxClassNames, ctx.root);
899
718
  for (const className of sandboxClassNames) console.log(`[flue] Auto-wiring DO binding "${className}" to @cloudflare/sandbox's Sandbox class.`);
900
719
  }
901
720
  const merged = mergeFlueAdditions(userConfig, additions);
902
721
  stripNoisyWranglerDefaults(merged);
903
722
  if (typeof merged.$schema !== "string") merged.$schema = "https://workers.cloudflare.com/schema/wrangler.json";
904
723
  outputs["wrangler.jsonc"] = JSON.stringify(merged, null, 2);
905
- writeDeployRedirectIfMissing(ctx.outputDir);
724
+ writeDeployRedirectIfMissing(ctx.root, ctx.output);
906
725
  return outputs;
907
726
  }
908
727
  };
@@ -914,7 +733,7 @@ function validateCloudflareAgentNames(ctx) {
914
733
  const invalidAgents = ctx.agents.filter((agent) => !CLOUDFLARE_AGENT_NAME_PATTERN.test(agent.name));
915
734
  if (invalidAgents.length === 0) return;
916
735
  const invalidList = invalidAgents.map((agent) => {
917
- return `${path.relative(ctx.workspaceDir, agent.filePath)} (${agent.name})`;
736
+ return `${path.relative(ctx.root, agent.filePath)} (${agent.name})`;
918
737
  }).join(", ");
919
738
  throw new Error(`[flue] Cloudflare target requires agent filenames to use lower-kebab-case so Durable Object bindings route correctly. Invalid agent file(s): ${invalidList}. Rename them to match ${CLOUDFLARE_AGENT_NAME_PATTERN}.`);
920
739
  }
@@ -935,31 +754,30 @@ var NodePlugin = class {
935
754
  name = "node";
936
755
  bundle = "esbuild";
937
756
  generateEntryPoint(ctx) {
938
- const { agents, roles } = ctx;
757
+ const { agents, roles, appEntry } = ctx;
939
758
  const rolesJson = JSON.stringify(roles);
940
759
  const webhookAgents = agents.filter((a) => a.triggers.webhook);
760
+ const agentImports = agents.map((a, index) => {
761
+ return `import ${agentVarName(a.name, index)} from '${a.filePath.replace(/\\/g, "/")}';`;
762
+ }).join("\n");
763
+ const handlerMapEntries = agents.map((a, index) => ` ${JSON.stringify(a.name)}: ${agentVarName(a.name, index)},`).join("\n");
764
+ const webhookNames = JSON.stringify(webhookAgents.map((a) => a.name));
941
765
  return `
942
766
  // Auto-generated by @flue/sdk build (node)
943
- import { Hono } from 'hono';
944
- import { streamSSE } from 'hono/streaming';
945
767
  import { serve } from '@hono/node-server';
946
- import { Bash, InMemoryFs, MountableFs, ReadWriteFs } from 'just-bash';
768
+ import { Bash, InMemoryFs } from 'just-bash';
947
769
  import {
948
770
  createFlueContext,
949
771
  InMemorySessionStore,
950
772
  bashFactoryToSessionEnv,
951
773
  resolveModel,
952
- parseJsonBody,
953
- validateAgentRequest,
954
- toHttpResponse,
955
- toSseData,
956
- RouteNotFoundError,
774
+ configureFlueRuntime,
775
+ createDefaultFlueApp,
957
776
  } from '@flue/sdk/internal';
958
- import { randomUUID } from 'node:crypto';
777
+ import { createLocalSessionEnv } from '@flue/sdk/node';
959
778
 
960
- ${agents.map((a, index) => {
961
- return `import ${agentVarName(a.name, index)} from '${a.filePath.replace(/\\/g, "/")}';`;
962
- }).join("\n")}
779
+ ${agentImports}
780
+ ${appEntry ? `import userApp from '${appEntry.replace(/\\/g, "/")}';` : ""}
963
781
 
964
782
  // ─── Config ─────────────────────────────────────────────────────────────────
965
783
 
@@ -968,13 +786,11 @@ const roles = ${rolesJson};
968
786
  const systemPrompt = '';
969
787
 
970
788
  const handlers = {
971
- ${agents.map((a, index) => ` ${JSON.stringify(a.name)}: ${agentVarName(a.name, index)},`).join("\n")}
789
+ ${handlerMapEntries}
972
790
  };
973
791
 
974
- // Set of webhook-accessible agent names. Named distinctly from the
975
- // validateAgentRequest \`webhookAgents\` parameter to keep the call site
976
- // below readable.
977
- const webhookAgentSet = new Set(${JSON.stringify(webhookAgents.map((a) => a.name))});
792
+ // Webhook-accessible agent names.
793
+ const webhookAgentNames = ${webhookNames};
978
794
 
979
795
  // When the CLI starts this server via \`flue run\`, it sets FLUE_MODE=local.
980
796
  // In local mode the HTTP route accepts any registered agent (including
@@ -983,11 +799,6 @@ const webhookAgentSet = new Set(${JSON.stringify(webhookAgents.map((a) => a.name
983
799
  // agents that the user only intended to invoke from their CI pipeline.
984
800
  const isLocalMode = process.env.FLUE_MODE === 'local';
985
801
 
986
- const manifest = ${JSON.stringify({ agents: agents.map((a) => ({
987
- name: a.name,
988
- triggers: a.triggers
989
- })) }, null, 2)};
990
-
991
802
  // ─── Sandbox Environments ───────────────────────────────────────────────────
992
803
 
993
804
  /**
@@ -1004,28 +815,25 @@ async function createDefaultEnv() {
1004
815
  }
1005
816
 
1006
817
  /**
1007
- * Create a local sandbox backed by the host filesystem.
1008
- * Mounts process.cwd() at /workspace via ReadWriteFs + MountableFs.
818
+ * Create a local SessionEnv backed directly by the host filesystem and
819
+ * child_process. No virtual filesystem, no sandbox layer — file methods
820
+ * call node:fs/promises and shell commands run on the host. Use this
821
+ * when flue itself is running inside an external sandbox / container /
822
+ * CI runner that already provides the isolation boundary.
1009
823
  */
1010
824
  async function createLocalEnv() {
1011
- const rwfs = new ReadWriteFs({ root: process.cwd() });
1012
- const fs = new MountableFs({ base: new InMemoryFs() });
1013
- fs.mount('/workspace', rwfs);
1014
- return bashFactoryToSessionEnv(() => new Bash({
1015
- fs,
1016
- cwd: '/workspace',
1017
- network: { dangerouslyAllowFullInternetAccess: true },
1018
- }));
825
+ return createLocalSessionEnv();
1019
826
  }
1020
827
 
1021
828
  // Default persistence store for Node — in-memory, process lifetime.
1022
829
  const defaultStore = new InMemorySessionStore();
1023
830
 
1024
- function createContextForRequest(id, payload) {
831
+ function createContextForRequest(id, payload, req) {
1025
832
  return createFlueContext({
1026
833
  id,
1027
834
  payload,
1028
835
  env: process.env,
836
+ req,
1029
837
  agentConfig: {
1030
838
  systemPrompt, skills, roles, model: undefined, resolveModel,
1031
839
  },
@@ -1035,130 +843,49 @@ function createContextForRequest(id, payload) {
1035
843
  });
1036
844
  }
1037
845
 
1038
- // ─── Server ─────────────────────────────────────────────────────────────────
1039
-
1040
- const app = new Hono();
1041
-
1042
- app.get('/health', (c) => c.json({ status: 'ok' }));
1043
- app.get('/agents', (c) => c.json(manifest));
1044
-
1045
- // Catch any method on the agent route so non-POSTs become 405 (instead of
1046
- // Hono's default 404 for unmatched method). Throws are translated by the
1047
- // onError handler into the canonical error envelope.
1048
- app.all('/agents/:name/:id', async (c) => {
1049
- const name = c.req.param('name');
1050
- const id = c.req.param('id');
1051
-
1052
- // Validate method, name shape, registration, webhook-accessibility.
1053
- // Throws FlueHttpError on any failure; caught by app.onError below.
1054
- validateAgentRequest({
1055
- method: c.req.method,
1056
- name,
1057
- id,
1058
- registeredAgents: Object.keys(handlers),
1059
- webhookAgents: Array.from(webhookAgentSet),
1060
- allowNonWebhook: isLocalMode,
1061
- });
1062
-
1063
- const handler = handlers[name];
1064
-
1065
- // Parse the request body. Throws on invalid Content-Type or malformed JSON;
1066
- // returns {} for genuinely empty bodies (so no-payload agents still work).
1067
- const payload = await parseJsonBody(c.req.raw);
1068
-
1069
- const accept = c.req.header('accept') || '';
1070
- const isWebhook = c.req.header('x-webhook') === 'true';
1071
- const isSSE = accept.includes('text/event-stream') && !isWebhook;
1072
-
1073
- // Fire-and-forget (webhook mode)
1074
- if (isWebhook) {
1075
- const requestId = randomUUID();
1076
- const ctx = createContextForRequest(id, payload);
1077
- handler(ctx).then(
1078
- (result) => {
1079
- ctx.setEventCallback(undefined);
1080
- console.log('[flue] Webhook handler complete:', name, result !== undefined ? JSON.stringify(result) : '(no return)');
1081
- },
1082
- (err) => {
1083
- ctx.setEventCallback(undefined);
1084
- console.error('[flue] Webhook handler error:', name, err);
1085
- },
1086
- );
1087
- return c.json({ status: 'accepted', requestId }, 202);
1088
- }
1089
-
1090
- // SSE streaming mode. Two error regimes meet here:
1091
- // - Pre-stream errors (validation, body parsing, agent lookup) have
1092
- // already thrown above and are rendered as plain HTTP responses by
1093
- // app.onError — headers haven't been sent yet, so this works.
1094
- // - Errors during agent execution surface as in-stream \`error\` events
1095
- // with the canonical envelope (via toSseData), since by then the
1096
- // 200 + text/event-stream headers are already on the wire.
1097
- if (isSSE) {
1098
- return streamSSE(c, async (stream) => {
1099
- let eventId = 0;
1100
- let isIdle = false;
1101
- const ctx = createContextForRequest(id, payload);
1102
- ctx.setEventCallback((event) => {
1103
- if (event.type === 'idle') isIdle = true;
1104
- stream.writeSSE({ data: JSON.stringify(event), event: event.type, id: String(eventId++) }).catch(() => {});
1105
- });
1106
-
1107
- try {
1108
- const result = await handler(ctx);
1109
- if (!isIdle) {
1110
- const idle = { type: 'idle' };
1111
- await stream.writeSSE({ data: JSON.stringify(idle), event: 'idle', id: String(eventId++) });
1112
- }
1113
- await stream.writeSSE({
1114
- data: JSON.stringify({ type: 'result', data: result !== undefined ? result : null }),
1115
- event: 'result',
1116
- id: String(eventId++),
1117
- });
1118
- } catch (err) {
1119
- await stream.writeSSE({
1120
- data: toSseData(err),
1121
- event: 'error',
1122
- id: String(eventId++),
1123
- });
1124
- if (!isIdle) {
1125
- const idle = { type: 'idle' };
1126
- await stream.writeSSE({ data: JSON.stringify(idle), event: 'idle', id: String(eventId++) });
1127
- }
1128
- } finally {
1129
- ctx.setEventCallback(undefined);
1130
- }
1131
- });
1132
- }
1133
-
1134
- // Sync mode (default). Errors propagate to app.onError.
1135
- const ctx = createContextForRequest(id, payload);
1136
- try {
1137
- const result = await handler(ctx);
1138
- return c.json({ result: result !== undefined ? result : null });
1139
- } finally {
1140
- ctx.setEventCallback(undefined);
1141
- }
846
+ // ─── Runtime seed ───────────────────────────────────────────────────────────
847
+
848
+ // Seed the public flue() sub-app with everything it needs to dispatch agent
849
+ // requests in-process. Must run before \`flue()\` handles any request — by
850
+ // virtue of being a top-level statement, it executes before \`serve(...)\`
851
+ // below binds the listener. User app.ts files that call \`flue()\` at top
852
+ // level are also fine because Hono routes are lazy: they read this config
853
+ // only when a request arrives.
854
+ configureFlueRuntime({
855
+ target: 'node',
856
+ webhookAgents: webhookAgentNames,
857
+ allowNonWebhook: isLocalMode,
858
+ handlers,
859
+ createContext: createContextForRequest,
1142
860
  });
1143
861
 
1144
- // 404 handler fires for any URL that didn't match a registered route.
1145
- app.notFound((c) => {
1146
- // Throw rather than return so the onError handler is the single source of
1147
- // truth for error-envelope shaping.
1148
- throw new RouteNotFoundError({ method: c.req.method, path: new URL(c.req.url).pathname });
1149
- });
862
+ // ─── App composition ────────────────────────────────────────────────────────
1150
863
 
1151
- // Single-source-of-truth error renderer. Every thrown FlueError (and every
1152
- // thrown unknown) is converted to the canonical JSON envelope here.
1153
- // toHttpResponse takes care of logging unknowns no extra console.error
1154
- // needed at this layer.
1155
- app.onError((err) => toHttpResponse(err));
864
+ ${appEntry ? `// User-supplied app.ts: their default export owns the entire request
865
+ // pipeline. We just verify it exposes a fetch method and pass the
866
+ // listener through. flue() is available for them to mount, but the
867
+ // composition is theirs to author.
868
+ const app = userApp;
869
+ if (!app || typeof app.fetch !== 'function') {
870
+ throw new Error(
871
+ '[flue] app.ts default export must be a Hono app or an object with a fetch(request) method.'
872
+ );
873
+ }` : `// No app.ts: build the default app via the SDK so the generated entry
874
+ // stays \`hono\`-free (users only need hono in their node_modules when
875
+ // they author their own app.ts). The default mounts \`flue()\` at root
876
+ // and renders canonical Flue envelopes for unmatched paths.
877
+ const app = createDefaultFlueApp();`}
1156
878
 
1157
879
  // ─── Start ──────────────────────────────────────────────────────────────────
1158
880
 
1159
881
  const port = parseInt(process.env.PORT || '3000', 10);
1160
882
 
1161
- const server = serve({ fetch: app.fetch, port });
883
+ const server = serve({
884
+ fetch: app.fetch,
885
+ port,
886
+ // SSE requests can outlive Node's default 300s request timeout.
887
+ serverOptions: { requestTimeout: 0 },
888
+ });
1162
889
  console.log('[flue] Server listening on http://localhost:' + port);
1163
890
  if (isLocalMode) {
1164
891
  console.log('[flue] Mode: local (all agents invokable, including trigger-less)');
@@ -1185,26 +912,123 @@ function agentVarName(name, index) {
1185
912
 
1186
913
  //#endregion
1187
914
  //#region src/build.ts
915
+ /** Extract static agent metadata at build time without evaluating the agent module. */
916
+ function parseAgentFile(filePath) {
917
+ return { triggers: parseTriggers(filePath) };
918
+ }
919
+ function parseTriggers(filePath) {
920
+ const source = fs.readFileSync(filePath, "utf-8");
921
+ const ast = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true, scriptKindForFile(filePath));
922
+ let result;
923
+ for (const statement of ast.statements) {
924
+ if (isTriggersReExport(statement)) throwUnsupportedTriggers(filePath, "re-exported triggers are not supported");
925
+ if (!ts.isVariableStatement(statement) || !hasExportModifier(statement)) continue;
926
+ for (const declaration of statement.declarationList.declarations) {
927
+ if (!ts.isIdentifier(declaration.name) || declaration.name.text !== "triggers") continue;
928
+ if (result) throwUnsupportedTriggers(filePath, "multiple triggers exports were found");
929
+ if (!declaration.initializer) throwUnsupportedTriggers(filePath, "missing initializer");
930
+ result = parseTriggersInitializer(filePath, declaration.initializer);
931
+ }
932
+ }
933
+ return result ?? {};
934
+ }
935
+ function scriptKindForFile(filePath) {
936
+ if (/\.m?js$/.test(filePath)) return ts.ScriptKind.JS;
937
+ return ts.ScriptKind.TS;
938
+ }
939
+ function hasExportModifier(statement) {
940
+ return statement.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword) ?? false;
941
+ }
942
+ function isTriggersReExport(statement) {
943
+ if (!ts.isExportDeclaration(statement) || !statement.exportClause) return false;
944
+ if (!ts.isNamedExports(statement.exportClause)) return false;
945
+ return statement.exportClause.elements.some((element) => element.name.text === "triggers");
946
+ }
947
+ function parseTriggersInitializer(filePath, initializer) {
948
+ const expr = unwrapExpression(initializer);
949
+ if (!ts.isObjectLiteralExpression(expr)) throwUnsupportedTriggers(filePath, "expected a static object literal");
950
+ const result = {};
951
+ for (const property of expr.properties) {
952
+ if (ts.isSpreadAssignment(property)) throwUnsupportedTriggers(filePath, "spread properties are not supported");
953
+ if (ts.isShorthandPropertyAssignment(property)) {
954
+ const name = property.name.text;
955
+ if (name === "webhook") throwUnsupportedTriggers(filePath, `"${name}" must use an explicit static value`);
956
+ continue;
957
+ }
958
+ if (!ts.isPropertyAssignment(property)) {
959
+ const name = propertyNameText(filePath, property.name);
960
+ if (name === "webhook") throwUnsupportedTriggers(filePath, `"${name}" must use an explicit static value`);
961
+ continue;
962
+ }
963
+ if (propertyNameText(filePath, property.name) === "webhook") {
964
+ const value = unwrapExpression(property.initializer);
965
+ if (value.kind === ts.SyntaxKind.TrueKeyword) result.webhook = true;
966
+ else if (value.kind === ts.SyntaxKind.FalseKeyword) delete result.webhook;
967
+ else throwUnsupportedTriggers(filePath, "\"webhook\" must be true or false");
968
+ }
969
+ }
970
+ return result;
971
+ }
972
+ function unwrapExpression(expr) {
973
+ while (ts.isAsExpression(expr) || ts.isSatisfiesExpression(expr) || ts.isTypeAssertionExpression(expr) || ts.isParenthesizedExpression(expr)) expr = expr.expression;
974
+ return expr;
975
+ }
976
+ function propertyNameText(filePath, name) {
977
+ if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) return name.text;
978
+ if (ts.isComputedPropertyName(name)) {
979
+ const expression = unwrapExpression(name.expression);
980
+ if (ts.isStringLiteral(expression) || ts.isNoSubstitutionTemplateLiteral(expression)) return expression.text;
981
+ throwUnsupportedTriggers(filePath, "computed property names must be static");
982
+ }
983
+ }
984
+ function throwUnsupportedTriggers(filePath, reason) {
985
+ throw new Error(`[flue] Unsupported triggers export in ${filePath}: ${reason}. Use a static object literal, for example: export const triggers = { webhook: true }.`);
986
+ }
987
+ const VALID_THINKING_LEVELS = {
988
+ off: true,
989
+ minimal: true,
990
+ low: true,
991
+ medium: true,
992
+ high: true,
993
+ xhigh: true
994
+ };
995
+ function parseThinkingLevel(value, source) {
996
+ if (value === void 0) return void 0;
997
+ const normalized = value.trim();
998
+ if (!normalized) return void 0;
999
+ if (!(normalized in VALID_THINKING_LEVELS)) throw new Error(`[flue] Invalid thinkingLevel ${JSON.stringify(value)} in ${source}. Expected one of: ${Object.keys(VALID_THINKING_LEVELS).join(", ")}.`);
1000
+ return normalized;
1001
+ }
1188
1002
  /**
1189
- * Build a workspace into a deployable artifact.
1003
+ * Build a project into a deployable artifact.
1004
+ *
1005
+ * `options.root` is the project root — typically the user's cwd. Source files
1006
+ * (agents, roles) are discovered from one of two locations inside the root,
1007
+ * with the same precedence rule the CLI uses:
1190
1008
  *
1191
- * `options.workspaceDir` is treated as an explicit workspace root the directory
1192
- * directly containing agents/ and roles/. No .flue/ waterfall is performed here;
1193
- * callers that want waterfall behavior (e.g. the CLI when --workspace is omitted)
1194
- * should use `resolveWorkspaceFromCwd` first.
1009
+ * - If `<root>/.flue/` exists, it is the source root. Look for
1010
+ * `.flue/agents/` and `.flue/roles/`. The bare `<root>/agents/` and
1011
+ * `<root>/roles/` are ignored entirely (no mixing).
1012
+ * - Otherwise, look at `<root>/agents/` and `<root>/roles/`.
1013
+ *
1014
+ * Build output lands in `options.output` (defaults to `<root>/dist`).
1195
1015
  *
1196
1016
  * AGENTS.md and .agents/skills/ are NOT bundled — discovered at runtime from session cwd.
1197
1017
  */
1198
1018
  async function build(options) {
1199
- const workspaceDir = path.resolve(options.workspaceDir);
1200
- const outputDir = path.resolve(options.outputDir);
1019
+ const root = path.resolve(options.root);
1020
+ const output = path.resolve(options.output ?? path.join(root, "dist"));
1201
1021
  const plugin = resolvePlugin(options);
1202
- console.log(`[flue] Building workspace: ${workspaceDir}`);
1203
- console.log(`[flue] Output: ${outputDir}/dist`);
1022
+ const sourceRoot = resolveSourceRoot(root);
1023
+ console.log(`[flue] Building: ${root}`);
1024
+ if (sourceRoot !== root) console.log(`[flue] Source root: ${sourceRoot}`);
1025
+ console.log(`[flue] Output: ${output}`);
1204
1026
  console.log(`[flue] Target: ${plugin.name}`);
1205
- const roles = discoverRoles(workspaceDir);
1206
- const agents = discoverAgents(workspaceDir);
1207
- if (agents.length === 0) throw new Error(`[flue] No agent files found.\n\nExpected at: ${path.join(workspaceDir, "agents")}/\nAdd at least one agent file (e.g. hello.ts).`);
1027
+ const roles = discoverRoles(sourceRoot);
1028
+ const agents = discoverAgents(sourceRoot);
1029
+ const appEntry = discoverAppEntry(sourceRoot);
1030
+ if (agents.length === 0) throw new Error(`[flue] No agent files found.\n\nExpected at: ${path.join(sourceRoot, "agents")}/\nAdd at least one agent file (e.g. hello.ts).`);
1031
+ if (appEntry) console.log(`[flue] Custom app entry: ${path.relative(root, appEntry) || appEntry}`);
1208
1032
  const webhookAgents = agents.filter((a) => a.triggers.webhook);
1209
1033
  const triggerlessAgents = agents.filter((a) => !a.triggers.webhook);
1210
1034
  console.log(`[flue] Found ${Object.keys(roles).length} role(s): ${Object.keys(roles).join(", ") || "(none)"}`);
@@ -1212,33 +1036,33 @@ async function build(options) {
1212
1036
  if (webhookAgents.length > 0) console.log(`[flue] Webhook agents: ${webhookAgents.map((a) => a.name).join(", ")}`);
1213
1037
  if (triggerlessAgents.length > 0) console.log(`[flue] CLI-only agents (no HTTP route in deployed build): ${triggerlessAgents.map((a) => a.name).join(", ")}`);
1214
1038
  console.log(`[flue] AGENTS.md and .agents/skills/ will be discovered at runtime from session cwd`);
1215
- const distDir = path.join(outputDir, "dist");
1216
- fs.mkdirSync(distDir, { recursive: true });
1039
+ fs.mkdirSync(output, { recursive: true });
1217
1040
  const manifest = { agents: agents.map((a) => ({
1218
1041
  name: a.name,
1219
1042
  triggers: a.triggers
1220
1043
  })) };
1221
- const manifestPath = path.join(distDir, "manifest.json");
1044
+ const manifestPath = path.join(output, "manifest.json");
1222
1045
  fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), "utf-8");
1223
1046
  console.log(`[flue] Generated: ${manifestPath}`);
1224
1047
  const ctx = {
1225
1048
  agents,
1226
1049
  roles,
1227
- workspaceDir,
1228
- outputDir,
1050
+ root,
1051
+ output,
1052
+ appEntry,
1229
1053
  options
1230
1054
  };
1231
1055
  const serverCode = await plugin.generateEntryPoint(ctx);
1232
1056
  const bundleStrategy = plugin.bundle ?? "esbuild";
1233
1057
  let anyChanged = false;
1234
1058
  if (bundleStrategy === "esbuild") {
1235
- const entryPath = path.join(distDir, "_entry_server.ts");
1236
- const outPath = path.join(distDir, "server.mjs");
1059
+ const entryPath = path.join(output, "_entry_server.ts");
1060
+ const outPath = path.join(output, "server.mjs");
1237
1061
  fs.writeFileSync(entryPath, serverCode, "utf-8");
1238
1062
  try {
1239
- const nodePathsSet = collectNodePaths(workspaceDir);
1063
+ const nodePathsSet = collectNodePaths(root);
1240
1064
  const { external: pluginExternal = [], ...pluginEsbuildOpts } = plugin.esbuildOptions ? plugin.esbuildOptions(ctx) : {};
1241
- const userExternals = getUserExternals(workspaceDir);
1065
+ const userExternals = getUserExternals(root);
1242
1066
  await esbuild.build({
1243
1067
  entryPoints: [entryPath],
1244
1068
  bundle: true,
@@ -1264,7 +1088,7 @@ async function build(options) {
1264
1088
  }
1265
1089
  } else if (bundleStrategy === "none") {
1266
1090
  if (!plugin.entryFilename) throw new Error(`[flue] Plugin "${plugin.name}" set bundle: 'none' but did not provide entryFilename.`);
1267
- const outPath = path.join(distDir, plugin.entryFilename);
1091
+ const outPath = path.join(output, plugin.entryFilename);
1268
1092
  if (!fs.existsSync(outPath) || fs.readFileSync(outPath, "utf-8") !== serverCode) {
1269
1093
  fs.writeFileSync(outPath, serverCode, "utf-8");
1270
1094
  console.log(`[flue] Wrote entry: ${outPath} (no bundle — downstream tool handles it)`);
@@ -1274,7 +1098,7 @@ async function build(options) {
1274
1098
  if (plugin.additionalOutputs) {
1275
1099
  const outputs = await plugin.additionalOutputs(ctx);
1276
1100
  for (const [filename, content] of Object.entries(outputs)) {
1277
- const filePath = path.join(distDir, filename);
1101
+ const filePath = path.join(output, filename);
1278
1102
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
1279
1103
  if (!fs.existsSync(filePath) || fs.readFileSync(filePath, "utf-8") !== content) {
1280
1104
  fs.writeFileSync(filePath, content, "utf-8");
@@ -1283,7 +1107,7 @@ async function build(options) {
1283
1107
  }
1284
1108
  }
1285
1109
  }
1286
- console.log(`[flue] Build complete. Output: ${distDir}`);
1110
+ console.log(`[flue] Build complete. Output: ${output}`);
1287
1111
  return { changed: anyChanged };
1288
1112
  }
1289
1113
  function resolvePlugin(options) {
@@ -1296,26 +1120,25 @@ function resolvePlugin(options) {
1296
1120
  }
1297
1121
  }
1298
1122
  /**
1299
- * Resolve a Flue workspace directory from the current working directory,
1300
- * using the two-layout convention. Intended for the CLI when `--workspace` is
1301
- * not provided — callers that pass an explicit workspace path should skip this
1302
- * and pass the path straight to `build()`.
1123
+ * Resolve the source root for a project, using the `.flue/`-as-src
1124
+ * convention (analogous to Next.js's `src/` folder).
1303
1125
  *
1304
- * Two supported layouts, checked in order:
1305
- * 1. `<cwd>/.flue/` use this when Flue is embedded in an existing project.
1306
- * 2. `<cwd>/` use this when the project itself is the Flue workspace.
1126
+ * If `<root>/.flue/` exists, it is the source root. Otherwise the source root
1127
+ * is the project root itself. The two layouts never mix if `.flue/` exists,
1128
+ * the bare layout is ignored entirely (even if a `<root>/agents/` directory
1129
+ * also happens to be present).
1307
1130
  *
1308
- * If `.flue/` exists, it wins unconditionally no mixing with the bare layout.
1309
- * Returns null if neither is present so the caller can produce a helpful error.
1131
+ * The project root (cwd) stays the same in both cases `.flue/` only shifts
1132
+ * where source files are discovered from. The build output directory is
1133
+ * independent (defaults to `<root>/dist`, override with `output`).
1310
1134
  */
1311
- function resolveWorkspaceFromCwd(cwd) {
1312
- const dotFlue = path.join(cwd, ".flue");
1135
+ function resolveSourceRoot(root) {
1136
+ const dotFlue = path.join(root, ".flue");
1313
1137
  if (fs.existsSync(dotFlue)) return dotFlue;
1314
- if (fs.existsSync(path.join(cwd, "agents"))) return cwd;
1315
- return null;
1138
+ return root;
1316
1139
  }
1317
- function discoverRoles(workspaceRoot) {
1318
- const rolesDir = path.join(workspaceRoot, "roles");
1140
+ function discoverRoles(sourceRoot) {
1141
+ const rolesDir = path.join(sourceRoot, "roles");
1319
1142
  if (!fs.existsSync(rolesDir)) return {};
1320
1143
  const roles = {};
1321
1144
  for (const entry of fs.readdirSync(rolesDir)) {
@@ -1324,17 +1147,19 @@ function discoverRoles(workspaceRoot) {
1324
1147
  const content = fs.readFileSync(filePath, "utf-8");
1325
1148
  const name = entry.replace(/\.(md|markdown)$/i, "");
1326
1149
  const parsed = parseFrontmatterFile(content, name);
1150
+ const thinkingLevel = parseThinkingLevel(parsed.frontmatter.thinkingLevel, `role "${name}" frontmatter`);
1327
1151
  roles[name] = {
1328
1152
  name,
1329
1153
  description: parsed.description,
1330
1154
  instructions: parsed.body,
1331
- model: parsed.frontmatter.model
1155
+ model: parsed.frontmatter.model,
1156
+ thinkingLevel
1332
1157
  };
1333
1158
  }
1334
1159
  return roles;
1335
1160
  }
1336
- function discoverAgents(workspaceRoot) {
1337
- const agentsDir = path.join(workspaceRoot, "agents");
1161
+ function discoverAgents(sourceRoot) {
1162
+ const agentsDir = path.join(sourceRoot, "agents");
1338
1163
  if (!fs.existsSync(agentsDir)) return [];
1339
1164
  return fs.readdirSync(agentsDir).filter((f) => /\.(ts|js|mts|mjs)$/.test(f)).map((f) => {
1340
1165
  const filePath = path.join(agentsDir, f);
@@ -1346,9 +1171,29 @@ function discoverAgents(workspaceRoot) {
1346
1171
  };
1347
1172
  });
1348
1173
  }
1174
+ /**
1175
+ * Discover an optional `app.{ts,mts,js,mjs}` entry alongside `agents/`
1176
+ * and `roles/`. Returns the absolute path to the first match found, or
1177
+ * undefined when no app entry is present.
1178
+ *
1179
+ * Extension priority matches {@link discoverAgents}: `.ts` > `.mts`
1180
+ * > `.js` > `.mjs`. Source-files-only — we don't probe inside the
1181
+ * `agents/` or `roles/` subdirs.
1182
+ */
1183
+ function discoverAppEntry(sourceRoot) {
1184
+ for (const ext of [
1185
+ "ts",
1186
+ "mts",
1187
+ "js",
1188
+ "mjs"
1189
+ ]) {
1190
+ const candidate = path.join(sourceRoot, `app.${ext}`);
1191
+ if (fs.existsSync(candidate)) return candidate;
1192
+ }
1193
+ }
1349
1194
  /** Externalize user's direct deps (bare name + subpath wildcard). */
1350
- function getUserExternals(workspaceDir) {
1351
- const pkgPath = packageUpSync({ cwd: workspaceDir });
1195
+ function getUserExternals(root) {
1196
+ const pkgPath = packageUpSync({ cwd: root });
1352
1197
  if (!pkgPath) return [];
1353
1198
  try {
1354
1199
  const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
@@ -1361,9 +1206,9 @@ function getUserExternals(workspaceDir) {
1361
1206
  return [];
1362
1207
  }
1363
1208
  }
1364
- function collectNodePaths(workspaceDir) {
1209
+ function collectNodePaths(root) {
1365
1210
  const nodePathsSet = /* @__PURE__ */ new Set();
1366
- for (const startDir of [workspaceDir, getSDKDir()]) {
1211
+ for (const startDir of [root, getSDKDir()]) {
1367
1212
  let dir = startDir;
1368
1213
  while (dir !== path.dirname(dir)) {
1369
1214
  const nm = path.join(dir, "node_modules");
@@ -1386,7 +1231,7 @@ function getSDKDir() {
1386
1231
  /**
1387
1232
  * Flue dev server.
1388
1233
  *
1389
- * Watches the user's workspace, rebuilds on file changes, and reloads the
1234
+ * Watches the project root, rebuilds on file changes, and reloads the
1390
1235
  * underlying server. Distinct from `flue run`: dev is the long-running,
1391
1236
  * edit-and-iterate command, while `flue run` is the one-shot
1392
1237
  * production-style invoker (build → run → exit).
@@ -1397,7 +1242,7 @@ function getSDKDir() {
1397
1242
  * what they each provide downstream is fundamentally different:
1398
1243
  *
1399
1244
  * - **Node** has no host bundler. Our esbuild pass produces the final
1400
- * `dist/server.mjs`. On any change in the workspace we rebuild and respawn
1245
+ * `dist/server.mjs`. On any change in the root we rebuild and respawn
1401
1246
  * the child Node process. Sub-second restart is fine.
1402
1247
  *
1403
1248
  * - **Cloudflare** uses Wrangler's bundler (the same one `wrangler dev` and
@@ -1426,18 +1271,18 @@ const DEFAULT_DEV_PORT = 3583;
1426
1271
  * — the user is editing code, after all, and we want to recover when they fix it.
1427
1272
  */
1428
1273
  async function dev(options) {
1429
- const workspaceDir = path.resolve(options.workspaceDir);
1430
- const outputDir = path.resolve(options.outputDir);
1274
+ const root = path.resolve(options.root);
1275
+ const output = path.resolve(options.output ?? path.join(root, "dist"));
1431
1276
  const port = options.port ?? DEFAULT_DEV_PORT;
1432
- const envFiles = resolveEnvFiles(options.envFiles, outputDir);
1277
+ const envFiles = resolveEnvFiles(options.envFiles, root);
1433
1278
  for (const f of envFiles) console.error(`[flue] Loading env from: ${f}`);
1434
1279
  const buildOptions = {
1435
- workspaceDir,
1436
- outputDir,
1280
+ root,
1281
+ output,
1437
1282
  target: options.target
1438
1283
  };
1439
1284
  console.error(`[flue] Starting dev server (target: ${options.target})`);
1440
- console.error(`[flue] Watching: ${workspaceDir}`);
1285
+ console.error(`[flue] Watching: ${root}`);
1441
1286
  console.error(`[flue] Building...`);
1442
1287
  const initialStart = Date.now();
1443
1288
  try {
@@ -1447,18 +1292,19 @@ async function dev(options) {
1447
1292
  }
1448
1293
  console.error(`[flue] Built in ${Date.now() - initialStart}ms`);
1449
1294
  const reloader = options.target === "node" ? new NodeReloader({
1450
- outputDir,
1295
+ root,
1296
+ output,
1451
1297
  port,
1452
1298
  envFiles
1453
1299
  }) : await createCloudflareReloader({
1454
- outputDir,
1300
+ output,
1455
1301
  port,
1456
1302
  envFiles
1457
1303
  });
1458
1304
  await reloader.start();
1459
1305
  if (reloader.url) {
1460
1306
  console.error(`[flue] Server: ${reloader.url}`);
1461
- const exampleAgent = pickExampleAgentName(outputDir, workspaceDir);
1307
+ const exampleAgent = pickExampleAgentName(output, root);
1462
1308
  if (exampleAgent) {
1463
1309
  console.error(`[flue] Try: curl -X POST ${reloader.url}/agents/${exampleAgent}/test-1 \\`);
1464
1310
  console.error(` -H 'Content-Type: application/json' -d '{}'`);
@@ -1468,8 +1314,8 @@ async function dev(options) {
1468
1314
  const rebuilder = createRebuilder(buildOptions, reloader);
1469
1315
  const envFileSet = new Set(envFiles);
1470
1316
  const watcher = createWatcher({
1471
- workspaceDir,
1472
- outputDir,
1317
+ root,
1318
+ output,
1473
1319
  target: options.target,
1474
1320
  envFiles,
1475
1321
  onChange: (relPath) => {
@@ -1543,39 +1389,46 @@ function createRebuilder(buildOptions, reloader) {
1543
1389
  } };
1544
1390
  }
1545
1391
  /**
1546
- * Watch the workspace for changes. Uses `fs.watch` recursive (Node 20+).
1392
+ * Watch the root for changes. Uses `fs.watch` recursive (Node 20+).
1547
1393
  *
1548
1394
  * Watched roots:
1549
- * - `<workspaceDir>` — agents/, roles/, AGENTS.md, .agents/skills/.
1550
- * - For Cloudflare: also `<outputDir>/wrangler.jsonc` (and `.json`),
1395
+ * - `<root>` — agents/, roles/, AGENTS.md, .agents/skills/, plus
1396
+ * `.flue/agents/` and `.flue/roles/` if the root uses the .flue/
1397
+ * source layout.
1398
+ * - For Cloudflare: also `<root>/wrangler.jsonc` (and `.json`),
1551
1399
  * since changes there require a worker restart.
1552
1400
  *
1553
1401
  * Ignored:
1554
- * - `dist/`, `node_modules/`, `.git/`, `.turbo/`
1555
- * - dotfiles other than the ones we explicitly care about (AGENTS.md is
1556
- * not a dotfile, so it's fine)
1557
- * - editor backup/swap suffixes
1402
+ * - The build output directory (`output`, defaults to `<root>/dist`).
1403
+ * Critical to break the build file-change rebuild loop.
1404
+ * - `node_modules/`, `.git/`, `.turbo/`
1405
+ * - Dotfiles and dotdirs at the project root, with one exception: the
1406
+ * `.flue/` source directory and everything inside it is allowed through
1407
+ * (since that's a valid source location under the .flue-as-src layout).
1408
+ * - Editor backup/swap suffixes
1558
1409
  */
1559
1410
  function createWatcher(options) {
1560
- const { workspaceDir, outputDir, target, envFiles, onChange } = options;
1411
+ const { root, output, target, envFiles, onChange } = options;
1561
1412
  const watchers = [];
1413
+ const outputRelToRoot = path.relative(root, output).split(path.sep).join("/");
1562
1414
  const isIgnoredPath = (relPath) => {
1563
- const parts = relPath.replace(/\\/g, "/").split("/");
1415
+ const normalized = relPath.replace(/\\/g, "/");
1416
+ if (normalized === ".flue" || normalized.startsWith(".flue/")) return false;
1417
+ if (outputRelToRoot && !outputRelToRoot.startsWith("..") && (normalized === outputRelToRoot || normalized.startsWith(outputRelToRoot + "/"))) return true;
1418
+ const parts = normalized.split("/");
1564
1419
  for (const part of parts) {
1565
1420
  if (part === "node_modules") return true;
1566
- if (part === "dist") return true;
1567
1421
  if (part === ".git") return true;
1568
1422
  if (part === ".turbo") return true;
1569
1423
  }
1570
1424
  const base = parts[parts.length - 1] ?? "";
1571
1425
  if (!base) return true;
1572
- if (base.startsWith(".") && base !== ".flueignore") return true;
1426
+ if (base.startsWith(".")) return true;
1573
1427
  if (base.endsWith("~") || base.endsWith(".swp") || base.endsWith(".swx")) return true;
1574
- if (base === ".DS_Store") return true;
1575
1428
  return false;
1576
1429
  };
1577
1430
  try {
1578
- const w = fs.watch(workspaceDir, { recursive: true }, (_event, filename) => {
1431
+ const w = fs.watch(root, { recursive: true }, (_event, filename) => {
1579
1432
  if (!filename) return;
1580
1433
  const rel = filename.toString();
1581
1434
  if (isIgnoredPath(rel)) return;
@@ -1583,14 +1436,14 @@ function createWatcher(options) {
1583
1436
  });
1584
1437
  watchers.push(w);
1585
1438
  } catch (err) {
1586
- console.error(`[flue] Failed to watch ${workspaceDir}: ${err instanceof Error ? err.message : String(err)}`);
1439
+ console.error(`[flue] Failed to watch ${root}: ${err instanceof Error ? err.message : String(err)}`);
1587
1440
  }
1588
1441
  if (target === "cloudflare") for (const cfgName of [
1589
1442
  "wrangler.jsonc",
1590
1443
  "wrangler.json",
1591
1444
  "wrangler.toml"
1592
1445
  ]) {
1593
- const cfgPath = path.join(outputDir, cfgName);
1446
+ const cfgPath = path.join(root, cfgName);
1594
1447
  if (!fs.existsSync(cfgPath)) continue;
1595
1448
  try {
1596
1449
  const w = fs.watch(cfgPath, () => onChange(cfgName));
@@ -1610,15 +1463,15 @@ function createWatcher(options) {
1610
1463
  var NodeReloader = class {
1611
1464
  child = null;
1612
1465
  serverPath;
1613
- outputDir;
1466
+ root;
1614
1467
  port;
1615
1468
  envFiles;
1616
1469
  url;
1617
1470
  constructor(opts) {
1618
- this.outputDir = opts.outputDir;
1471
+ this.root = opts.root;
1619
1472
  this.port = opts.port;
1620
1473
  this.envFiles = opts.envFiles;
1621
- this.serverPath = path.join(this.outputDir, "dist", "server.mjs");
1474
+ this.serverPath = path.join(opts.output, "server.mjs");
1622
1475
  this.url = `http://localhost:${this.port}`;
1623
1476
  }
1624
1477
  async start() {
@@ -1649,7 +1502,7 @@ var NodeReloader = class {
1649
1502
  "pipe",
1650
1503
  "pipe"
1651
1504
  ],
1652
- cwd: this.outputDir,
1505
+ cwd: this.root,
1653
1506
  env: {
1654
1507
  ...fromFiles,
1655
1508
  ...process.env,
@@ -1674,10 +1527,6 @@ var NodeReloader = class {
1674
1527
  if (code !== 0 && code !== null) console.error(`[flue] Node server exited unexpectedly (code=${code}, signal=${signal ?? "none"})`);
1675
1528
  }
1676
1529
  });
1677
- if (!await waitForHealth(this.url, 15e3)) {
1678
- await this.killChild();
1679
- throw new Error("Node server did not become ready within 15s");
1680
- }
1681
1530
  }
1682
1531
  async killChild() {
1683
1532
  const child = this.child;
@@ -1731,7 +1580,6 @@ Underlying error: ${err instanceof Error ? err.message : String(err)}`);
1731
1580
  var CloudflareReloader = class {
1732
1581
  worker = null;
1733
1582
  wrangler;
1734
- outputDir;
1735
1583
  port;
1736
1584
  configPath;
1737
1585
  envFiles;
@@ -1761,10 +1609,9 @@ var CloudflareReloader = class {
1761
1609
  url;
1762
1610
  constructor(wrangler, opts) {
1763
1611
  this.wrangler = wrangler;
1764
- this.outputDir = opts.outputDir;
1765
1612
  this.port = opts.port;
1766
1613
  this.envFiles = opts.envFiles;
1767
- this.configPath = path.join(this.outputDir, "dist", "wrangler.jsonc");
1614
+ this.configPath = path.join(opts.output, "wrangler.jsonc");
1768
1615
  this.containerBuildId = randomUUID().slice(0, 8);
1769
1616
  }
1770
1617
  async start() {
@@ -1783,19 +1630,26 @@ var CloudflareReloader = class {
1783
1630
  * so we have to re-parse them. (Plain body edits redo a tiny amount
1784
1631
  * of work but the rebuild is cheap and idempotent.)
1785
1632
  * - Changes to `roles/*.md` — roles are baked into the entry as JSON.
1633
+ * - Adds/removes/edits of `app.{ts,mts,js,mjs}` — discovery flips the
1634
+ * entry between the user-app form and the default-app fallback,
1635
+ * and the import path is baked into `_entry.ts`. Body edits are
1636
+ * handled by wrangler's source watcher, but emitting a rebuild on
1637
+ * the path itself is cheap and means add/remove is correctly
1638
+ * observed even when the user toggles the file in/out.
1786
1639
  * - Changes to the user's `wrangler.jsonc` — affects the merged config.
1787
1640
  *
1788
1641
  * Notes we explicitly DO ignore for rebuild purposes (wrangler handles
1789
- * them): edits to imported source files outside of `agents/`/`roles/`,
1790
- * AGENTS.md, and `.agents/skills/` (those are runtime-discovered, not
1791
- * baked into the entry).
1642
+ * them): edits to imported source files outside of `agents/`/`roles/`/
1643
+ * `app.*`, AGENTS.md, and `.agents/skills/` (those are runtime-
1644
+ * discovered, not baked into the entry).
1792
1645
  */
1793
1646
  shouldRebuildOn(relPath) {
1794
1647
  if (this.envFiles.includes(relPath)) return true;
1795
1648
  const normalized = relPath.replace(/\\/g, "/");
1796
1649
  if (normalized === "wrangler.jsonc" || normalized === "wrangler.json" || normalized === "wrangler.toml") return true;
1797
- if (normalized.startsWith("agents/")) return true;
1798
- if (normalized.startsWith("roles/")) return true;
1650
+ if (normalized.startsWith("agents/") || normalized.startsWith(".flue/agents/")) return true;
1651
+ if (normalized.startsWith("roles/") || normalized.startsWith(".flue/roles/")) return true;
1652
+ if (/^(?:\.flue\/)?app\.(?:ts|mts|js|mjs)$/.test(normalized)) return true;
1799
1653
  return false;
1800
1654
  }
1801
1655
  async reload(buildChanged) {
@@ -1892,32 +1746,18 @@ function parseEnvFiles(absolutePaths) {
1892
1746
  }
1893
1747
  return merged;
1894
1748
  }
1895
- async function waitForHealth(baseUrl, timeoutMs) {
1896
- const start = Date.now();
1897
- while (Date.now() - start < timeoutMs) {
1898
- try {
1899
- const controller = new AbortController();
1900
- const timeout = setTimeout(() => controller.abort(), 1e3);
1901
- const res = await fetch(`${baseUrl}/health`, { signal: controller.signal });
1902
- clearTimeout(timeout);
1903
- if (res.ok) return true;
1904
- } catch {}
1905
- await new Promise((r) => setTimeout(r, 200));
1906
- }
1907
- return false;
1908
- }
1909
1749
  /**
1910
1750
  * Pick a webhook agent name to print in the friendly curl example. Falls back
1911
1751
  * to any agent if none have webhook triggers (the example would 404 on the
1912
1752
  * dev server in that case, but it's still a hint at the URL shape). Reads the
1913
- * manifest written by the build, with a directory-scan fallback in case the
1914
- * manifest is somehow missing.
1753
+ * manifest written by the build at `<output>/manifest.json`, with a
1754
+ * source-tree scan fallback in case the manifest is somehow missing.
1915
1755
  *
1916
1756
  * Best-effort — silently returns null if anything goes wrong.
1917
1757
  */
1918
- function pickExampleAgentName(outputDir, workspaceDir) {
1758
+ function pickExampleAgentName(output, root) {
1919
1759
  try {
1920
- const manifestPath = path.join(outputDir, "dist", "manifest.json");
1760
+ const manifestPath = path.join(output, "manifest.json");
1921
1761
  if (fs.existsSync(manifestPath)) {
1922
1762
  const agents = JSON.parse(fs.readFileSync(manifestPath, "utf-8")).agents ?? [];
1923
1763
  const webhook = agents.find((a) => a.triggers?.webhook);
@@ -1926,7 +1766,7 @@ function pickExampleAgentName(outputDir, workspaceDir) {
1926
1766
  }
1927
1767
  } catch {}
1928
1768
  try {
1929
- const agentsDir = path.join(workspaceDir, "agents");
1769
+ const agentsDir = path.join(resolveSourceRoot(root), "agents");
1930
1770
  if (!fs.existsSync(agentsDir)) return null;
1931
1771
  for (const e of fs.readdirSync(agentsDir)) {
1932
1772
  const m = e.match(/^([a-zA-Z0-9_-]+)\.(ts|js|mts|mjs)$/);
@@ -1939,4 +1779,4 @@ function pickExampleAgentName(outputDir, workspaceDir) {
1939
1779
  }
1940
1780
 
1941
1781
  //#endregion
1942
- export { BUILTIN_TOOL_NAMES, DEFAULT_DEV_PORT, build, createTools, dev, parseEnvFiles, resolveEnvFiles, resolveWorkspaceFromCwd };
1782
+ export { BUILTIN_TOOL_NAMES, DEFAULT_DEV_PORT, ResultUnavailableError, build, createTools, dev, parseEnvFiles, resolveEnvFiles, resolveSourceRoot };