@flue/sdk 0.2.0 → 0.3.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,57 +1,98 @@
1
- import { a as parseFrontmatterFile, n as createTools, t as BUILTIN_TOOL_NAMES } from "./agent-BYG0nVbQ.mjs";
1
+ import { a as parseFrontmatterFile, n as createTools, t as BUILTIN_TOOL_NAMES } from "./agent-BB4lwAd5.mjs";
2
2
  import * as esbuild from "esbuild";
3
3
  import * as fs from "node:fs";
4
4
  import * as path from "node:path";
5
5
  import { packageUpSync } from "package-up";
6
- import { parse } from "jsonc-parser";
6
+ import { spawn } from "node:child_process";
7
7
 
8
8
  //#region src/cloudflare-wrangler-merge.ts
9
9
  /**
10
10
  * Merge Flue's Cloudflare additions into the user's wrangler config.
11
11
  *
12
- * Philosophy: the user's wrangler.jsonc is the source of truth. Flue contributes
12
+ * Philosophy: the user's wrangler config is the source of truth. Flue contributes
13
13
  * the pieces it owns (the Worker entrypoint, its per-agent Durable Object
14
14
  * bindings, the Sandbox DO, the migration tag) and leaves everything else
15
15
  * untouched. The merged result is written to `dist/wrangler.jsonc` so the
16
16
  * deployed Worker sees both.
17
17
  *
18
- * We use `jsonc-parser` (the same library wrangler uses internally) for
19
- * reading. TOML is intentionally unsupported Cloudflare itself recommends
20
- * wrangler.jsonc for new projects, and supporting both formats here would
21
- * double the surface area for no real benefit. Users with wrangler.toml get a
22
- * clear error directing them to convert.
18
+ * We delegate parsing and normalization to wrangler's own `unstable_readConfig`
19
+ * (lazy-imported so Node-only Flue users don't pay for it). This gets us:
20
+ * - Both jsonc and TOML support for free.
21
+ * - Wrangler's own validation diagnostics (clearer errors than ours).
22
+ * - Path normalization: relative paths in fields like `containers[].image`
23
+ * are resolved to absolute paths against the user's config dir before
24
+ * we merge. This is critical because we write the merged config to
25
+ * `dist/wrangler.jsonc` — wrangler resolves relative paths against the
26
+ * config file's own directory, so without normalization a user's
27
+ * `containers[].image: "./Dockerfile"` would resolve to `dist/Dockerfile`
28
+ * after the move and fail to deploy.
29
+ *
30
+ * Flue still owns merge semantics (DO binding de-dup by `name`, migration
31
+ * append-if-tag-absent) and Flue-specific validation (compat date floor,
32
+ * required compat flags) — wrangler doesn't know about those.
23
33
  */
24
34
  /** Minimum compatibility_date Flue supports. */
25
- const MIN_COMPATIBILITY_DATE = "2024-04-03";
35
+ const MIN_COMPATIBILITY_DATE = "2026-04-01";
26
36
  /** compatibility_flag Flue requires for pi-ai's process.env-based API key lookup. */
27
37
  const REQUIRED_COMPAT_FLAG = "nodejs_compat";
28
38
  /**
29
- * Read the user's wrangler config from `outputDir` if present.
39
+ * Read and normalize the user's wrangler config from `outputDir`.
40
+ *
41
+ * Looks for `wrangler.jsonc`, `wrangler.json`, then `wrangler.toml` (jsonc is
42
+ * Cloudflare's recommended format for new projects, but all three work).
43
+ * Returns an empty config if no file is present.
44
+ *
45
+ * Delegates parsing + normalization to wrangler via `unstable_readConfig`. This
46
+ * is async only because wrangler is a lazy import (it's a peer dep — Flue users
47
+ * who only target Node should not pay for resolving it). The wrangler call
48
+ * itself is synchronous under the hood.
49
+ *
50
+ * The returned config has been through wrangler's `normalizeAndValidateConfig`,
51
+ * which:
52
+ * - Resolves relative paths to absolute (notably `containers[].image`).
53
+ * - Fills in defaults (`compatibility_date` if absent, etc.).
54
+ * - Merges `env.*` per-environment overrides.
55
+ * - Throws on validation errors via wrangler's own `UserError`.
30
56
  *
31
- * Looks for `wrangler.jsonc` then `wrangler.json` (in that order jsonc is the
32
- * recommended format). If a `wrangler.toml` is present instead, throws with a
33
- * clear conversion hint. Returns an empty config if no file is present.
57
+ * The verbose / defaulted output is intentionalthe cost is a slightly bigger
58
+ * `dist/wrangler.jsonc` and the benefit is correctness without us reimplementing
59
+ * wrangler's path-resolution logic.
34
60
  */
35
- function readUserWranglerConfig(outputDir) {
36
- const jsoncPath = path.join(outputDir, "wrangler.jsonc");
37
- const jsonPath = path.join(outputDir, "wrangler.json");
38
- const tomlPath = path.join(outputDir, "wrangler.toml");
39
- const foundPath = fs.existsSync(jsoncPath) ? jsoncPath : fs.existsSync(jsonPath) ? jsonPath : null;
40
- if (!foundPath) {
41
- if (fs.existsSync(tomlPath)) throw new Error(`[flue] Found wrangler.toml at ${tomlPath}. Flue only supports wrangler.jsonc (the format Cloudflare recommends for new projects). Convert your config to wrangler.jsonc — you can use any online TOML-to-JSON converter, or copy the fields over by hand.`);
42
- return {
43
- config: {},
44
- path: null
45
- };
61
+ async function readUserWranglerConfig(outputDir) {
62
+ const candidates = [
63
+ "wrangler.jsonc",
64
+ "wrangler.json",
65
+ "wrangler.toml"
66
+ ];
67
+ let foundPath = null;
68
+ for (const name of candidates) {
69
+ const candidate = path.join(outputDir, name);
70
+ if (fs.existsSync(candidate)) {
71
+ foundPath = candidate;
72
+ break;
73
+ }
46
74
  }
47
- const source = fs.readFileSync(foundPath, "utf-8");
48
- const errors = [];
49
- const parsed = parse(source, errors, { allowTrailingComma: true });
50
- if (errors.length > 0) {
51
- const summary = errors.slice(0, 3).map((e) => `offset ${e.offset}: error code ${e.error}`).join("; ");
52
- throw new Error(`[flue] Failed to parse ${foundPath}: ${summary}. Please fix syntax errors in your wrangler config before building.`);
75
+ if (!foundPath) return {
76
+ config: {},
77
+ path: null
78
+ };
79
+ let wrangler;
80
+ try {
81
+ wrangler = await import("wrangler");
82
+ } catch (err) {
83
+ throw new Error(`[flue] Reading the Cloudflare wrangler config requires the "wrangler" package as a peer dependency.
84
+ Install it in your project:
85
+
86
+ npm install --save-dev wrangler
87
+
88
+ Underlying error: ${err instanceof Error ? err.message : String(err)}`);
89
+ }
90
+ let parsed;
91
+ try {
92
+ parsed = wrangler.unstable_readConfig({ config: foundPath }, { hideWarnings: true });
93
+ } catch (err) {
94
+ throw new Error(`[flue] Failed to read ${foundPath}: ${err instanceof Error ? err.message : String(err)}`);
53
95
  }
54
- if (parsed === void 0 || parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error(`[flue] ${foundPath} did not contain a JSON object at the top level. A wrangler config must be an object.`);
55
96
  return {
56
97
  config: parsed,
57
98
  path: foundPath
@@ -65,6 +106,14 @@ function readUserWranglerConfig(outputDir) {
65
106
  * the failure modes when these are wrong (missing nodejs_compat, old
66
107
  * compat_date) produce confusing runtime errors, and surfacing the problem at
67
108
  * build time is much friendlier.
109
+ *
110
+ * Together with `mergeFlueAdditions`, this enforces two invariants on every
111
+ * Flue worker:
112
+ * 1. `nodejs_compat` is in `compatibility_flags` (added if missing).
113
+ * 2. `compatibility_date >= MIN_COMPATIBILITY_DATE` (defaulted if missing).
114
+ *
115
+ * Those invariants are what let `dev.ts` hardcode `nodejsCompatMode: 'v2'`
116
+ * without re-deriving it from the config on every reload.
68
117
  */
69
118
  function validateUserWranglerConfig(config) {
70
119
  if (Array.isArray(config.compatibility_flags)) {
@@ -73,7 +122,7 @@ function validateUserWranglerConfig(config) {
73
122
  if (typeof config.compatibility_date === "string") {
74
123
  const userDate = config.compatibility_date;
75
124
  if (!/^\d{4}-\d{2}-\d{2}$/.test(userDate)) throw new Error(`[flue] Your wrangler config's "compatibility_date" ("${userDate}") is not in YYYY-MM-DD format.`);
76
- if (userDate < MIN_COMPATIBILITY_DATE) throw new Error(`[flue] Your wrangler config's "compatibility_date" is "${userDate}". Flue requires at least "${MIN_COMPATIBILITY_DATE}" for SQLite-backed Durable Object support and nodejs_compat v2. Bump the date (set it to today unless you have a specific reason).`);
125
+ if (userDate < MIN_COMPATIBILITY_DATE) throw new Error(`[flue] Your wrangler config's "compatibility_date" is "${userDate}". Flue requires at least "${MIN_COMPATIBILITY_DATE}" for SQLite-backed Durable Object support, nodejs_compat v2, and AsyncLocalStorage. Bump the date (set it to today unless you have a specific reason).`);
77
126
  }
78
127
  }
79
128
  /**
@@ -84,7 +133,7 @@ function mergeFlueAdditions(userConfig, additions) {
84
133
  const merged = { ...userConfig };
85
134
  merged.main = additions.main;
86
135
  if (typeof merged.name !== "string" || merged.name.length === 0) merged.name = additions.defaultName;
87
- if (typeof merged.compatibility_date !== "string") merged.compatibility_date = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
136
+ if (typeof merged.compatibility_date !== "string") merged.compatibility_date = MIN_COMPATIBILITY_DATE;
88
137
  const existingFlags = Array.isArray(merged.compatibility_flags) ? merged.compatibility_flags.filter((f) => typeof f === "string") : [];
89
138
  if (!existingFlags.includes(REQUIRED_COMPAT_FLAG)) existingFlags.push(REQUIRED_COMPAT_FLAG);
90
139
  merged.compatibility_flags = existingFlags;
@@ -187,9 +236,24 @@ function writeDeployRedirectIfMissing(outputDir) {
187
236
 
188
237
  //#endregion
189
238
  //#region src/build-plugin-cloudflare.ts
239
+ /** Cloudflare build plugin. Produces a Worker + DO entry point with SSE/webhook/sync modes. */
190
240
  var CloudflarePlugin = class {
191
241
  name = "cloudflare";
192
- generateEntryPoint(ctx) {
242
+ bundle = "none";
243
+ entryFilename = "_entry.ts";
244
+ /**
245
+ * Per-build cache of the user's wrangler config. Both `generateEntryPoint`
246
+ * and `additionalOutputs` need it (for sandbox detection + the merge), and
247
+ * a fresh `CloudflarePlugin` instance is constructed for each build (see
248
+ * `resolvePlugin` in build.ts), so the cache is implicitly scoped to a
249
+ * single build.
250
+ */
251
+ userConfigCache;
252
+ async getUserConfig(outputDir) {
253
+ if (!this.userConfigCache) this.userConfigCache = await readUserWranglerConfig(outputDir);
254
+ return this.userConfigCache;
255
+ }
256
+ async generateEntryPoint(ctx) {
193
257
  const { agents, roles } = ctx;
194
258
  const rolesJson = JSON.stringify(roles);
195
259
  const webhookAgents = agents.filter((a) => a.triggers.webhook);
@@ -207,16 +271,29 @@ var CloudflarePlugin = class {
207
271
  async onRequest(request) {
208
272
  return handleAgentRequest(request, this, ${JSON.stringify(a.name)}, ${handlerVar});
209
273
  }
274
+
275
+ async onFiberRecovered(ctx) {
276
+ if (ctx.name?.startsWith('flue:')) {
277
+ return handleFlueFiberRecovered(ctx, this, ${JSON.stringify(a.name)});
278
+ }
279
+ if (typeof super.onFiberRecovered === 'function') {
280
+ return super.onFiberRecovered(ctx);
281
+ }
282
+ }
210
283
  }`;
211
284
  }).join("\n\n");
212
- const { config: userConfig } = readUserWranglerConfig(ctx.outputDir);
285
+ const { config: userConfig } = await this.getUserConfig(ctx.outputDir);
213
286
  return `
214
287
  // Auto-generated by @flue/sdk build (cloudflare)
215
288
  import { Agent, routeAgentRequest } from 'agents';
216
289
  import { Bash, InMemoryFs } from 'just-bash';
217
- import { getModel } from '@mariozechner/pi-ai';
218
- import { createFlueContext, InMemorySessionStore, bashToSessionEnv } from '@flue/sdk/internal';
219
- import { setCloudflareContext, clearCloudflareContext, cfSandboxToSessionEnv } from '@flue/sdk/cloudflare';
290
+ import {
291
+ createFlueContext,
292
+ InMemorySessionStore,
293
+ bashFactoryToSessionEnv,
294
+ resolveModel,
295
+ } from '@flue/sdk/internal';
296
+ import { runWithCloudflareContext, cfSandboxToSessionEnv } from '@flue/sdk/cloudflare';
220
297
 
221
298
  ${agentImports}
222
299
 
@@ -230,42 +307,21 @@ const manifest = ${manifest};
230
307
  // ─── Infrastructure ─────────────────────────────────────────────────────────
231
308
 
232
309
  // No build-time model default. The user sets model at runtime via
233
- // \`init({ model: "provider/model-id" })\` for a session default, or via
310
+ // \`init({ model: "provider/model-id" })\` for an agent default, or via
234
311
  // \`{ model: "provider/model-id" }\` on any individual prompt/skill/task call.
235
312
  const model = undefined;
236
313
 
237
- function resolveModel(modelString) {
238
- const slash = modelString.indexOf('/');
239
- if (slash === -1) {
240
- throw new Error(
241
- '[flue] Invalid model "' + modelString + '". ' +
242
- 'Use the "provider/model-id" format (e.g. "anthropic/claude-haiku-4-5").'
243
- );
244
- }
245
- const provider = modelString.slice(0, slash);
246
- const modelId = modelString.slice(slash + 1);
247
- const resolved = getModel(provider, modelId);
248
- if (!resolved) {
249
- throw new Error(
250
- '[flue] Unknown model "' + modelString + '". ' +
251
- 'Provider "' + provider + '" / model id "' + modelId + '" ' +
252
- 'is not registered with @mariozechner/pi-ai.'
253
- );
254
- }
255
- return resolved;
256
- }
257
-
258
314
  // ─── Sandbox Environments ───────────────────────────────────────────────────
259
315
 
260
316
  /**
261
317
  * Create an empty in-memory sandbox (default).
262
318
  */
263
319
  async function createDefaultEnv() {
264
- const bash = new Bash({
265
- fs: new InMemoryFs(),
320
+ const fs = new InMemoryFs();
321
+ return bashFactoryToSessionEnv(() => new Bash({
322
+ fs,
266
323
  network: { dangerouslyAllowFullInternetAccess: true },
267
- });
268
- return bashToSessionEnv(bash);
324
+ }));
269
325
  }
270
326
 
271
327
  /**
@@ -274,7 +330,7 @@ async function createDefaultEnv() {
274
330
  async function createLocalEnv() {
275
331
  throw new Error(
276
332
  "[flue] 'local' sandbox is not supported on Cloudflare Workers. " +
277
- "Use the default empty sandbox, pass a custom Bash instance, " +
333
+ "Use the default empty sandbox, pass a BashFactory, " +
278
334
  "or pass a sandbox instance (from any SDK — e.g. @cloudflare/sandbox " +
279
335
  "or a Flue connector) to init({ sandbox })."
280
336
  );
@@ -326,14 +382,14 @@ function createDOStore(sql) {
326
382
  };
327
383
  }
328
384
 
329
- function createContextForRequest(sessionId, payload, doInstance) {
385
+ function createContextForRequest(id, payload, doInstance) {
330
386
  // Use DO SQLite storage by default, fall back to in-memory
331
387
  const defaultStore = doInstance?.ctx?.storage?.sql
332
388
  ? createDOStore(doInstance.ctx.storage.sql)
333
389
  : memoryStore;
334
390
 
335
391
  return createFlueContext({
336
- sessionId,
392
+ id,
337
393
  payload,
338
394
  env: doInstance?.env ?? {},
339
395
  agentConfig: {
@@ -346,11 +402,66 @@ function createContextForRequest(sessionId, payload, doInstance) {
346
402
  });
347
403
  }
348
404
 
405
+ function runWithInstanceContext(doInstance, fn) {
406
+ return runWithCloudflareContext(
407
+ { env: doInstance.env, agentInstance: doInstance, storage: doInstance.ctx.storage },
408
+ fn,
409
+ );
410
+ }
411
+
412
+ function assertAgentsDurabilityApi(doInstance, method) {
413
+ if (typeof doInstance[method] !== 'function') {
414
+ throw new Error(
415
+ '[flue] The installed "agents" package does not provide the required Cloudflare Agents SDK method "' +
416
+ method +
417
+ '". Install or upgrade the "agents" package in your project.',
418
+ );
419
+ }
420
+ }
421
+
422
+ function runHandlerWithKeepAlive(doInstance, ctx, handler) {
423
+ return runWithInstanceContext(doInstance, () => {
424
+ assertAgentsDurabilityApi(doInstance, 'keepAliveWhile');
425
+ return doInstance.keepAliveWhile(() => handler(ctx));
426
+ });
427
+ }
428
+
429
+ function startWebhookFiber(doInstance, requestId, agentName, id, payload, handler) {
430
+ const run = async (fiber) => {
431
+ fiber?.stash?.({
432
+ version: 1,
433
+ kind: 'webhook',
434
+ agentName,
435
+ id,
436
+ requestId,
437
+ phase: 'running',
438
+ startedAt: Date.now(),
439
+ });
440
+
441
+ const ctx = createContextForRequest(id, payload, doInstance);
442
+ return runWithInstanceContext(doInstance, async () => {
443
+ try {
444
+ return await handler(ctx);
445
+ } finally {
446
+ ctx.setEventCallback(undefined);
447
+ }
448
+ });
449
+ };
450
+
451
+ assertAgentsDurabilityApi(doInstance, 'runFiber');
452
+ return doInstance.runFiber('flue:webhook:' + requestId, run);
453
+ }
454
+
455
+ async function handleFlueFiberRecovered(ctx, _doInstance, agentName) {
456
+ if (!ctx.name || !ctx.name.startsWith('flue:')) return;
457
+ console.warn('[flue] Cloudflare fiber interrupted:', agentName, ctx.name, ctx.snapshot ?? null);
458
+ }
459
+
349
460
  // ─── Shared Request Handler ────────────────────────────────────────────────
350
461
 
351
462
  async function handleAgentRequest(request, doInstance, agentName, handler) {
352
- // Session ID is the DO "room name" set by routeAgentRequest
353
- const sessionId = doInstance.name;
463
+ // Agent id is the DO "room name" set by routeAgentRequest
464
+ const id = doInstance.name;
354
465
 
355
466
  // Parse payload
356
467
  let payload;
@@ -360,9 +471,6 @@ async function handleAgentRequest(request, doInstance, agentName, handler) {
360
471
  payload = {};
361
472
  }
362
473
 
363
- // Set up Cloudflare context for runtime primitives
364
- setCloudflareContext({ env: doInstance.env, agentInstance: doInstance, storage: doInstance.ctx.storage });
365
-
366
474
  const accept = request.headers.get('accept') || '';
367
475
  const isWebhook = request.headers.get('x-webhook') === 'true';
368
476
  const isSSE = accept.includes('text/event-stream') && !isWebhook;
@@ -371,17 +479,12 @@ async function handleAgentRequest(request, doInstance, agentName, handler) {
371
479
  // Fire-and-forget (webhook mode)
372
480
  if (isWebhook) {
373
481
  const requestId = crypto.randomUUID();
374
- const ctx = createContextForRequest(sessionId, payload, doInstance);
375
- handler(ctx).then(
482
+ startWebhookFiber(doInstance, requestId, agentName, id, payload, handler).then(
376
483
  (result) => {
377
- ctx.setEventCallback(undefined);
378
- clearCloudflareContext();
379
484
  console.log('[flue] Webhook handler complete:', agentName,
380
485
  result !== undefined ? JSON.stringify(result) : '(no return)');
381
486
  },
382
487
  (err) => {
383
- ctx.setEventCallback(undefined);
384
- clearCloudflareContext();
385
488
  console.error('[flue] Webhook handler error:', agentName, err);
386
489
  },
387
490
  );
@@ -397,6 +500,7 @@ async function handleAgentRequest(request, doInstance, agentName, handler) {
397
500
  const writer = writable.getWriter();
398
501
  const encoder = new TextEncoder();
399
502
  let eventId = 0;
503
+ let isIdle = false;
400
504
 
401
505
  const writeSSE = async (data, event) => {
402
506
  const lines = [];
@@ -407,14 +511,18 @@ async function handleAgentRequest(request, doInstance, agentName, handler) {
407
511
  await writer.write(encoder.encode(lines.join('\\n')));
408
512
  };
409
513
 
410
- const ctx = createContextForRequest(sessionId, payload, doInstance);
514
+ const ctx = createContextForRequest(id, payload, doInstance);
411
515
  ctx.setEventCallback((event) => {
516
+ if (event.type === 'idle') isIdle = true;
412
517
  writeSSE(event, event.type).catch(() => {});
413
518
  });
414
519
 
415
520
  (async () => {
416
521
  try {
417
- const result = await handler(ctx);
522
+ const result = await runHandlerWithKeepAlive(doInstance, ctx, handler);
523
+ if (!isIdle) {
524
+ await writeSSE({ type: 'idle' }, 'idle');
525
+ }
418
526
  await writeSSE(
419
527
  { type: 'result', data: result !== undefined ? result : null },
420
528
  'result',
@@ -424,9 +532,11 @@ async function handleAgentRequest(request, doInstance, agentName, handler) {
424
532
  { type: 'error', error: String(err) },
425
533
  'error',
426
534
  );
535
+ if (!isIdle) {
536
+ await writeSSE({ type: 'idle' }, 'idle');
537
+ }
427
538
  } finally {
428
539
  ctx.setEventCallback(undefined);
429
- clearCloudflareContext();
430
540
  await writer.close();
431
541
  }
432
542
  })();
@@ -441,16 +551,17 @@ async function handleAgentRequest(request, doInstance, agentName, handler) {
441
551
  }
442
552
 
443
553
  // Sync mode (default)
444
- const ctx = createContextForRequest(sessionId, payload, doInstance);
445
- const result = await handler(ctx);
446
- ctx.setEventCallback(undefined);
447
- clearCloudflareContext();
448
- return new Response(
449
- JSON.stringify({ result: result !== undefined ? result : null }),
450
- { headers: { 'content-type': 'application/json' } },
451
- );
554
+ const ctx = createContextForRequest(id, payload, doInstance);
555
+ try {
556
+ const result = await runHandlerWithKeepAlive(doInstance, ctx, handler);
557
+ return new Response(
558
+ JSON.stringify({ result: result !== undefined ? result : null }),
559
+ { headers: { 'content-type': 'application/json' } },
560
+ );
561
+ } finally {
562
+ ctx.setEventCallback(undefined);
563
+ }
452
564
  } catch (err) {
453
- clearCloudflareContext();
454
565
  console.error('[flue] Agent error:', agentName, err);
455
566
  return new Response(
456
567
  JSON.stringify({ error: String(err) }),
@@ -492,7 +603,7 @@ export default {
492
603
  }
493
604
 
494
605
  // Route to per-agent DOs via the Agents SDK
495
- // URL: /agents/<agent-name>/<session-id>
606
+ // URL: /agents/<agent-name>/<id>
496
607
  const response = await routeAgentRequest(request, env);
497
608
  if (response) return response;
498
609
 
@@ -501,18 +612,7 @@ export default {
501
612
  };
502
613
  `;
503
614
  }
504
- esbuildOptions(_ctx) {
505
- return {
506
- target: "esnext",
507
- external: [
508
- "node:*",
509
- "cloudflare:*",
510
- "node-liblzma",
511
- "@mongodb-js/zstd"
512
- ]
513
- };
514
- }
515
- additionalOutputs(ctx) {
615
+ async additionalOutputs(ctx) {
516
616
  const outputs = {};
517
617
  const flueBindings = ctx.agents.filter((a) => a.triggers.webhook).map((a) => ({
518
618
  class_name: agentClassName(a.name),
@@ -520,15 +620,15 @@ export default {
520
620
  }));
521
621
  const flueSqliteClasses = flueBindings.map((b) => b.class_name);
522
622
  const additions = {
523
- defaultName: ctx.outputDir.split("/").pop() ?? "flue-agents",
524
- main: "server.mjs",
623
+ defaultName: path.basename(ctx.outputDir) || "flue-agents",
624
+ main: "_entry.ts",
525
625
  doBindings: flueBindings,
526
626
  migration: {
527
627
  tag: "flue-v1",
528
628
  new_sqlite_classes: flueSqliteClasses
529
629
  }
530
630
  };
531
- const { config: userConfig, path: userConfigPath } = readUserWranglerConfig(ctx.outputDir);
631
+ const { config: userConfig, path: userConfigPath } = await this.getUserConfig(ctx.outputDir);
532
632
  if (userConfigPath) console.log(`[flue] Merging with user wrangler config: ${userConfigPath}`);
533
633
  validateUserWranglerConfig(userConfig);
534
634
  const sandboxClassNames = detectSandboxBindings(userConfig);
@@ -561,6 +661,7 @@ function agentClassName(name) {
561
661
  //#region src/build-plugin-node.ts
562
662
  var NodePlugin = class {
563
663
  name = "node";
664
+ bundle = "esbuild";
564
665
  generateEntryPoint(ctx) {
565
666
  const { agents, roles } = ctx;
566
667
  const rolesJson = JSON.stringify(roles);
@@ -571,8 +672,12 @@ import { Hono } from 'hono';
571
672
  import { streamSSE } from 'hono/streaming';
572
673
  import { serve } from '@hono/node-server';
573
674
  import { Bash, InMemoryFs, MountableFs, ReadWriteFs } from 'just-bash';
574
- import { getModel } from '@mariozechner/pi-ai';
575
- import { createFlueContext, InMemorySessionStore, bashToSessionEnv } from '@flue/sdk/internal';
675
+ import {
676
+ createFlueContext,
677
+ InMemorySessionStore,
678
+ bashFactoryToSessionEnv,
679
+ resolveModel,
680
+ } from '@flue/sdk/internal';
576
681
  import { randomUUID } from 'node:crypto';
577
682
 
578
683
  ${agents.map((a) => {
@@ -606,31 +711,10 @@ const manifest = ${JSON.stringify({ agents: agents.map((a) => ({
606
711
  // ─── Infrastructure ─────────────────────────────────────────────────────────
607
712
 
608
713
  // No build-time model default. The user sets model at runtime via
609
- // \`init({ model: "provider/model-id" })\` for a session default, or via
714
+ // \`init({ model: "provider/model-id" })\` for an agent default, or via
610
715
  // \`{ model: "provider/model-id" }\` on any individual prompt/skill/task call.
611
716
  const model = undefined;
612
717
 
613
- function resolveModel(modelString) {
614
- const slash = modelString.indexOf('/');
615
- if (slash === -1) {
616
- throw new Error(
617
- '[flue] Invalid model "' + modelString + '". ' +
618
- 'Use the "provider/model-id" format (e.g. "anthropic/claude-haiku-4-5").'
619
- );
620
- }
621
- const provider = modelString.slice(0, slash);
622
- const modelId = modelString.slice(slash + 1);
623
- const resolved = getModel(provider, modelId);
624
- if (!resolved) {
625
- throw new Error(
626
- '[flue] Unknown model "' + modelString + '". ' +
627
- 'Provider "' + provider + '" / model id "' + modelId + '" ' +
628
- 'is not registered with @mariozechner/pi-ai.'
629
- );
630
- }
631
- return resolved;
632
- }
633
-
634
718
  // ─── Sandbox Environments ───────────────────────────────────────────────────
635
719
 
636
720
  /**
@@ -639,11 +723,11 @@ function resolveModel(modelString) {
639
723
  * cwd = /home/user, /tmp exists, /bin and /usr/bin exist.
640
724
  */
641
725
  async function createDefaultEnv() {
642
- const bash = new Bash({
643
- fs: new InMemoryFs(),
726
+ const fs = new InMemoryFs();
727
+ return bashFactoryToSessionEnv(() => new Bash({
728
+ fs,
644
729
  network: { dangerouslyAllowFullInternetAccess: true },
645
- });
646
- return bashToSessionEnv(bash);
730
+ }));
647
731
  }
648
732
 
649
733
  /**
@@ -654,20 +738,19 @@ async function createLocalEnv() {
654
738
  const rwfs = new ReadWriteFs({ root: process.cwd() });
655
739
  const fs = new MountableFs({ base: new InMemoryFs() });
656
740
  fs.mount('/workspace', rwfs);
657
- const bash = new Bash({
741
+ return bashFactoryToSessionEnv(() => new Bash({
658
742
  fs,
659
743
  cwd: '/workspace',
660
744
  network: { dangerouslyAllowFullInternetAccess: true },
661
- });
662
- return bashToSessionEnv(bash);
745
+ }));
663
746
  }
664
747
 
665
748
  // Default persistence store for Node — in-memory, process lifetime.
666
749
  const defaultStore = new InMemorySessionStore();
667
750
 
668
- function createContextForRequest(sessionId, payload) {
751
+ function createContextForRequest(id, payload) {
669
752
  return createFlueContext({
670
- sessionId,
753
+ id,
671
754
  payload,
672
755
  env: process.env,
673
756
  agentConfig: {
@@ -686,16 +769,16 @@ const app = new Hono();
686
769
  app.get('/health', (c) => c.json({ status: 'ok' }));
687
770
  app.get('/agents', (c) => c.json(manifest));
688
771
 
689
- // Session ID is required in the URL
772
+ // Agent id is required in the URL
690
773
  app.post('/agents/:name', (c) => {
691
774
  return c.json({
692
- error: 'Session ID is required. Use /agents/:name/:sessionId',
775
+ error: 'Agent id is required. Use /agents/:name/:id',
693
776
  }, 400);
694
777
  });
695
778
 
696
- app.post('/agents/:name/:sessionId', async (c) => {
779
+ app.post('/agents/:name/:id', async (c) => {
697
780
  const name = c.req.param('name');
698
- const sessionId = c.req.param('sessionId');
781
+ const id = c.req.param('id');
699
782
 
700
783
  if (!handlers[name]) {
701
784
  return c.json({ error: 'Agent not found' }, 404);
@@ -719,7 +802,7 @@ app.post('/agents/:name/:sessionId', async (c) => {
719
802
  // Fire-and-forget (webhook mode)
720
803
  if (isWebhook) {
721
804
  const requestId = randomUUID();
722
- const ctx = createContextForRequest(sessionId, payload);
805
+ const ctx = createContextForRequest(id, payload);
723
806
  handler(ctx).then(
724
807
  (result) => {
725
808
  ctx.setEventCallback(undefined);
@@ -737,13 +820,19 @@ app.post('/agents/:name/:sessionId', async (c) => {
737
820
  if (isSSE) {
738
821
  return streamSSE(c, async (stream) => {
739
822
  let eventId = 0;
740
- const ctx = createContextForRequest(sessionId, payload);
823
+ let isIdle = false;
824
+ const ctx = createContextForRequest(id, payload);
741
825
  ctx.setEventCallback((event) => {
826
+ if (event.type === 'idle') isIdle = true;
742
827
  stream.writeSSE({ data: JSON.stringify(event), event: event.type, id: String(eventId++) }).catch(() => {});
743
828
  });
744
829
 
745
830
  try {
746
831
  const result = await handler(ctx);
832
+ if (!isIdle) {
833
+ const idle = { type: 'idle' };
834
+ await stream.writeSSE({ data: JSON.stringify(idle), event: 'idle', id: String(eventId++) });
835
+ }
747
836
  await stream.writeSSE({
748
837
  data: JSON.stringify({ type: 'result', data: result !== undefined ? result : null }),
749
838
  event: 'result',
@@ -755,6 +844,10 @@ app.post('/agents/:name/:sessionId', async (c) => {
755
844
  event: 'error',
756
845
  id: String(eventId++),
757
846
  });
847
+ if (!isIdle) {
848
+ const idle = { type: 'idle' };
849
+ await stream.writeSSE({ data: JSON.stringify(idle), event: 'idle', id: String(eventId++) });
850
+ }
758
851
  } finally {
759
852
  ctx.setEventCallback(undefined);
760
853
  }
@@ -763,7 +856,7 @@ app.post('/agents/:name/:sessionId', async (c) => {
763
856
 
764
857
  // Sync mode (default)
765
858
  try {
766
- const ctx = createContextForRequest(sessionId, payload);
859
+ const ctx = createContextForRequest(id, payload);
767
860
  const result = await handler(ctx);
768
861
  ctx.setEventCallback(undefined);
769
862
  return c.json({ result: result !== undefined ? result : null });
@@ -849,46 +942,63 @@ async function build(options) {
849
942
  outputDir,
850
943
  options
851
944
  };
852
- const serverCode = plugin.generateEntryPoint(ctx);
853
- const entryPath = path.join(distDir, "_entry_server.ts");
854
- const outPath = path.join(distDir, "server.mjs");
855
- fs.writeFileSync(entryPath, serverCode, "utf-8");
856
- try {
857
- const nodePathsSet = collectNodePaths(workspaceDir);
858
- const { external: pluginExternal = [], ...pluginEsbuildOpts } = plugin.esbuildOptions(ctx);
859
- const userExternals = getUserExternals(workspaceDir);
860
- await esbuild.build({
861
- entryPoints: [entryPath],
862
- bundle: true,
863
- outfile: outPath,
864
- format: "esm",
865
- external: [...pluginExternal, ...userExternals],
866
- nodePaths: [...nodePathsSet],
867
- logLevel: "warning",
868
- loader: {
869
- ".ts": "ts",
870
- ".node": "empty"
871
- },
872
- treeShaking: true,
873
- sourcemap: true,
874
- ...pluginEsbuildOpts
875
- });
876
- console.log(`[flue] Built: ${outPath}`);
877
- } finally {
945
+ const serverCode = await plugin.generateEntryPoint(ctx);
946
+ const bundleStrategy = plugin.bundle ?? "esbuild";
947
+ let anyChanged = false;
948
+ if (bundleStrategy === "esbuild") {
949
+ const entryPath = path.join(distDir, "_entry_server.ts");
950
+ const outPath = path.join(distDir, "server.mjs");
951
+ fs.writeFileSync(entryPath, serverCode, "utf-8");
878
952
  try {
879
- fs.unlinkSync(entryPath);
880
- } catch {}
881
- }
953
+ const nodePathsSet = collectNodePaths(workspaceDir);
954
+ const { external: pluginExternal = [], ...pluginEsbuildOpts } = plugin.esbuildOptions ? plugin.esbuildOptions(ctx) : {};
955
+ const userExternals = getUserExternals(workspaceDir);
956
+ await esbuild.build({
957
+ entryPoints: [entryPath],
958
+ bundle: true,
959
+ outfile: outPath,
960
+ format: "esm",
961
+ external: [...pluginExternal, ...userExternals],
962
+ nodePaths: [...nodePathsSet],
963
+ logLevel: "warning",
964
+ loader: {
965
+ ".ts": "ts",
966
+ ".node": "empty"
967
+ },
968
+ treeShaking: true,
969
+ sourcemap: true,
970
+ ...pluginEsbuildOpts
971
+ });
972
+ console.log(`[flue] Built: ${outPath}`);
973
+ anyChanged = true;
974
+ } finally {
975
+ try {
976
+ fs.unlinkSync(entryPath);
977
+ } catch {}
978
+ }
979
+ } else if (bundleStrategy === "none") {
980
+ if (!plugin.entryFilename) throw new Error(`[flue] Plugin "${plugin.name}" set bundle: 'none' but did not provide entryFilename.`);
981
+ const outPath = path.join(distDir, plugin.entryFilename);
982
+ if (!fs.existsSync(outPath) || fs.readFileSync(outPath, "utf-8") !== serverCode) {
983
+ fs.writeFileSync(outPath, serverCode, "utf-8");
984
+ console.log(`[flue] Wrote entry: ${outPath} (no bundle — downstream tool handles it)`);
985
+ anyChanged = true;
986
+ } else console.log(`[flue] Entry unchanged: ${outPath}`);
987
+ } else throw new Error(`[flue] Unknown bundle strategy: ${bundleStrategy}`);
882
988
  if (plugin.additionalOutputs) {
883
- const outputs = plugin.additionalOutputs(ctx);
989
+ const outputs = await plugin.additionalOutputs(ctx);
884
990
  for (const [filename, content] of Object.entries(outputs)) {
885
991
  const filePath = path.join(distDir, filename);
886
992
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
887
- fs.writeFileSync(filePath, content, "utf-8");
888
- console.log(`[flue] Generated: ${filePath}`);
993
+ if (!fs.existsSync(filePath) || fs.readFileSync(filePath, "utf-8") !== content) {
994
+ fs.writeFileSync(filePath, content, "utf-8");
995
+ console.log(`[flue] Generated: ${filePath}`);
996
+ anyChanged = true;
997
+ }
889
998
  }
890
999
  }
891
1000
  console.log(`[flue] Build complete. Output: ${distDir}`);
1001
+ return { changed: anyChanged };
892
1002
  }
893
1003
  function resolvePlugin(options) {
894
1004
  if (options.plugin) return options.plugin;
@@ -998,4 +1108,461 @@ function getSDKDir() {
998
1108
  }
999
1109
 
1000
1110
  //#endregion
1001
- export { BUILTIN_TOOL_NAMES, build, createTools, resolveWorkspaceFromCwd };
1111
+ //#region src/dev.ts
1112
+ /**
1113
+ * Flue dev server.
1114
+ *
1115
+ * Watches the user's workspace, rebuilds on file changes, and reloads the
1116
+ * underlying server. Distinct from `flue run`: dev is the long-running,
1117
+ * edit-and-iterate command, while `flue run` is the one-shot
1118
+ * production-style invoker (build → run → exit).
1119
+ *
1120
+ * # Two very different reload models
1121
+ *
1122
+ * Node and Cloudflare use fundamentally different rebuild strategies, because
1123
+ * what they each provide downstream is fundamentally different:
1124
+ *
1125
+ * - **Node** has no host bundler. Our esbuild pass produces the final
1126
+ * `dist/server.mjs`. On any change in the workspace we rebuild and respawn
1127
+ * the child Node process. Sub-second restart is fine.
1128
+ *
1129
+ * - **Cloudflare** uses Wrangler's bundler (the same one `wrangler dev` and
1130
+ * `wrangler deploy` use). Wrangler watches the entry's transitive import
1131
+ * graph itself and reloads workerd on source edits. So we *don't* need to
1132
+ * rebuild for body edits — wrangler handles it. We only need to act when:
1133
+ * 1. The set of agents changes (added / removed / triggers changed) →
1134
+ * regenerate `dist/_entry.ts`. Wrangler picks up the new entry
1135
+ * automatically because it's already watching it.
1136
+ * 2. The user's `wrangler.jsonc` changes → re-merge our additions and
1137
+ * restart the worker (config changes don't hot-apply).
1138
+ * Pure body edits to agent files: wrangler reloads workerd; we do nothing.
1139
+ *
1140
+ * # Watching
1141
+ *
1142
+ * Watching uses `node:fs.watch` recursive (Node 20+). Debounced 150ms. The
1143
+ * Node path treats every non-ignored change as a rebuild trigger; the
1144
+ * Cloudflare path filters to "structural" changes only.
1145
+ */
1146
+ /** Default port for `flue dev`. F=3, L=5, U=8, E=3 on a phone keypad. */
1147
+ const DEFAULT_DEV_PORT = 3583;
1148
+ /**
1149
+ * Start a Flue dev server. Resolves only when the server is shut down (e.g.
1150
+ * via SIGINT). Errors during the initial build/start are thrown synchronously;
1151
+ * errors during subsequent rebuilds are logged but do NOT exit the dev server
1152
+ * — the user is editing code, after all, and we want to recover when they fix it.
1153
+ */
1154
+ async function dev(options) {
1155
+ const workspaceDir = path.resolve(options.workspaceDir);
1156
+ const outputDir = path.resolve(options.outputDir);
1157
+ const port = options.port ?? DEFAULT_DEV_PORT;
1158
+ const buildOptions = {
1159
+ workspaceDir,
1160
+ outputDir,
1161
+ target: options.target
1162
+ };
1163
+ console.error(`[flue] Starting dev server (target: ${options.target})`);
1164
+ console.error(`[flue] Watching: ${workspaceDir}`);
1165
+ console.error(`[flue] Building...`);
1166
+ const initialStart = Date.now();
1167
+ try {
1168
+ await build(buildOptions);
1169
+ } catch (err) {
1170
+ throw new Error(`[flue] Initial build failed: ${err instanceof Error ? err.message : String(err)}`);
1171
+ }
1172
+ console.error(`[flue] Built in ${Date.now() - initialStart}ms`);
1173
+ const reloader = options.target === "node" ? new NodeReloader({
1174
+ outputDir,
1175
+ port
1176
+ }) : await createCloudflareReloader({
1177
+ outputDir,
1178
+ port
1179
+ });
1180
+ await reloader.start();
1181
+ if (reloader.url) {
1182
+ console.error(`[flue] Server: ${reloader.url}`);
1183
+ const exampleAgent = pickExampleAgentName(outputDir, workspaceDir);
1184
+ if (exampleAgent) {
1185
+ console.error(`[flue] Try: curl -X POST ${reloader.url}/agents/${exampleAgent}/test-1 \\`);
1186
+ console.error(` -H 'Content-Type: application/json' -d '{}'`);
1187
+ }
1188
+ }
1189
+ console.error(`[flue] Press Ctrl+C to stop\n`);
1190
+ const rebuilder = createRebuilder(buildOptions, reloader);
1191
+ const watcher = createWatcher({
1192
+ workspaceDir,
1193
+ outputDir,
1194
+ target: options.target,
1195
+ onChange: (relPath) => {
1196
+ if (!reloader.shouldRebuildOn(relPath)) return;
1197
+ console.error(`[flue] Change detected: ${relPath}`);
1198
+ rebuilder.schedule();
1199
+ }
1200
+ });
1201
+ let shuttingDown = false;
1202
+ const shutdown = async (signal, exitCode) => {
1203
+ if (shuttingDown) return;
1204
+ shuttingDown = true;
1205
+ console.error(`\n[flue] Received ${signal}, shutting down...`);
1206
+ watcher.close();
1207
+ try {
1208
+ await reloader.stop();
1209
+ } catch (err) {
1210
+ console.error(`[flue] Error during shutdown: ${err instanceof Error ? err.message : String(err)}`);
1211
+ }
1212
+ console.error(`[flue] Stopped.`);
1213
+ process.exit(exitCode);
1214
+ };
1215
+ process.on("SIGINT", () => void shutdown("SIGINT", 130));
1216
+ process.on("SIGTERM", () => void shutdown("SIGTERM", 143));
1217
+ process.on("exit", () => {
1218
+ try {
1219
+ reloader.killSync?.();
1220
+ } catch {}
1221
+ });
1222
+ await new Promise(() => {});
1223
+ }
1224
+ function createRebuilder(buildOptions, reloader) {
1225
+ let running = false;
1226
+ let queued = false;
1227
+ let debounceTimer = null;
1228
+ const runOnce = async () => {
1229
+ running = true;
1230
+ const start = Date.now();
1231
+ console.error(`[flue] Rebuilding...`);
1232
+ try {
1233
+ const { changed } = await build(buildOptions);
1234
+ await reloader.reload(changed);
1235
+ console.error(`[flue] Reloaded in ${Date.now() - start}ms\n`);
1236
+ } catch (err) {
1237
+ console.error(`[flue] Rebuild failed: ${err instanceof Error ? err.message : String(err)}\n`);
1238
+ } finally {
1239
+ running = false;
1240
+ if (queued) {
1241
+ queued = false;
1242
+ runOnce();
1243
+ }
1244
+ }
1245
+ };
1246
+ return { schedule() {
1247
+ if (debounceTimer) clearTimeout(debounceTimer);
1248
+ debounceTimer = setTimeout(() => {
1249
+ debounceTimer = null;
1250
+ if (running) queued = true;
1251
+ else runOnce();
1252
+ }, 150);
1253
+ } };
1254
+ }
1255
+ /**
1256
+ * Watch the workspace for changes. Uses `fs.watch` recursive (Node 20+).
1257
+ *
1258
+ * Watched roots:
1259
+ * - `<workspaceDir>` — agents/, roles/, AGENTS.md, .agents/skills/.
1260
+ * - For Cloudflare: also `<outputDir>/wrangler.jsonc` (and `.json`),
1261
+ * since changes there require a worker restart.
1262
+ *
1263
+ * Ignored:
1264
+ * - `dist/`, `node_modules/`, `.git/`, `.turbo/`
1265
+ * - dotfiles other than the ones we explicitly care about (AGENTS.md is
1266
+ * not a dotfile, so it's fine)
1267
+ * - editor backup/swap suffixes
1268
+ */
1269
+ function createWatcher(options) {
1270
+ const { workspaceDir, outputDir, target, onChange } = options;
1271
+ const watchers = [];
1272
+ const isIgnoredPath = (relPath) => {
1273
+ const parts = relPath.replace(/\\/g, "/").split("/");
1274
+ for (const part of parts) {
1275
+ if (part === "node_modules") return true;
1276
+ if (part === "dist") return true;
1277
+ if (part === ".git") return true;
1278
+ if (part === ".turbo") return true;
1279
+ }
1280
+ const base = parts[parts.length - 1] ?? "";
1281
+ if (!base) return true;
1282
+ if (base.startsWith(".") && base !== ".flueignore") return true;
1283
+ if (base.endsWith("~") || base.endsWith(".swp") || base.endsWith(".swx")) return true;
1284
+ if (base === ".DS_Store") return true;
1285
+ return false;
1286
+ };
1287
+ try {
1288
+ const w = fs.watch(workspaceDir, { recursive: true }, (_event, filename) => {
1289
+ if (!filename) return;
1290
+ const rel = filename.toString();
1291
+ if (isIgnoredPath(rel)) return;
1292
+ onChange(rel);
1293
+ });
1294
+ watchers.push(w);
1295
+ } catch (err) {
1296
+ console.error(`[flue] Failed to watch ${workspaceDir}: ${err instanceof Error ? err.message : String(err)}`);
1297
+ }
1298
+ if (target === "cloudflare") for (const cfgName of [
1299
+ "wrangler.jsonc",
1300
+ "wrangler.json",
1301
+ "wrangler.toml"
1302
+ ]) {
1303
+ const cfgPath = path.join(outputDir, cfgName);
1304
+ if (!fs.existsSync(cfgPath)) continue;
1305
+ try {
1306
+ const w = fs.watch(cfgPath, () => onChange(cfgName));
1307
+ watchers.push(w);
1308
+ } catch {}
1309
+ }
1310
+ return { close() {
1311
+ for (const w of watchers) try {
1312
+ w.close();
1313
+ } catch {}
1314
+ } };
1315
+ }
1316
+ var NodeReloader = class {
1317
+ child = null;
1318
+ serverPath;
1319
+ outputDir;
1320
+ port;
1321
+ url;
1322
+ constructor(opts) {
1323
+ this.outputDir = opts.outputDir;
1324
+ this.port = opts.port;
1325
+ this.serverPath = path.join(this.outputDir, "dist", "server.mjs");
1326
+ this.url = `http://localhost:${this.port}`;
1327
+ }
1328
+ async start() {
1329
+ await this.spawnAndWait();
1330
+ }
1331
+ shouldRebuildOn(_relPath) {
1332
+ return true;
1333
+ }
1334
+ async reload(_buildChanged) {
1335
+ await this.killChild();
1336
+ await this.spawnAndWait();
1337
+ }
1338
+ async stop() {
1339
+ await this.killChild();
1340
+ }
1341
+ killSync() {
1342
+ const child = this.child;
1343
+ if (!child || child.killed) return;
1344
+ try {
1345
+ child.kill("SIGKILL");
1346
+ } catch {}
1347
+ }
1348
+ async spawnAndWait() {
1349
+ const child = spawn("node", [this.serverPath], {
1350
+ stdio: [
1351
+ "ignore",
1352
+ "pipe",
1353
+ "pipe"
1354
+ ],
1355
+ cwd: this.outputDir,
1356
+ env: {
1357
+ ...process.env,
1358
+ PORT: String(this.port),
1359
+ FLUE_MODE: "local"
1360
+ }
1361
+ });
1362
+ this.child = child;
1363
+ const pipe = (data) => {
1364
+ const text = data.toString().trimEnd();
1365
+ for (const line of text.split("\n")) {
1366
+ if (!line.trim()) continue;
1367
+ if (line.includes("[flue] Server listening") || line.includes("[flue] Available agents:") || line.includes("[flue] Mode: local")) continue;
1368
+ console.error(line);
1369
+ }
1370
+ };
1371
+ child.stdout?.on("data", pipe);
1372
+ child.stderr?.on("data", pipe);
1373
+ child.on("exit", (code, signal) => {
1374
+ if (this.child === child) {
1375
+ this.child = null;
1376
+ if (code !== 0 && code !== null) console.error(`[flue] Node server exited unexpectedly (code=${code}, signal=${signal ?? "none"})`);
1377
+ }
1378
+ });
1379
+ if (!await waitForHealth(this.url, 15e3)) {
1380
+ await this.killChild();
1381
+ throw new Error("Node server did not become ready within 15s");
1382
+ }
1383
+ }
1384
+ async killChild() {
1385
+ const child = this.child;
1386
+ if (!child || child.killed) {
1387
+ this.child = null;
1388
+ return;
1389
+ }
1390
+ this.child = null;
1391
+ await new Promise((resolve) => {
1392
+ let resolved = false;
1393
+ const done = () => {
1394
+ if (!resolved) {
1395
+ resolved = true;
1396
+ resolve();
1397
+ }
1398
+ };
1399
+ child.once("exit", done);
1400
+ try {
1401
+ child.kill("SIGTERM");
1402
+ } catch {
1403
+ done();
1404
+ return;
1405
+ }
1406
+ setTimeout(() => {
1407
+ try {
1408
+ if (!child.killed) child.kill("SIGKILL");
1409
+ } catch {}
1410
+ done();
1411
+ }, 1e3);
1412
+ });
1413
+ }
1414
+ };
1415
+ /**
1416
+ * Lazy-import wrangler so users targeting only Node don't need it installed.
1417
+ * If the import fails, surface a friendly message pointing at the peer-dep.
1418
+ */
1419
+ async function createCloudflareReloader(opts) {
1420
+ let wrangler;
1421
+ try {
1422
+ wrangler = await import("wrangler");
1423
+ } catch (err) {
1424
+ throw new Error(`[flue] Cloudflare dev requires the "wrangler" package as a peer dependency.
1425
+ Install it in your project:
1426
+
1427
+ npm install --save-dev wrangler
1428
+
1429
+ Underlying error: ${err instanceof Error ? err.message : String(err)}`);
1430
+ }
1431
+ return new CloudflareReloader(wrangler, opts);
1432
+ }
1433
+ var CloudflareReloader = class {
1434
+ worker = null;
1435
+ wrangler;
1436
+ outputDir;
1437
+ port;
1438
+ configPath;
1439
+ url;
1440
+ constructor(wrangler, opts) {
1441
+ this.wrangler = wrangler;
1442
+ this.outputDir = opts.outputDir;
1443
+ this.port = opts.port;
1444
+ this.configPath = path.join(this.outputDir, "dist", "wrangler.jsonc");
1445
+ }
1446
+ async start() {
1447
+ await this.startWorker();
1448
+ }
1449
+ /**
1450
+ * On Cloudflare, wrangler watches the entry's transitive imports itself
1451
+ * and hot-reloads workerd when an agent file body changes. We only need
1452
+ * to act when something *structural* changes — i.e. something that
1453
+ * affects what `_entry.ts` or `wrangler.jsonc` look like.
1454
+ *
1455
+ * Concretely, we trigger a Flue-side rebuild for:
1456
+ * - File adds/removes in `agents/` (the agent set determines DO classes
1457
+ * and binding declarations).
1458
+ * - Changes to `agents/*.ts` — these MAY change the exported `triggers`,
1459
+ * so we have to re-parse them. (Plain body edits redo a tiny amount
1460
+ * of work but the rebuild is cheap and idempotent.)
1461
+ * - Changes to `roles/*.md` — roles are baked into the entry as JSON.
1462
+ * - Changes to the user's `wrangler.jsonc` — affects the merged config.
1463
+ *
1464
+ * Notes we explicitly DO ignore for rebuild purposes (wrangler handles
1465
+ * them): edits to imported source files outside of `agents/`/`roles/`,
1466
+ * AGENTS.md, and `.agents/skills/` (those are runtime-discovered, not
1467
+ * baked into the entry).
1468
+ */
1469
+ shouldRebuildOn(relPath) {
1470
+ const normalized = relPath.replace(/\\/g, "/");
1471
+ if (normalized === "wrangler.jsonc" || normalized === "wrangler.json" || normalized === "wrangler.toml") return true;
1472
+ if (normalized.startsWith("agents/")) return true;
1473
+ if (normalized.startsWith("roles/")) return true;
1474
+ return false;
1475
+ }
1476
+ async reload(buildChanged) {
1477
+ if (!buildChanged) {
1478
+ console.error(`[flue] No structural change — wrangler will hot-reload\n`);
1479
+ return;
1480
+ }
1481
+ await this.disposeWorker();
1482
+ await this.startWorker();
1483
+ }
1484
+ async stop() {
1485
+ await this.disposeWorker();
1486
+ }
1487
+ killSync() {
1488
+ this.worker = null;
1489
+ }
1490
+ async startWorker() {
1491
+ if (!fs.existsSync(this.configPath)) throw new Error(`[flue] Expected ${this.configPath} after build, but it doesn't exist. Did the Cloudflare build succeed?`);
1492
+ this.worker = await this.wrangler.unstable_startWorker({
1493
+ config: this.configPath,
1494
+ build: { nodejsCompatMode: "v2" },
1495
+ dev: {
1496
+ server: {
1497
+ hostname: "localhost",
1498
+ port: this.port
1499
+ },
1500
+ watch: false,
1501
+ logLevel: "info"
1502
+ }
1503
+ });
1504
+ try {
1505
+ this.url = (await this.worker.url).toString().replace(/\/$/, "");
1506
+ } catch {
1507
+ this.url = `http://127.0.0.1:${this.port}`;
1508
+ }
1509
+ }
1510
+ async disposeWorker() {
1511
+ const worker = this.worker;
1512
+ this.worker = null;
1513
+ if (!worker) return;
1514
+ try {
1515
+ await worker.dispose();
1516
+ } catch (err) {
1517
+ console.error(`[flue] Error disposing Cloudflare worker: ${err instanceof Error ? err.message : String(err)}`);
1518
+ }
1519
+ }
1520
+ };
1521
+ async function waitForHealth(baseUrl, timeoutMs) {
1522
+ const start = Date.now();
1523
+ while (Date.now() - start < timeoutMs) {
1524
+ try {
1525
+ const controller = new AbortController();
1526
+ const timeout = setTimeout(() => controller.abort(), 1e3);
1527
+ const res = await fetch(`${baseUrl}/health`, { signal: controller.signal });
1528
+ clearTimeout(timeout);
1529
+ if (res.ok) return true;
1530
+ } catch {}
1531
+ await new Promise((r) => setTimeout(r, 200));
1532
+ }
1533
+ return false;
1534
+ }
1535
+ /**
1536
+ * Pick a webhook agent name to print in the friendly curl example. Falls back
1537
+ * to any agent if none have webhook triggers (the example would 404 on the
1538
+ * dev server in that case, but it's still a hint at the URL shape). Reads the
1539
+ * manifest written by the build, with a directory-scan fallback in case the
1540
+ * manifest is somehow missing.
1541
+ *
1542
+ * Best-effort — silently returns null if anything goes wrong.
1543
+ */
1544
+ function pickExampleAgentName(outputDir, workspaceDir) {
1545
+ try {
1546
+ const manifestPath = path.join(outputDir, "dist", "manifest.json");
1547
+ if (fs.existsSync(manifestPath)) {
1548
+ const agents = JSON.parse(fs.readFileSync(manifestPath, "utf-8")).agents ?? [];
1549
+ const webhook = agents.find((a) => a.triggers?.webhook);
1550
+ if (webhook) return webhook.name;
1551
+ if (agents[0]) return agents[0].name;
1552
+ }
1553
+ } catch {}
1554
+ try {
1555
+ const agentsDir = path.join(workspaceDir, "agents");
1556
+ if (!fs.existsSync(agentsDir)) return null;
1557
+ for (const e of fs.readdirSync(agentsDir)) {
1558
+ const m = e.match(/^([a-zA-Z0-9_-]+)\.(ts|js|mts|mjs)$/);
1559
+ if (m && m[1]) return m[1];
1560
+ }
1561
+ return null;
1562
+ } catch {
1563
+ return null;
1564
+ }
1565
+ }
1566
+
1567
+ //#endregion
1568
+ export { BUILTIN_TOOL_NAMES, DEFAULT_DEV_PORT, build, createTools, dev, resolveWorkspaceFromCwd };