@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/README.md +155 -20
- package/dist/{agent-BYG0nVbQ.mjs → agent-BB4lwAd5.mjs} +24 -3
- package/dist/client.d.mts +4 -3
- package/dist/client.mjs +45 -26
- package/dist/cloudflare/index.d.mts +4 -9
- package/dist/cloudflare/index.mjs +32 -21
- package/dist/{command-helpers-C8SHLdaA.d.mts → command-helpers-DdAfbnom.d.mts} +1 -1
- package/dist/index.d.mts +48 -5
- package/dist/index.mjs +746 -179
- package/dist/internal.d.mts +17 -3
- package/dist/internal.mjs +37 -4
- package/dist/mcp-BVF-sOBZ.d.mts +22 -0
- package/dist/mcp-DOgMtp8y.mjs +285 -0
- package/dist/node/index.d.mts +2 -2
- package/dist/node/index.mjs +1 -1
- package/dist/sandbox.d.mts +4 -3
- package/dist/sandbox.mjs +58 -28
- package/dist/{session-CiAMTsLZ.mjs → session-DukL3zwF.mjs} +629 -269
- package/dist/{types-C0nqbu6Z.d.mts → types-T8pE1xIS.d.mts} +156 -53
- package/package.json +12 -4
- /package/dist/{command-helpers-CxRhK1my.mjs → command-helpers-hTZKWK13.mjs} +0 -0
package/dist/index.mjs
CHANGED
|
@@ -1,57 +1,98 @@
|
|
|
1
|
-
import { a as parseFrontmatterFile, n as createTools, t as BUILTIN_TOOL_NAMES } from "./agent-
|
|
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 {
|
|
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
|
|
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
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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 = "
|
|
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
|
|
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
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
57
|
+
* The verbose / defaulted output is intentional — the 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
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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 } =
|
|
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 {
|
|
218
|
-
|
|
219
|
-
|
|
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
|
|
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
|
|
265
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
//
|
|
353
|
-
const
|
|
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
|
-
|
|
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(
|
|
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
|
|
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(
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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>/<
|
|
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
|
-
|
|
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
|
|
524
|
-
main: "
|
|
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 } =
|
|
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 {
|
|
575
|
-
|
|
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
|
|
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
|
|
643
|
-
|
|
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
|
-
|
|
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(
|
|
751
|
+
function createContextForRequest(id, payload) {
|
|
669
752
|
return createFlueContext({
|
|
670
|
-
|
|
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
|
-
//
|
|
772
|
+
// Agent id is required in the URL
|
|
690
773
|
app.post('/agents/:name', (c) => {
|
|
691
774
|
return c.json({
|
|
692
|
-
error: '
|
|
775
|
+
error: 'Agent id is required. Use /agents/:name/:id',
|
|
693
776
|
}, 400);
|
|
694
777
|
});
|
|
695
778
|
|
|
696
|
-
app.post('/agents/:name/:
|
|
779
|
+
app.post('/agents/:name/:id', async (c) => {
|
|
697
780
|
const name = c.req.param('name');
|
|
698
|
-
const
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
const
|
|
858
|
-
|
|
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
|
-
|
|
880
|
-
|
|
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.
|
|
888
|
-
|
|
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
|
-
|
|
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 };
|