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