@flue/sdk 0.3.10 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,7 +1,8 @@
1
- import { a as parseFrontmatterFile, n as createTools, t as BUILTIN_TOOL_NAMES } from "./agent-BTB0809P.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 * as ts from "typescript";
5
6
  import { packageUpSync } from "package-up";
6
7
  import { spawn } from "node:child_process";
7
8
  import { randomUUID } from "node:crypto";
@@ -38,7 +39,7 @@ const MIN_COMPATIBILITY_DATE = "2026-04-01";
38
39
  /** compatibility_flag Flue requires for pi-ai's process.env-based API key lookup. */
39
40
  const REQUIRED_COMPAT_FLAG = "nodejs_compat";
40
41
  /**
41
- * Read and normalize the user's wrangler config from `outputDir`.
42
+ * Read and normalize the user's wrangler config from `root`.
42
43
  *
43
44
  * Looks for `wrangler.jsonc`, `wrangler.json`, then `wrangler.toml` (jsonc is
44
45
  * Cloudflare's recommended format for new projects, but all three work).
@@ -60,7 +61,7 @@ const REQUIRED_COMPAT_FLAG = "nodejs_compat";
60
61
  * `dist/wrangler.jsonc` and the benefit is correctness without us reimplementing
61
62
  * wrangler's path-resolution logic.
62
63
  */
63
- async function readUserWranglerConfig(outputDir) {
64
+ async function readUserWranglerConfig(root) {
64
65
  const candidates = [
65
66
  "wrangler.jsonc",
66
67
  "wrangler.json",
@@ -68,7 +69,7 @@ async function readUserWranglerConfig(outputDir) {
68
69
  ];
69
70
  let foundPath = null;
70
71
  for (const name of candidates) {
71
- const candidate = path.join(outputDir, name);
72
+ const candidate = path.join(root, name);
72
73
  if (fs.existsSync(candidate)) {
73
74
  foundPath = candidate;
74
75
  break;
@@ -316,43 +317,48 @@ function detectSandboxBindings(userConfig) {
316
317
  * silently and let esbuild's own error path take over. This avoids false
317
318
  * positives in unusual project layouts.
318
319
  */
319
- function assertSandboxPackageInstalled(sandboxClassNames, searchDirs) {
320
+ function assertSandboxPackageInstalled(sandboxClassNames, root) {
320
321
  if (sandboxClassNames.length === 0) return;
321
- for (const dir of searchDirs) {
322
- let current = dir;
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;
335
- }
336
- 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;
337
335
  }
336
+ current = path.dirname(current);
338
337
  }
339
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\`.`);
340
339
  }
341
340
  /**
342
- * Write the wrangler deploy-redirect file at `<outputDir>/.wrangler/deploy/config.json`
343
- * so that `wrangler deploy` run from `outputDir` automatically picks up the
344
- * 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`.
345
345
  *
346
346
  * This is wrangler's own native redirection mechanism (the same one Astro's
347
347
  * Cloudflare adapter uses). We only write the file if one doesn't already
348
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.
349
353
  */
350
- function writeDeployRedirectIfMissing(outputDir) {
351
- const redirectDir = path.join(outputDir, ".wrangler", "deploy");
354
+ function writeDeployRedirectIfMissing(root, output) {
355
+ const redirectDir = path.join(root, ".wrangler", "deploy");
352
356
  const redirectPath = path.join(redirectDir, "config.json");
353
357
  if (fs.existsSync(redirectPath)) return;
354
358
  fs.mkdirSync(redirectDir, { recursive: true });
355
- 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");
356
362
  }
357
363
 
358
364
  //#endregion
@@ -370,27 +376,30 @@ var CloudflarePlugin = class {
370
376
  * single build.
371
377
  */
372
378
  userConfigCache;
373
- async getUserConfig(outputDir) {
374
- 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);
375
387
  return this.userConfigCache;
376
388
  }
377
389
  async generateEntryPoint(ctx) {
378
- const { agents, roles } = ctx;
390
+ const { agents, roles, appEntry } = ctx;
379
391
  const rolesJson = JSON.stringify(roles);
392
+ validateCloudflareAgentNames(ctx);
380
393
  const webhookAgents = agents.filter((a) => a.triggers.webhook);
381
- const agentImports = agents.map((a) => {
382
- return `import ${agentVarName$1(a.name)} from '${a.filePath.replace(/\\/g, "/")}';`;
394
+ const agentImports = agents.map((a, index) => {
395
+ return `import ${agentVarName$1(a.name, index)} from '${a.filePath.replace(/\\/g, "/")}';`;
383
396
  }).join("\n");
384
- const manifest = JSON.stringify({ agents: agents.map((a) => ({
385
- name: a.name,
386
- triggers: a.triggers
387
- })) }, null, 2);
388
397
  const agentClasses = webhookAgents.map((a) => {
389
398
  const className = agentClassName(a.name);
390
- const handlerVar = agentVarName$1(a.name);
399
+ const handlerVar = agentVarName$1(a.name, agents.indexOf(a));
391
400
  return `export class ${className} extends Agent {
392
401
  async onRequest(request) {
393
- return handleAgentRequest(request, this, ${JSON.stringify(a.name)}, ${handlerVar});
402
+ return dispatchAgent(request, this, ${JSON.stringify(a.name)}, ${handlerVar});
394
403
  }
395
404
 
396
405
  async onFiberRecovered(ctx) {
@@ -403,10 +412,11 @@ var CloudflarePlugin = class {
403
412
  }
404
413
  }`;
405
414
  }).join("\n\n");
406
- const { config: userConfig } = await this.getUserConfig(ctx.outputDir);
415
+ const { config: userConfig } = await this.getUserConfig(ctx.root);
407
416
  const sandboxReExports = detectSandboxBindings(userConfig).map((name) => `export { Sandbox as ${name} } from '@cloudflare/sandbox';`).join("\n");
408
417
  return `
409
418
  // Auto-generated by @flue/sdk build (cloudflare)
419
+ import { env } from 'cloudflare:workers';
410
420
  import { Agent, routeAgentRequest } from 'agents';
411
421
  import { Bash, InMemoryFs } from 'just-bash';
412
422
  import {
@@ -414,30 +424,43 @@ import {
414
424
  InMemorySessionStore,
415
425
  bashFactoryToSessionEnv,
416
426
  resolveModel,
417
- parseJsonBody,
418
- toHttpResponse,
419
- toSseData,
420
- AgentNotFoundError,
421
- MethodNotAllowedError,
422
- RouteNotFoundError,
423
- InvalidRequestError,
427
+ handleAgentRequest,
428
+ configureFlueRuntime,
429
+ createDefaultFlueApp,
424
430
  } from '@flue/sdk/internal';
425
- 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';
426
437
 
427
438
  ${agentImports}
428
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
+
429
455
  // ─── Config ─────────────────────────────────────────────────────────────────
430
456
 
431
457
  const roles = ${rolesJson};
432
458
  const skills = {};
433
459
  const systemPrompt = '';
434
- const manifest = ${manifest};
435
460
 
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))});
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))};
441
464
 
442
465
  // ─── Sandbox Environments ───────────────────────────────────────────────────
443
466
 
@@ -531,7 +554,7 @@ function createDOStore(sql) {
531
554
  };
532
555
  }
533
556
 
534
- function createContextForRequest(id, payload, doInstance) {
557
+ function createContextForRequest(id, payload, doInstance, req) {
535
558
  // Use DO SQLite storage by default, fall back to in-memory
536
559
  const defaultStore = doInstance?.ctx?.storage?.sql
537
560
  ? createDOStore(doInstance.ctx.storage.sql)
@@ -541,6 +564,7 @@ function createContextForRequest(id, payload, doInstance) {
541
564
  id,
542
565
  payload,
543
566
  env: doInstance?.env ?? {},
567
+ req,
544
568
  agentConfig: {
545
569
  systemPrompt, skills, roles, model: undefined, resolveModel,
546
570
  },
@@ -568,154 +592,54 @@ function assertAgentsDurabilityApi(doInstance, method) {
568
592
  }
569
593
  }
570
594
 
571
- function runHandlerWithKeepAlive(doInstance, ctx, handler) {
572
- return runWithInstanceContext(doInstance, () => {
573
- assertAgentsDurabilityApi(doInstance, 'keepAliveWhile');
574
- return doInstance.keepAliveWhile(() => handler(ctx));
575
- });
576
- }
577
-
578
- function startWebhookFiber(doInstance, requestId, agentName, id, payload, handler) {
579
- const run = async (fiber) => {
580
- fiber?.stash?.({
581
- version: 1,
582
- kind: 'webhook',
583
- agentName,
584
- id,
585
- requestId,
586
- phase: 'running',
587
- startedAt: Date.now(),
588
- });
589
-
590
- const ctx = createContextForRequest(id, payload, doInstance);
591
- return runWithInstanceContext(doInstance, async () => {
592
- try {
593
- return await handler(ctx);
594
- } finally {
595
- ctx.setEventCallback(undefined);
596
- }
597
- });
598
- };
599
-
600
- assertAgentsDurabilityApi(doInstance, 'runFiber');
601
- return doInstance.runFiber('flue:webhook:' + requestId, run);
602
- }
603
-
604
595
  async function handleFlueFiberRecovered(ctx, _doInstance, agentName) {
605
596
  if (!ctx.name || !ctx.name.startsWith('flue:')) return;
606
597
  console.warn('[flue] Cloudflare fiber interrupted:', agentName, ctx.name, ctx.snapshot ?? null);
607
598
  }
608
599
 
609
- // ─── Shared Request Handler ────────────────────────────────────────────────
610
-
611
- async function handleAgentRequest(request, doInstance, agentName, handler) {
612
- // Agent id is the DO "room name" set by routeAgentRequest
613
- const id = doInstance.name;
614
-
615
- try {
616
- // Parse the request body. Throws on invalid Content-Type or malformed
617
- // JSON; returns {} for genuinely empty bodies (so no-payload agents
618
- // still work).
619
- const payload = await parseJsonBody(request);
600
+ // ─── Per-DO Dispatch ───────────────────────────────────────────────────────
620
601
 
621
- const accept = request.headers.get('accept') || '';
622
- const isWebhook = request.headers.get('x-webhook') === 'true';
623
- const isSSE = accept.includes('text/event-stream') && !isWebhook;
624
-
625
- // Fire-and-forget (webhook mode)
626
- if (isWebhook) {
627
- const requestId = crypto.randomUUID();
628
- startWebhookFiber(doInstance, requestId, agentName, id, payload, handler).then(
629
- (result) => {
630
- console.log('[flue] Webhook handler complete:', agentName,
631
- result !== undefined ? JSON.stringify(result) : '(no return)');
632
- },
633
- (err) => {
634
- console.error('[flue] Webhook handler error:', agentName, err);
635
- },
636
- );
637
- return new Response(JSON.stringify({ status: 'accepted', requestId }), {
638
- status: 202,
639
- headers: { 'content-type': 'application/json' },
640
- });
641
- }
642
-
643
- // SSE streaming mode. Two error regimes meet here:
644
- // - Pre-stream errors (body parsing, etc.) have already thrown above
645
- // and are caught by the outer try/catch — rendered as plain HTTP
646
- // responses by toHttpResponse, since headers haven't been sent yet.
647
- // - Errors during agent execution surface as in-stream \`error\`
648
- // events with the canonical envelope (via toSseData), since by
649
- // then the 200 + text/event-stream headers are already on the wire.
650
- if (isSSE) {
651
- const { readable, writable } = new TransformStream();
652
- const writer = writable.getWriter();
653
- const encoder = new TextEncoder();
654
- let eventId = 0;
655
- 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
656
615
 
657
- const writeSSE = async (data, event) => {
658
- const lines = [];
659
- if (event) lines.push('event: ' + event);
660
- lines.push('id: ' + eventId++);
661
- lines.push('data: ' + (typeof data === 'string' ? data : JSON.stringify(data)));
662
- lines.push('', '');
663
- 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);
664
634
  };
665
-
666
- const ctx = createContextForRequest(id, payload, doInstance);
667
- ctx.setEventCallback((event) => {
668
- if (event.type === 'idle') isIdle = true;
669
- writeSSE(event, event.type).catch(() => {});
670
- });
671
-
672
- (async () => {
673
- try {
674
- const result = await runHandlerWithKeepAlive(doInstance, ctx, handler);
675
- if (!isIdle) {
676
- await writeSSE({ type: 'idle' }, 'idle');
677
- }
678
- await writeSSE(
679
- { type: 'result', data: result !== undefined ? result : null },
680
- 'result',
681
- );
682
- } catch (err) {
683
- await writeSSE(toSseData(err), 'error');
684
- if (!isIdle) {
685
- await writeSSE({ type: 'idle' }, 'idle');
686
- }
687
- } finally {
688
- ctx.setEventCallback(undefined);
689
- await writer.close();
690
- }
691
- })();
692
-
693
- return new Response(readable, {
694
- headers: {
695
- 'content-type': 'text/event-stream',
696
- 'cache-control': 'no-cache',
697
- 'connection': 'keep-alive',
698
- },
699
- });
700
- }
701
-
702
- // Sync mode (default)
703
- const ctx = createContextForRequest(id, payload, doInstance);
704
- try {
705
- const result = await runHandlerWithKeepAlive(doInstance, ctx, handler);
706
- return new Response(
707
- JSON.stringify({ result: result !== undefined ? result : null }),
708
- { headers: { 'content-type': 'application/json' } },
709
- );
710
- } finally {
711
- ctx.setEventCallback(undefined);
712
- }
713
- } catch (err) {
714
- // toHttpResponse logs unknowns via flueLog.error — no extra console.error
715
- // needed here. The agentName tag is captured in the wrapped error's
716
- // server-side log line via flueLog's prefix.
717
- return toHttpResponse(err);
718
- }
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
+ });
719
643
  }
720
644
 
721
645
  // ─── Per-Agent Durable Object Classes ──────────────────────────────────────
@@ -730,71 +654,43 @@ ${agentClasses}
730
654
  // by the user's wrangler.jsonc.
731
655
  ${sandboxReExports}
732
656
 
733
- // ─── Worker Fetch Handler ───────────────────────────────────────────────────
734
-
735
- export default {
736
- async fetch(request, env) {
737
- try {
738
- const url = new URL(request.url);
739
- const method = request.method;
740
-
741
- // Health check
742
- if (url.pathname === '/health') {
743
- return new Response(JSON.stringify({ status: 'ok' }), {
744
- headers: { 'content-type': 'application/json' },
745
- });
746
- }
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
+ });
747
673
 
748
- // Agent manifest
749
- if (url.pathname === '/agents' && method === 'GET') {
750
- return new Response(JSON.stringify(manifest), {
751
- headers: { 'content-type': 'application/json' },
752
- });
753
- }
674
+ // ─── App composition ────────────────────────────────────────────────────────
754
675
 
755
- // Webhook agent route: /agents/<name>/<id>
756
- //
757
- // We pre-check method and agent registration here, BEFORE delegating to
758
- // routeAgentRequest. Without this, partyserver (the transitive
759
- // dispatcher behind routeAgentRequest) returns a text/plain
760
- // "Invalid request" 400 for unknown agent namespaces — visibly
761
- // inconsistent with the rest of the API and with the Node target.
762
- // Pre-routing means every error path in the agent route flows through
763
- // the same JSON envelope.
764
- const agentRouteMatch = url.pathname.match(/^\\/agents\\/([^/]+)\\/([^/]+)\\/?$/);
765
- if (agentRouteMatch) {
766
- if (method !== 'POST') {
767
- throw new MethodNotAllowedError({ method, allowed: ['POST'] });
768
- }
769
- const name = decodeURIComponent(agentRouteMatch[1]);
770
- const id = decodeURIComponent(agentRouteMatch[2]);
771
- if (name.trim() === '' || id.trim() === '') {
772
- throw new InvalidRequestError({
773
- reason: 'Webhook URLs must have the shape /agents/<name>/<id> with non-empty segments.',
774
- });
775
- }
776
- if (!webhookAgentNames.has(name)) {
777
- throw new AgentNotFoundError({
778
- name,
779
- available: Array.from(webhookAgentNames),
780
- });
781
- }
782
- // All gating passed. Delegate to the Agents SDK / partyserver, which
783
- // dispatches into the per-agent DO's onRequest → handleAgentRequest.
784
- // routeAgentRequest may still return null for shape mismatches we
785
- // didn't anticipate; treat that as a route_not_found with a hint.
786
- const response = await routeAgentRequest(request, env);
787
- if (response) return response;
788
- throw new RouteNotFoundError({ method, path: url.pathname });
789
- }
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();`}
790
690
 
791
- // Anything else: canonical 404 envelope.
792
- throw new RouteNotFoundError({ method, path: url.pathname });
793
- } catch (err) {
794
- // toHttpResponse logs unknowns via flueLog.error — no extra
795
- // console.error needed at this layer.
796
- return toHttpResponse(err);
797
- }
691
+ export default {
692
+ fetch(request, env, ctx) {
693
+ return app.fetch(request, env, ctx);
798
694
  },
799
695
  };
800
696
  `;
@@ -806,31 +702,40 @@ export default {
806
702
  name: agentClassName(a.name)
807
703
  }));
808
704
  const flueSqliteClasses = flueBindings.map((b) => b.class_name);
809
- const { config: userConfig, path: userConfigPath } = await this.getUserConfig(ctx.outputDir);
705
+ const { config: userConfig, path: userConfigPath } = await this.getUserConfig(ctx.root);
810
706
  if (userConfigPath) console.log(`[flue] Merging with user wrangler config: ${userConfigPath}`);
811
707
  validateUserWranglerConfig(userConfig);
812
708
  const flueMigrations = computeFlueMigrations(flueSqliteClasses, userConfig.migrations);
813
709
  const additions = {
814
- defaultName: path.basename(ctx.outputDir) || "flue-agents",
710
+ defaultName: path.basename(ctx.root) || "flue-agents",
815
711
  main: "_entry.ts",
816
712
  doBindings: flueBindings,
817
713
  migrations: flueMigrations
818
714
  };
819
715
  const sandboxClassNames = detectSandboxBindings(userConfig);
820
716
  if (sandboxClassNames.length > 0) {
821
- assertSandboxPackageInstalled(sandboxClassNames, [ctx.outputDir, ctx.workspaceDir]);
717
+ assertSandboxPackageInstalled(sandboxClassNames, ctx.root);
822
718
  for (const className of sandboxClassNames) console.log(`[flue] Auto-wiring DO binding "${className}" to @cloudflare/sandbox's Sandbox class.`);
823
719
  }
824
720
  const merged = mergeFlueAdditions(userConfig, additions);
825
721
  stripNoisyWranglerDefaults(merged);
826
722
  if (typeof merged.$schema !== "string") merged.$schema = "https://workers.cloudflare.com/schema/wrangler.json";
827
723
  outputs["wrangler.jsonc"] = JSON.stringify(merged, null, 2);
828
- writeDeployRedirectIfMissing(ctx.outputDir);
724
+ writeDeployRedirectIfMissing(ctx.root, ctx.output);
829
725
  return outputs;
830
726
  }
831
727
  };
832
- function agentVarName$1(name) {
833
- return "handler_" + name.replace(/[^a-zA-Z0-9]/g, "_");
728
+ function agentVarName$1(name, index) {
729
+ return `handler_${name.replace(/[^a-zA-Z0-9]/g, "_").replace(/^_+|_+$/g, "") || "agent"}_${index}`;
730
+ }
731
+ const CLOUDFLARE_AGENT_NAME_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
732
+ function validateCloudflareAgentNames(ctx) {
733
+ const invalidAgents = ctx.agents.filter((agent) => !CLOUDFLARE_AGENT_NAME_PATTERN.test(agent.name));
734
+ if (invalidAgents.length === 0) return;
735
+ const invalidList = invalidAgents.map((agent) => {
736
+ return `${path.relative(ctx.root, agent.filePath)} (${agent.name})`;
737
+ }).join(", ");
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}.`);
834
739
  }
835
740
  /**
836
741
  * Convert agent name to a PascalCase DO class name.
@@ -849,31 +754,30 @@ var NodePlugin = class {
849
754
  name = "node";
850
755
  bundle = "esbuild";
851
756
  generateEntryPoint(ctx) {
852
- const { agents, roles } = ctx;
757
+ const { agents, roles, appEntry } = ctx;
853
758
  const rolesJson = JSON.stringify(roles);
854
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));
855
765
  return `
856
766
  // Auto-generated by @flue/sdk build (node)
857
- import { Hono } from 'hono';
858
- import { streamSSE } from 'hono/streaming';
859
767
  import { serve } from '@hono/node-server';
860
- import { Bash, InMemoryFs, MountableFs, ReadWriteFs } from 'just-bash';
768
+ import { Bash, InMemoryFs } from 'just-bash';
861
769
  import {
862
770
  createFlueContext,
863
771
  InMemorySessionStore,
864
772
  bashFactoryToSessionEnv,
865
773
  resolveModel,
866
- parseJsonBody,
867
- validateAgentRequest,
868
- toHttpResponse,
869
- toSseData,
870
- RouteNotFoundError,
774
+ configureFlueRuntime,
775
+ createDefaultFlueApp,
871
776
  } from '@flue/sdk/internal';
872
- import { randomUUID } from 'node:crypto';
777
+ import { createLocalSessionEnv } from '@flue/sdk/node';
873
778
 
874
- ${agents.map((a) => {
875
- return `import ${agentVarName(a.name)} from '${a.filePath.replace(/\\/g, "/")}';`;
876
- }).join("\n")}
779
+ ${agentImports}
780
+ ${appEntry ? `import userApp from '${appEntry.replace(/\\/g, "/")}';` : ""}
877
781
 
878
782
  // ─── Config ─────────────────────────────────────────────────────────────────
879
783
 
@@ -882,13 +786,11 @@ const roles = ${rolesJson};
882
786
  const systemPrompt = '';
883
787
 
884
788
  const handlers = {
885
- ${agents.map((a) => ` ${JSON.stringify(a.name)}: ${agentVarName(a.name)},`).join("\n")}
789
+ ${handlerMapEntries}
886
790
  };
887
791
 
888
- // Set of webhook-accessible agent names. Named distinctly from the
889
- // validateAgentRequest \`webhookAgents\` parameter to keep the call site
890
- // below readable.
891
- const webhookAgentSet = new Set(${JSON.stringify(webhookAgents.map((a) => a.name))});
792
+ // Webhook-accessible agent names.
793
+ const webhookAgentNames = ${webhookNames};
892
794
 
893
795
  // When the CLI starts this server via \`flue run\`, it sets FLUE_MODE=local.
894
796
  // In local mode the HTTP route accepts any registered agent (including
@@ -897,11 +799,6 @@ const webhookAgentSet = new Set(${JSON.stringify(webhookAgents.map((a) => a.name
897
799
  // agents that the user only intended to invoke from their CI pipeline.
898
800
  const isLocalMode = process.env.FLUE_MODE === 'local';
899
801
 
900
- const manifest = ${JSON.stringify({ agents: agents.map((a) => ({
901
- name: a.name,
902
- triggers: a.triggers
903
- })) }, null, 2)};
904
-
905
802
  // ─── Sandbox Environments ───────────────────────────────────────────────────
906
803
 
907
804
  /**
@@ -918,28 +815,25 @@ async function createDefaultEnv() {
918
815
  }
919
816
 
920
817
  /**
921
- * Create a local sandbox backed by the host filesystem.
922
- * 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.
923
823
  */
924
824
  async function createLocalEnv() {
925
- const rwfs = new ReadWriteFs({ root: process.cwd() });
926
- const fs = new MountableFs({ base: new InMemoryFs() });
927
- fs.mount('/workspace', rwfs);
928
- return bashFactoryToSessionEnv(() => new Bash({
929
- fs,
930
- cwd: '/workspace',
931
- network: { dangerouslyAllowFullInternetAccess: true },
932
- }));
825
+ return createLocalSessionEnv();
933
826
  }
934
827
 
935
828
  // Default persistence store for Node — in-memory, process lifetime.
936
829
  const defaultStore = new InMemorySessionStore();
937
830
 
938
- function createContextForRequest(id, payload) {
831
+ function createContextForRequest(id, payload, req) {
939
832
  return createFlueContext({
940
833
  id,
941
834
  payload,
942
835
  env: process.env,
836
+ req,
943
837
  agentConfig: {
944
838
  systemPrompt, skills, roles, model: undefined, resolveModel,
945
839
  },
@@ -949,130 +843,49 @@ function createContextForRequest(id, payload) {
949
843
  });
950
844
  }
951
845
 
952
- // ─── Server ─────────────────────────────────────────────────────────────────
953
-
954
- const app = new Hono();
955
-
956
- app.get('/health', (c) => c.json({ status: 'ok' }));
957
- app.get('/agents', (c) => c.json(manifest));
958
-
959
- // Catch any method on the agent route so non-POSTs become 405 (instead of
960
- // Hono's default 404 for unmatched method). Throws are translated by the
961
- // onError handler into the canonical error envelope.
962
- app.all('/agents/:name/:id', async (c) => {
963
- const name = c.req.param('name');
964
- const id = c.req.param('id');
965
-
966
- // Validate method, name shape, registration, webhook-accessibility.
967
- // Throws FlueHttpError on any failure; caught by app.onError below.
968
- validateAgentRequest({
969
- method: c.req.method,
970
- name,
971
- id,
972
- registeredAgents: Object.keys(handlers),
973
- webhookAgents: Array.from(webhookAgentSet),
974
- allowNonWebhook: isLocalMode,
975
- });
976
-
977
- const handler = handlers[name];
978
-
979
- // Parse the request body. Throws on invalid Content-Type or malformed JSON;
980
- // returns {} for genuinely empty bodies (so no-payload agents still work).
981
- const payload = await parseJsonBody(c.req.raw);
982
-
983
- const accept = c.req.header('accept') || '';
984
- const isWebhook = c.req.header('x-webhook') === 'true';
985
- const isSSE = accept.includes('text/event-stream') && !isWebhook;
986
-
987
- // Fire-and-forget (webhook mode)
988
- if (isWebhook) {
989
- const requestId = randomUUID();
990
- const ctx = createContextForRequest(id, payload);
991
- handler(ctx).then(
992
- (result) => {
993
- ctx.setEventCallback(undefined);
994
- console.log('[flue] Webhook handler complete:', name, result !== undefined ? JSON.stringify(result) : '(no return)');
995
- },
996
- (err) => {
997
- ctx.setEventCallback(undefined);
998
- console.error('[flue] Webhook handler error:', name, err);
999
- },
1000
- );
1001
- return c.json({ status: 'accepted', requestId }, 202);
1002
- }
1003
-
1004
- // SSE streaming mode. Two error regimes meet here:
1005
- // - Pre-stream errors (validation, body parsing, agent lookup) have
1006
- // already thrown above and are rendered as plain HTTP responses by
1007
- // app.onError — headers haven't been sent yet, so this works.
1008
- // - Errors during agent execution surface as in-stream \`error\` events
1009
- // with the canonical envelope (via toSseData), since by then the
1010
- // 200 + text/event-stream headers are already on the wire.
1011
- if (isSSE) {
1012
- return streamSSE(c, async (stream) => {
1013
- let eventId = 0;
1014
- let isIdle = false;
1015
- const ctx = createContextForRequest(id, payload);
1016
- ctx.setEventCallback((event) => {
1017
- if (event.type === 'idle') isIdle = true;
1018
- stream.writeSSE({ data: JSON.stringify(event), event: event.type, id: String(eventId++) }).catch(() => {});
1019
- });
1020
-
1021
- try {
1022
- const result = await handler(ctx);
1023
- if (!isIdle) {
1024
- const idle = { type: 'idle' };
1025
- await stream.writeSSE({ data: JSON.stringify(idle), event: 'idle', id: String(eventId++) });
1026
- }
1027
- await stream.writeSSE({
1028
- data: JSON.stringify({ type: 'result', data: result !== undefined ? result : null }),
1029
- event: 'result',
1030
- id: String(eventId++),
1031
- });
1032
- } catch (err) {
1033
- await stream.writeSSE({
1034
- data: toSseData(err),
1035
- event: 'error',
1036
- id: String(eventId++),
1037
- });
1038
- if (!isIdle) {
1039
- const idle = { type: 'idle' };
1040
- await stream.writeSSE({ data: JSON.stringify(idle), event: 'idle', id: String(eventId++) });
1041
- }
1042
- } finally {
1043
- ctx.setEventCallback(undefined);
1044
- }
1045
- });
1046
- }
1047
-
1048
- // Sync mode (default). Errors propagate to app.onError.
1049
- const ctx = createContextForRequest(id, payload);
1050
- try {
1051
- const result = await handler(ctx);
1052
- return c.json({ result: result !== undefined ? result : null });
1053
- } finally {
1054
- ctx.setEventCallback(undefined);
1055
- }
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,
1056
860
  });
1057
861
 
1058
- // 404 handler fires for any URL that didn't match a registered route.
1059
- app.notFound((c) => {
1060
- // Throw rather than return so the onError handler is the single source of
1061
- // truth for error-envelope shaping.
1062
- throw new RouteNotFoundError({ method: c.req.method, path: new URL(c.req.url).pathname });
1063
- });
862
+ // ─── App composition ────────────────────────────────────────────────────────
1064
863
 
1065
- // Single-source-of-truth error renderer. Every thrown FlueError (and every
1066
- // thrown unknown) is converted to the canonical JSON envelope here.
1067
- // toHttpResponse takes care of logging unknowns no extra console.error
1068
- // needed at this layer.
1069
- 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();`}
1070
878
 
1071
879
  // ─── Start ──────────────────────────────────────────────────────────────────
1072
880
 
1073
881
  const port = parseInt(process.env.PORT || '3000', 10);
1074
882
 
1075
- 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
+ });
1076
889
  console.log('[flue] Server listening on http://localhost:' + port);
1077
890
  if (isLocalMode) {
1078
891
  console.log('[flue] Mode: local (all agents invokable, including trigger-less)');
@@ -1093,68 +906,163 @@ process.on('SIGTERM', () => { server.close(); process.exit(0); });
1093
906
  };
1094
907
  }
1095
908
  };
1096
- function agentVarName(name) {
1097
- return "handler_" + name.replace(/[^a-zA-Z0-9]/g, "_");
909
+ function agentVarName(name, index) {
910
+ return `handler_${name.replace(/[^a-zA-Z0-9]/g, "_").replace(/^_+|_+$/g, "") || "agent"}_${index}`;
1098
911
  }
1099
912
 
1100
913
  //#endregion
1101
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
+ }
1102
1002
  /**
1103
- * 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:
1008
+ *
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/`.
1104
1013
  *
1105
- * `options.workspaceDir` is treated as an explicit workspace root — the directory
1106
- * directly containing agents/ and roles/. No .flue/ waterfall is performed here;
1107
- * callers that want waterfall behavior (e.g. the CLI when --workspace is omitted)
1108
- * should use `resolveWorkspaceFromCwd` first.
1014
+ * Build output lands in `options.output` (defaults to `<root>/dist`).
1109
1015
  *
1110
1016
  * AGENTS.md and .agents/skills/ are NOT bundled — discovered at runtime from session cwd.
1111
1017
  */
1112
1018
  async function build(options) {
1113
- const workspaceDir = path.resolve(options.workspaceDir);
1114
- const outputDir = path.resolve(options.outputDir);
1019
+ const root = path.resolve(options.root);
1020
+ const output = path.resolve(options.output ?? path.join(root, "dist"));
1115
1021
  const plugin = resolvePlugin(options);
1116
- console.log(`[flue] Building workspace: ${workspaceDir}`);
1117
- 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}`);
1118
1026
  console.log(`[flue] Target: ${plugin.name}`);
1119
- const roles = discoverRoles(workspaceDir);
1120
- const agents = discoverAgents(workspaceDir);
1121
- 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}`);
1122
1032
  const webhookAgents = agents.filter((a) => a.triggers.webhook);
1123
- const cronAgents = agents.filter((a) => a.triggers.cron);
1124
- const triggerlessAgents = agents.filter((a) => !a.triggers.webhook && !a.triggers.cron);
1033
+ const triggerlessAgents = agents.filter((a) => !a.triggers.webhook);
1125
1034
  console.log(`[flue] Found ${Object.keys(roles).length} role(s): ${Object.keys(roles).join(", ") || "(none)"}`);
1126
1035
  console.log(`[flue] Found ${agents.length} agent(s): ${agents.map((a) => a.name).join(", ")}`);
1127
1036
  if (webhookAgents.length > 0) console.log(`[flue] Webhook agents: ${webhookAgents.map((a) => a.name).join(", ")}`);
1128
- if (cronAgents.length > 0) console.log(`[flue] Cron agents (manifest only): ${cronAgents.map((a) => `${a.name} (${a.triggers.cron})`).join(", ")}`);
1129
1037
  if (triggerlessAgents.length > 0) console.log(`[flue] CLI-only agents (no HTTP route in deployed build): ${triggerlessAgents.map((a) => a.name).join(", ")}`);
1130
1038
  console.log(`[flue] AGENTS.md and .agents/skills/ will be discovered at runtime from session cwd`);
1131
- const distDir = path.join(outputDir, "dist");
1132
- fs.mkdirSync(distDir, { recursive: true });
1039
+ fs.mkdirSync(output, { recursive: true });
1133
1040
  const manifest = { agents: agents.map((a) => ({
1134
1041
  name: a.name,
1135
1042
  triggers: a.triggers
1136
1043
  })) };
1137
- const manifestPath = path.join(distDir, "manifest.json");
1044
+ const manifestPath = path.join(output, "manifest.json");
1138
1045
  fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), "utf-8");
1139
1046
  console.log(`[flue] Generated: ${manifestPath}`);
1140
1047
  const ctx = {
1141
1048
  agents,
1142
1049
  roles,
1143
- workspaceDir,
1144
- outputDir,
1050
+ root,
1051
+ output,
1052
+ appEntry,
1145
1053
  options
1146
1054
  };
1147
1055
  const serverCode = await plugin.generateEntryPoint(ctx);
1148
1056
  const bundleStrategy = plugin.bundle ?? "esbuild";
1149
1057
  let anyChanged = false;
1150
1058
  if (bundleStrategy === "esbuild") {
1151
- const entryPath = path.join(distDir, "_entry_server.ts");
1152
- const outPath = path.join(distDir, "server.mjs");
1059
+ const entryPath = path.join(output, "_entry_server.ts");
1060
+ const outPath = path.join(output, "server.mjs");
1153
1061
  fs.writeFileSync(entryPath, serverCode, "utf-8");
1154
1062
  try {
1155
- const nodePathsSet = collectNodePaths(workspaceDir);
1063
+ const nodePathsSet = collectNodePaths(root);
1156
1064
  const { external: pluginExternal = [], ...pluginEsbuildOpts } = plugin.esbuildOptions ? plugin.esbuildOptions(ctx) : {};
1157
- const userExternals = getUserExternals(workspaceDir);
1065
+ const userExternals = getUserExternals(root);
1158
1066
  await esbuild.build({
1159
1067
  entryPoints: [entryPath],
1160
1068
  bundle: true,
@@ -1180,7 +1088,7 @@ async function build(options) {
1180
1088
  }
1181
1089
  } else if (bundleStrategy === "none") {
1182
1090
  if (!plugin.entryFilename) throw new Error(`[flue] Plugin "${plugin.name}" set bundle: 'none' but did not provide entryFilename.`);
1183
- const outPath = path.join(distDir, plugin.entryFilename);
1091
+ const outPath = path.join(output, plugin.entryFilename);
1184
1092
  if (!fs.existsSync(outPath) || fs.readFileSync(outPath, "utf-8") !== serverCode) {
1185
1093
  fs.writeFileSync(outPath, serverCode, "utf-8");
1186
1094
  console.log(`[flue] Wrote entry: ${outPath} (no bundle — downstream tool handles it)`);
@@ -1190,7 +1098,7 @@ async function build(options) {
1190
1098
  if (plugin.additionalOutputs) {
1191
1099
  const outputs = await plugin.additionalOutputs(ctx);
1192
1100
  for (const [filename, content] of Object.entries(outputs)) {
1193
- const filePath = path.join(distDir, filename);
1101
+ const filePath = path.join(output, filename);
1194
1102
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
1195
1103
  if (!fs.existsSync(filePath) || fs.readFileSync(filePath, "utf-8") !== content) {
1196
1104
  fs.writeFileSync(filePath, content, "utf-8");
@@ -1199,7 +1107,7 @@ async function build(options) {
1199
1107
  }
1200
1108
  }
1201
1109
  }
1202
- console.log(`[flue] Build complete. Output: ${distDir}`);
1110
+ console.log(`[flue] Build complete. Output: ${output}`);
1203
1111
  return { changed: anyChanged };
1204
1112
  }
1205
1113
  function resolvePlugin(options) {
@@ -1212,26 +1120,25 @@ function resolvePlugin(options) {
1212
1120
  }
1213
1121
  }
1214
1122
  /**
1215
- * Resolve a Flue workspace directory from the current working directory,
1216
- * using the two-layout convention. Intended for the CLI when `--workspace` is
1217
- * not provided — callers that pass an explicit workspace path should skip this
1218
- * 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).
1219
1125
  *
1220
- * Two supported layouts, checked in order:
1221
- * 1. `<cwd>/.flue/` use this when Flue is embedded in an existing project.
1222
- * 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).
1223
1130
  *
1224
- * If `.flue/` exists, it wins unconditionally no mixing with the bare layout.
1225
- * 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`).
1226
1134
  */
1227
- function resolveWorkspaceFromCwd(cwd) {
1228
- const dotFlue = path.join(cwd, ".flue");
1135
+ function resolveSourceRoot(root) {
1136
+ const dotFlue = path.join(root, ".flue");
1229
1137
  if (fs.existsSync(dotFlue)) return dotFlue;
1230
- if (fs.existsSync(path.join(cwd, "agents"))) return cwd;
1231
- return null;
1138
+ return root;
1232
1139
  }
1233
- function discoverRoles(workspaceRoot) {
1234
- const rolesDir = path.join(workspaceRoot, "roles");
1140
+ function discoverRoles(sourceRoot) {
1141
+ const rolesDir = path.join(sourceRoot, "roles");
1235
1142
  if (!fs.existsSync(rolesDir)) return {};
1236
1143
  const roles = {};
1237
1144
  for (const entry of fs.readdirSync(rolesDir)) {
@@ -1240,21 +1147,23 @@ function discoverRoles(workspaceRoot) {
1240
1147
  const content = fs.readFileSync(filePath, "utf-8");
1241
1148
  const name = entry.replace(/\.(md|markdown)$/i, "");
1242
1149
  const parsed = parseFrontmatterFile(content, name);
1150
+ const thinkingLevel = parseThinkingLevel(parsed.frontmatter.thinkingLevel, `role "${name}" frontmatter`);
1243
1151
  roles[name] = {
1244
1152
  name,
1245
1153
  description: parsed.description,
1246
1154
  instructions: parsed.body,
1247
- model: parsed.frontmatter.model
1155
+ model: parsed.frontmatter.model,
1156
+ thinkingLevel
1248
1157
  };
1249
1158
  }
1250
1159
  return roles;
1251
1160
  }
1252
- function discoverAgents(workspaceRoot) {
1253
- const agentsDir = path.join(workspaceRoot, "agents");
1161
+ function discoverAgents(sourceRoot) {
1162
+ const agentsDir = path.join(sourceRoot, "agents");
1254
1163
  if (!fs.existsSync(agentsDir)) return [];
1255
1164
  return fs.readdirSync(agentsDir).filter((f) => /\.(ts|js|mts|mjs)$/.test(f)).map((f) => {
1256
1165
  const filePath = path.join(agentsDir, f);
1257
- const triggers = parseTriggers(filePath);
1166
+ const { triggers } = parseAgentFile(filePath);
1258
1167
  return {
1259
1168
  name: f.replace(/\.(ts|js|mts|mjs)$/, ""),
1260
1169
  filePath,
@@ -1262,21 +1171,29 @@ function discoverAgents(workspaceRoot) {
1262
1171
  };
1263
1172
  });
1264
1173
  }
1265
- /** Extract trigger config via regex. Only triggers are parsed at build time (needed for routing). */
1266
- function parseTriggers(filePath) {
1267
- const source = fs.readFileSync(filePath, "utf-8");
1268
- const result = {};
1269
- const triggersExportMatch = source.match(/export\s+const\s+triggers\s*=\s*\{([^}]*)\}/);
1270
- if (!triggersExportMatch) return result;
1271
- const triggersBlock = triggersExportMatch[1] ?? "";
1272
- if (/webhook\s*:\s*true/.test(triggersBlock)) result.webhook = true;
1273
- const cronMatch = triggersBlock.match(/cron\s*:\s*['"]([^'"]+)['"]/);
1274
- if (cronMatch?.[1]) result.cron = cronMatch[1];
1275
- return result;
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
+ }
1276
1193
  }
1277
1194
  /** Externalize user's direct deps (bare name + subpath wildcard). */
1278
- function getUserExternals(workspaceDir) {
1279
- const pkgPath = packageUpSync({ cwd: workspaceDir });
1195
+ function getUserExternals(root) {
1196
+ const pkgPath = packageUpSync({ cwd: root });
1280
1197
  if (!pkgPath) return [];
1281
1198
  try {
1282
1199
  const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
@@ -1289,9 +1206,9 @@ function getUserExternals(workspaceDir) {
1289
1206
  return [];
1290
1207
  }
1291
1208
  }
1292
- function collectNodePaths(workspaceDir) {
1209
+ function collectNodePaths(root) {
1293
1210
  const nodePathsSet = /* @__PURE__ */ new Set();
1294
- for (const startDir of [workspaceDir, getSDKDir()]) {
1211
+ for (const startDir of [root, getSDKDir()]) {
1295
1212
  let dir = startDir;
1296
1213
  while (dir !== path.dirname(dir)) {
1297
1214
  const nm = path.join(dir, "node_modules");
@@ -1314,7 +1231,7 @@ function getSDKDir() {
1314
1231
  /**
1315
1232
  * Flue dev server.
1316
1233
  *
1317
- * Watches the user's workspace, rebuilds on file changes, and reloads the
1234
+ * Watches the project root, rebuilds on file changes, and reloads the
1318
1235
  * underlying server. Distinct from `flue run`: dev is the long-running,
1319
1236
  * edit-and-iterate command, while `flue run` is the one-shot
1320
1237
  * production-style invoker (build → run → exit).
@@ -1325,7 +1242,7 @@ function getSDKDir() {
1325
1242
  * what they each provide downstream is fundamentally different:
1326
1243
  *
1327
1244
  * - **Node** has no host bundler. Our esbuild pass produces the final
1328
- * `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
1329
1246
  * the child Node process. Sub-second restart is fine.
1330
1247
  *
1331
1248
  * - **Cloudflare** uses Wrangler's bundler (the same one `wrangler dev` and
@@ -1354,18 +1271,18 @@ const DEFAULT_DEV_PORT = 3583;
1354
1271
  * — the user is editing code, after all, and we want to recover when they fix it.
1355
1272
  */
1356
1273
  async function dev(options) {
1357
- const workspaceDir = path.resolve(options.workspaceDir);
1358
- const outputDir = path.resolve(options.outputDir);
1274
+ const root = path.resolve(options.root);
1275
+ const output = path.resolve(options.output ?? path.join(root, "dist"));
1359
1276
  const port = options.port ?? DEFAULT_DEV_PORT;
1360
- const envFiles = resolveEnvFiles(options.envFiles, outputDir);
1277
+ const envFiles = resolveEnvFiles(options.envFiles, root);
1361
1278
  for (const f of envFiles) console.error(`[flue] Loading env from: ${f}`);
1362
1279
  const buildOptions = {
1363
- workspaceDir,
1364
- outputDir,
1280
+ root,
1281
+ output,
1365
1282
  target: options.target
1366
1283
  };
1367
1284
  console.error(`[flue] Starting dev server (target: ${options.target})`);
1368
- console.error(`[flue] Watching: ${workspaceDir}`);
1285
+ console.error(`[flue] Watching: ${root}`);
1369
1286
  console.error(`[flue] Building...`);
1370
1287
  const initialStart = Date.now();
1371
1288
  try {
@@ -1375,18 +1292,19 @@ async function dev(options) {
1375
1292
  }
1376
1293
  console.error(`[flue] Built in ${Date.now() - initialStart}ms`);
1377
1294
  const reloader = options.target === "node" ? new NodeReloader({
1378
- outputDir,
1295
+ root,
1296
+ output,
1379
1297
  port,
1380
1298
  envFiles
1381
1299
  }) : await createCloudflareReloader({
1382
- outputDir,
1300
+ output,
1383
1301
  port,
1384
1302
  envFiles
1385
1303
  });
1386
1304
  await reloader.start();
1387
1305
  if (reloader.url) {
1388
1306
  console.error(`[flue] Server: ${reloader.url}`);
1389
- const exampleAgent = pickExampleAgentName(outputDir, workspaceDir);
1307
+ const exampleAgent = pickExampleAgentName(output, root);
1390
1308
  if (exampleAgent) {
1391
1309
  console.error(`[flue] Try: curl -X POST ${reloader.url}/agents/${exampleAgent}/test-1 \\`);
1392
1310
  console.error(` -H 'Content-Type: application/json' -d '{}'`);
@@ -1396,8 +1314,8 @@ async function dev(options) {
1396
1314
  const rebuilder = createRebuilder(buildOptions, reloader);
1397
1315
  const envFileSet = new Set(envFiles);
1398
1316
  const watcher = createWatcher({
1399
- workspaceDir,
1400
- outputDir,
1317
+ root,
1318
+ output,
1401
1319
  target: options.target,
1402
1320
  envFiles,
1403
1321
  onChange: (relPath) => {
@@ -1471,39 +1389,46 @@ function createRebuilder(buildOptions, reloader) {
1471
1389
  } };
1472
1390
  }
1473
1391
  /**
1474
- * Watch the workspace for changes. Uses `fs.watch` recursive (Node 20+).
1392
+ * Watch the root for changes. Uses `fs.watch` recursive (Node 20+).
1475
1393
  *
1476
1394
  * Watched roots:
1477
- * - `<workspaceDir>` — agents/, roles/, AGENTS.md, .agents/skills/.
1478
- * - 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`),
1479
1399
  * since changes there require a worker restart.
1480
1400
  *
1481
1401
  * Ignored:
1482
- * - `dist/`, `node_modules/`, `.git/`, `.turbo/`
1483
- * - dotfiles other than the ones we explicitly care about (AGENTS.md is
1484
- * not a dotfile, so it's fine)
1485
- * - 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
1486
1409
  */
1487
1410
  function createWatcher(options) {
1488
- const { workspaceDir, outputDir, target, envFiles, onChange } = options;
1411
+ const { root, output, target, envFiles, onChange } = options;
1489
1412
  const watchers = [];
1413
+ const outputRelToRoot = path.relative(root, output).split(path.sep).join("/");
1490
1414
  const isIgnoredPath = (relPath) => {
1491
- 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("/");
1492
1419
  for (const part of parts) {
1493
1420
  if (part === "node_modules") return true;
1494
- if (part === "dist") return true;
1495
1421
  if (part === ".git") return true;
1496
1422
  if (part === ".turbo") return true;
1497
1423
  }
1498
1424
  const base = parts[parts.length - 1] ?? "";
1499
1425
  if (!base) return true;
1500
- if (base.startsWith(".") && base !== ".flueignore") return true;
1426
+ if (base.startsWith(".")) return true;
1501
1427
  if (base.endsWith("~") || base.endsWith(".swp") || base.endsWith(".swx")) return true;
1502
- if (base === ".DS_Store") return true;
1503
1428
  return false;
1504
1429
  };
1505
1430
  try {
1506
- const w = fs.watch(workspaceDir, { recursive: true }, (_event, filename) => {
1431
+ const w = fs.watch(root, { recursive: true }, (_event, filename) => {
1507
1432
  if (!filename) return;
1508
1433
  const rel = filename.toString();
1509
1434
  if (isIgnoredPath(rel)) return;
@@ -1511,14 +1436,14 @@ function createWatcher(options) {
1511
1436
  });
1512
1437
  watchers.push(w);
1513
1438
  } catch (err) {
1514
- 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)}`);
1515
1440
  }
1516
1441
  if (target === "cloudflare") for (const cfgName of [
1517
1442
  "wrangler.jsonc",
1518
1443
  "wrangler.json",
1519
1444
  "wrangler.toml"
1520
1445
  ]) {
1521
- const cfgPath = path.join(outputDir, cfgName);
1446
+ const cfgPath = path.join(root, cfgName);
1522
1447
  if (!fs.existsSync(cfgPath)) continue;
1523
1448
  try {
1524
1449
  const w = fs.watch(cfgPath, () => onChange(cfgName));
@@ -1538,15 +1463,15 @@ function createWatcher(options) {
1538
1463
  var NodeReloader = class {
1539
1464
  child = null;
1540
1465
  serverPath;
1541
- outputDir;
1466
+ root;
1542
1467
  port;
1543
1468
  envFiles;
1544
1469
  url;
1545
1470
  constructor(opts) {
1546
- this.outputDir = opts.outputDir;
1471
+ this.root = opts.root;
1547
1472
  this.port = opts.port;
1548
1473
  this.envFiles = opts.envFiles;
1549
- this.serverPath = path.join(this.outputDir, "dist", "server.mjs");
1474
+ this.serverPath = path.join(opts.output, "server.mjs");
1550
1475
  this.url = `http://localhost:${this.port}`;
1551
1476
  }
1552
1477
  async start() {
@@ -1577,7 +1502,7 @@ var NodeReloader = class {
1577
1502
  "pipe",
1578
1503
  "pipe"
1579
1504
  ],
1580
- cwd: this.outputDir,
1505
+ cwd: this.root,
1581
1506
  env: {
1582
1507
  ...fromFiles,
1583
1508
  ...process.env,
@@ -1602,10 +1527,6 @@ var NodeReloader = class {
1602
1527
  if (code !== 0 && code !== null) console.error(`[flue] Node server exited unexpectedly (code=${code}, signal=${signal ?? "none"})`);
1603
1528
  }
1604
1529
  });
1605
- if (!await waitForHealth(this.url, 15e3)) {
1606
- await this.killChild();
1607
- throw new Error("Node server did not become ready within 15s");
1608
- }
1609
1530
  }
1610
1531
  async killChild() {
1611
1532
  const child = this.child;
@@ -1659,7 +1580,6 @@ Underlying error: ${err instanceof Error ? err.message : String(err)}`);
1659
1580
  var CloudflareReloader = class {
1660
1581
  worker = null;
1661
1582
  wrangler;
1662
- outputDir;
1663
1583
  port;
1664
1584
  configPath;
1665
1585
  envFiles;
@@ -1689,10 +1609,9 @@ var CloudflareReloader = class {
1689
1609
  url;
1690
1610
  constructor(wrangler, opts) {
1691
1611
  this.wrangler = wrangler;
1692
- this.outputDir = opts.outputDir;
1693
1612
  this.port = opts.port;
1694
1613
  this.envFiles = opts.envFiles;
1695
- this.configPath = path.join(this.outputDir, "dist", "wrangler.jsonc");
1614
+ this.configPath = path.join(opts.output, "wrangler.jsonc");
1696
1615
  this.containerBuildId = randomUUID().slice(0, 8);
1697
1616
  }
1698
1617
  async start() {
@@ -1711,19 +1630,26 @@ var CloudflareReloader = class {
1711
1630
  * so we have to re-parse them. (Plain body edits redo a tiny amount
1712
1631
  * of work but the rebuild is cheap and idempotent.)
1713
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.
1714
1639
  * - Changes to the user's `wrangler.jsonc` — affects the merged config.
1715
1640
  *
1716
1641
  * Notes we explicitly DO ignore for rebuild purposes (wrangler handles
1717
- * them): edits to imported source files outside of `agents/`/`roles/`,
1718
- * AGENTS.md, and `.agents/skills/` (those are runtime-discovered, not
1719
- * 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).
1720
1645
  */
1721
1646
  shouldRebuildOn(relPath) {
1722
1647
  if (this.envFiles.includes(relPath)) return true;
1723
1648
  const normalized = relPath.replace(/\\/g, "/");
1724
1649
  if (normalized === "wrangler.jsonc" || normalized === "wrangler.json" || normalized === "wrangler.toml") return true;
1725
- if (normalized.startsWith("agents/")) return true;
1726
- 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;
1727
1653
  return false;
1728
1654
  }
1729
1655
  async reload(buildChanged) {
@@ -1820,32 +1746,18 @@ function parseEnvFiles(absolutePaths) {
1820
1746
  }
1821
1747
  return merged;
1822
1748
  }
1823
- async function waitForHealth(baseUrl, timeoutMs) {
1824
- const start = Date.now();
1825
- while (Date.now() - start < timeoutMs) {
1826
- try {
1827
- const controller = new AbortController();
1828
- const timeout = setTimeout(() => controller.abort(), 1e3);
1829
- const res = await fetch(`${baseUrl}/health`, { signal: controller.signal });
1830
- clearTimeout(timeout);
1831
- if (res.ok) return true;
1832
- } catch {}
1833
- await new Promise((r) => setTimeout(r, 200));
1834
- }
1835
- return false;
1836
- }
1837
1749
  /**
1838
1750
  * Pick a webhook agent name to print in the friendly curl example. Falls back
1839
1751
  * to any agent if none have webhook triggers (the example would 404 on the
1840
1752
  * dev server in that case, but it's still a hint at the URL shape). Reads the
1841
- * manifest written by the build, with a directory-scan fallback in case the
1842
- * 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.
1843
1755
  *
1844
1756
  * Best-effort — silently returns null if anything goes wrong.
1845
1757
  */
1846
- function pickExampleAgentName(outputDir, workspaceDir) {
1758
+ function pickExampleAgentName(output, root) {
1847
1759
  try {
1848
- const manifestPath = path.join(outputDir, "dist", "manifest.json");
1760
+ const manifestPath = path.join(output, "manifest.json");
1849
1761
  if (fs.existsSync(manifestPath)) {
1850
1762
  const agents = JSON.parse(fs.readFileSync(manifestPath, "utf-8")).agents ?? [];
1851
1763
  const webhook = agents.find((a) => a.triggers?.webhook);
@@ -1854,7 +1766,7 @@ function pickExampleAgentName(outputDir, workspaceDir) {
1854
1766
  }
1855
1767
  } catch {}
1856
1768
  try {
1857
- const agentsDir = path.join(workspaceDir, "agents");
1769
+ const agentsDir = path.join(resolveSourceRoot(root), "agents");
1858
1770
  if (!fs.existsSync(agentsDir)) return null;
1859
1771
  for (const e of fs.readdirSync(agentsDir)) {
1860
1772
  const m = e.match(/^([a-zA-Z0-9_-]+)\.(ts|js|mts|mjs)$/);
@@ -1867,4 +1779,4 @@ function pickExampleAgentName(outputDir, workspaceDir) {
1867
1779
  }
1868
1780
 
1869
1781
  //#endregion
1870
- 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 };