tina4-nodejs 3.13.88 → 3.13.90

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/CLAUDE.md CHANGED
@@ -1,10 +1,10 @@
1
- # CLAUDE.md - AI Developer Guide for tina4-nodejs (v3.13.88)
1
+ # CLAUDE.md - AI Developer Guide for tina4-nodejs (v3.13.90)
2
2
 
3
3
  > This file helps AI assistants (Claude, Copilot, Cursor, etc.) understand and work on this codebase effectively.
4
4
 
5
5
  ## What This Project Is
6
6
 
7
- Tina4 for Node.js/TypeScript v3.13.88 - The Intelligent Native Application 4ramework. A convention-over-configuration structural paradigm. The developer writes TypeScript; Tina4 is invisible infrastructure.
7
+ Tina4 for Node.js/TypeScript v3.13.90 - The Intelligent Native Application 4ramework. A convention-over-configuration structural paradigm. The developer writes TypeScript; Tina4 is invisible infrastructure.
8
8
 
9
9
  The philosophy: zero ceremony, batteries included, file system as source of truth.
10
10
 
@@ -1244,7 +1244,7 @@ When adding new features, add a corresponding `test/<feature>.test.ts` file.
1244
1244
  ## v3 Features Summary
1245
1245
 
1246
1246
  - **98 built-in features**, zero third-party dependencies
1247
- - **5,899 tests** passing across 189 files (build + typecheck green)
1247
+ - **5,923 tests** passing across 189 files (build + typecheck green)
1248
1248
  - **Race-safe `getNextId()`** with atomic sequence table (`tina4_sequences`) for SQLite/MySQL/MSSQL; PostgreSQL auto-creates sequences
1249
1249
  - **Frond template engine optimizations**: pre-compiled regexes, lazy loop context (copy-on-write), filter chain caching, path split caching, inline common filters (11-15% speedup)
1250
1250
  - **Production server auto-detect**: `npx tina4nodejs serve --production` auto-uses cluster mode
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tina4-nodejs",
3
- "version": "3.13.88",
3
+ "version": "3.13.90",
4
4
  "type": "module",
5
5
  "description": "Tina4 for Node.js/TypeScript - 54 built-in features, zero dependencies",
6
6
  "keywords": [
@@ -13793,7 +13793,7 @@ function _generateFormToken(descriptor = "") {
13793
13793
  function _generateFormTokenValue(descriptor = "") {
13794
13794
  return new SafeString(_buildFormTokenJwt(descriptor));
13795
13795
  }
13796
- var SafeString, JSON_UNSAFE_RE, JSON_UNSAFE_MAP, NUMERIC_RE, METHOD_CALL_RE, FN_CALL_RE, IS_NOT_RE, IS_RE, NOT_IN_RE, IN_RE, DIVISIBLE_BY_RE, FILTER_WITH_ARGS_RE, FILTER_COMPARISON_RE, TITLE_WORD_RE, STRIP_TAGS_RE, FORMAT_RE, LEADING_WS_RE, TRAILING_WS_RE, THOUSANDS_RE, LIVE_RE, LIVE_WS_RE, LIVE_SRC_RE, filterChainCache, pathParseCache, TEMPLATE_CACHE_MAX, TOKEN_RE, RAW_BLOCK_RE, VarRef, BUILTIN_FILTERS, _formTokenSessionId, Frond;
13796
+ var SafeString, KNOWN_TAGS, TERMINATOR_TAGS, JSON_UNSAFE_RE, JSON_UNSAFE_MAP, NUMERIC_RE, METHOD_CALL_RE, FN_CALL_RE, IS_NOT_RE, IS_RE, NOT_IN_RE, IN_RE, DIVISIBLE_BY_RE, FILTER_WITH_ARGS_RE, FILTER_COMPARISON_RE, TITLE_WORD_RE, STRIP_TAGS_RE, FORMAT_RE, LEADING_WS_RE, TRAILING_WS_RE, THOUSANDS_RE, LIVE_RE, LIVE_WS_RE, LIVE_SRC_RE, filterChainCache, pathParseCache, TEMPLATE_CACHE_MAX, TOKEN_RE, RAW_BLOCK_RE, VarRef, BUILTIN_FILTERS, _formTokenSessionId, Frond;
13797
13797
  var init_engine = __esm({
13798
13798
  "../frond/src/engine.ts"() {
13799
13799
  "use strict";
@@ -13805,6 +13805,37 @@ var init_engine = __esm({
13805
13805
  return this.value;
13806
13806
  }
13807
13807
  };
13808
+ KNOWN_TAGS = /* @__PURE__ */ new Set([
13809
+ "autoescape",
13810
+ "block",
13811
+ "cache",
13812
+ "extends",
13813
+ "for",
13814
+ "from",
13815
+ "if",
13816
+ "import",
13817
+ "include",
13818
+ "live",
13819
+ "macro",
13820
+ "raw",
13821
+ "set",
13822
+ "spaceless"
13823
+ ]);
13824
+ TERMINATOR_TAGS = /* @__PURE__ */ new Set([
13825
+ "elif",
13826
+ "else",
13827
+ "elseif",
13828
+ "endautoescape",
13829
+ "endblock",
13830
+ "endcache",
13831
+ "endfor",
13832
+ "endif",
13833
+ "endlive",
13834
+ "endmacro",
13835
+ "endraw",
13836
+ "endset",
13837
+ "endspaceless"
13838
+ ]);
13808
13839
  JSON_UNSAFE_RE = /[<>&'\u2028\u2029]/g;
13809
13840
  JSON_UNSAFE_MAP = {
13810
13841
  "<": "\\u003c",
@@ -14459,8 +14490,11 @@ var init_engine = __esm({
14459
14490
  i = skip;
14460
14491
  }
14461
14492
  } else if (tag === "set") {
14493
+ const isBlockSet = !content.includes("=");
14462
14494
  if (this._sandbox && this._allowedTags !== null && !this._allowedTags.has("set")) {
14463
- i++;
14495
+ i = isBlockSet ? this.skipBlock(tokens, i, "set", "endset") : i + 1;
14496
+ } else if (isBlockSet) {
14497
+ i = this.handleSetBlock(tokens, i, context);
14464
14498
  } else {
14465
14499
  this.handleSet(content, context);
14466
14500
  i++;
@@ -14502,6 +14536,11 @@ var init_engine = __esm({
14502
14536
  i++;
14503
14537
  } else {
14504
14538
  i++;
14539
+ if (tag !== "" && !TERMINATOR_TAGS.has(tag)) {
14540
+ throw new Error(
14541
+ `Frond: unknown tag "${tag}" -- known tags are: ${[...KNOWN_TAGS].sort().join(", ")}`
14542
+ );
14543
+ }
14505
14544
  }
14506
14545
  if (stripA && i < tokens.length && tokens[i][0] === "TEXT") {
14507
14546
  tokens[i] = ["TEXT", tokens[i][1].replace(LEADING_WS_RE, "")];
@@ -15398,6 +15437,47 @@ var init_engine = __esm({
15398
15437
  }
15399
15438
  return html;
15400
15439
  }
15440
+ /**
15441
+ * {% set name %}...{% endset %} -- render the body and bind it.
15442
+ *
15443
+ * Emits nothing itself. The captured value is a SafeString because it is
15444
+ * template output that has already been escaped on the way in; re-escaping it
15445
+ * at {{ name }} would double-encode every entity. Twig and Jinja2 both mark the
15446
+ * capture safe. Returns the index just past {% endset %}.
15447
+ */
15448
+ handleSetBlock(tokens, start2, context) {
15449
+ const [content] = stripTag(tokens[start2][1]);
15450
+ const name = (content.split(/\s+/)[1] || "").trim();
15451
+ const bodyTokens = [];
15452
+ let i = start2 + 1;
15453
+ let depth = 0;
15454
+ while (i < tokens.length) {
15455
+ if (tokens[i][0] === "BLOCK") {
15456
+ const [tagContent] = stripTag(tokens[i][1]);
15457
+ const tag = tagContent.split(/\s+/)[0] || "";
15458
+ if (tag === "set" && !tagContent.includes("=")) {
15459
+ depth++;
15460
+ bodyTokens.push(tokens[i]);
15461
+ } else if (tag === "endset") {
15462
+ if (depth === 0) {
15463
+ i++;
15464
+ break;
15465
+ }
15466
+ depth--;
15467
+ bodyTokens.push(tokens[i]);
15468
+ } else {
15469
+ bodyTokens.push(tokens[i]);
15470
+ }
15471
+ } else {
15472
+ bodyTokens.push(tokens[i]);
15473
+ }
15474
+ i++;
15475
+ }
15476
+ if (name) {
15477
+ context[name] = new SafeString(this.renderTokens([...bodyTokens], context));
15478
+ }
15479
+ return i;
15480
+ }
15401
15481
  handleSpaceless(tokens, start2, context) {
15402
15482
  const bodyTokens = [];
15403
15483
  let i = start2 + 1;
@@ -38681,6 +38761,11 @@ function parseFields(fieldsStr) {
38681
38761
  }
38682
38762
  return result;
38683
38763
  }
38764
+ var DEFAULT_FIELDS = [["name", "string"]];
38765
+ function fieldsOrDefault(fieldsStr) {
38766
+ const parsed = parseFields(fieldsStr);
38767
+ return parsed.length > 0 ? parsed : DEFAULT_FIELDS.map(([f, t]) => [f, t]);
38768
+ }
38684
38769
  function parseCliArgs(args) {
38685
38770
  const booleanFlags = /* @__PURE__ */ new Set([
38686
38771
  "no-browser",
@@ -38796,7 +38881,7 @@ async function generate2(what, name, extraArgs = []) {
38796
38881
  }
38797
38882
  }
38798
38883
  function generateModel(name, flags, emitTest = true) {
38799
- const fields = parseFields(flags.fields || "");
38884
+ const fields = fieldsOrDefault(flags.fields || "");
38800
38885
  const table2 = toTableName(name);
38801
38886
  const dir = resolve29("src/models");
38802
38887
  ensureDir(dir);
@@ -38804,13 +38889,9 @@ function generateModel(name, flags, emitTest = true) {
38804
38889
  const fieldLines = [
38805
38890
  ` id: { type: "integer" as const, primaryKey: true, autoIncrement: true },`
38806
38891
  ];
38807
- if (fields.length > 0) {
38808
- for (const [fname, ftype] of fields) {
38809
- const info = FIELD_TYPE_MAP[ftype] || FIELD_TYPE_MAP.string;
38810
- fieldLines.push(` ${fname}: { type: ${info.orm} as const },`);
38811
- }
38812
- } else {
38813
- fieldLines.push(` name: { type: "string" as const },`);
38892
+ for (const [fname, ftype] of fields) {
38893
+ const info = FIELD_TYPE_MAP[ftype] || FIELD_TYPE_MAP.string;
38894
+ fieldLines.push(` ${fname}: { type: ${info.orm} as const },`);
38814
38895
  }
38815
38896
  fieldLines.push(` created_at: { type: "datetime" as const },`);
38816
38897
  const content = `import { BaseModel } from "tina4-nodejs/orm";
@@ -38824,7 +38905,7 @@ ${fieldLines.join("\n")}
38824
38905
  `;
38825
38906
  writeFileSafe(path8, content);
38826
38907
  if (!flags["no-migration"]) {
38827
- generateMigration(`create_${table2}`, flags, fields.length > 0 ? fields : void 0, table2, false);
38908
+ generateMigration(`create_${table2}`, flags, fields, table2, false);
38828
38909
  }
38829
38910
  if (emitTest) emitModelTest(name, table2, fields);
38830
38911
  }
@@ -38895,7 +38976,12 @@ ${extend(
38895
38976
  "validate / business rules before persist",
38896
38977
  `e.g. reject invalid input; ground: tina4_context("validate before create", "nodejs")`
38897
38978
  )} const item = new ${model}(req.body as Record<string, unknown>);
38898
- await item.save();
38979
+ // save() returns false on failure rather than throwing - check it, or a failed
38980
+ // write is reported to the client as a 201 carrying unsaved data.
38981
+ if ((await item.save()) === false) {
38982
+ res.json({ error: "Could not create ${singular}" }, 400);
38983
+ return;
38984
+ }
38899
38985
  res.json({ data: item.toObject() }, 201);
38900
38986
  }
38901
38987
  `
@@ -38975,7 +39061,12 @@ ${extend(
38975
39061
  "guard which fields / who may update",
38976
39062
  `e.g. enforce ownership; ground: tina4_context("authorize update", "nodejs")`
38977
39063
  )} Object.assign(item, req.body as Record<string, unknown>);
38978
- await item.save();
39064
+ // save() returns false on failure rather than throwing - check it, or a failed
39065
+ // write is reported to the client as a 200 carrying unsaved data.
39066
+ if ((await item.save()) === false) {
39067
+ res.json({ error: "Could not update ${singular}" }, 400);
39068
+ return;
39069
+ }
38979
39070
  res.json({ data: item.toObject() });
38980
39071
  }
38981
39072
  `
@@ -39282,7 +39373,7 @@ void test${titleName};
39282
39373
  writeFileSafe(path8, content);
39283
39374
  }
39284
39375
  function generateForm(name, flags) {
39285
- const fields = parseFields(flags.fields || "");
39376
+ const fields = fieldsOrDefault(flags.fields || "");
39286
39377
  const table2 = toTableName(name);
39287
39378
  const routeName = toPlural(table2);
39288
39379
  const inputTypes = {
@@ -39302,9 +39393,8 @@ function generateForm(name, flags) {
39302
39393
  const dir = resolve29("src/templates/forms");
39303
39394
  ensureDir(dir);
39304
39395
  const path8 = join33(dir, `${table2}.twig`);
39305
- const fieldEntries = fields.length > 0 ? fields : [["name", "string"]];
39306
39396
  let fieldHtml = "";
39307
- for (const [fname, ftype] of fieldEntries) {
39397
+ for (const [fname, ftype] of fields) {
39308
39398
  const itype = inputTypes[ftype] || "text";
39309
39399
  const label = fname.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
39310
39400
  const step = ["float", "numeric", "decimal"].includes(ftype) ? ' step="0.01"' : "";
@@ -39348,10 +39438,10 @@ function generateForm(name, flags) {
39348
39438
  writeFileSafe(path8, content);
39349
39439
  }
39350
39440
  function generateView(name, flags) {
39351
- const fields = parseFields(flags.fields || "");
39441
+ const fields = fieldsOrDefault(flags.fields || "");
39352
39442
  const table2 = toTableName(name);
39353
39443
  const routeName = toPlural(table2);
39354
- const cols = fields.length > 0 ? fields.map(([f]) => f) : ["name"];
39444
+ const cols = fields.map(([f]) => f);
39355
39445
  const dir = resolve29("src/templates/pages");
39356
39446
  ensureDir(dir);
39357
39447
  const listPath = join33(dir, `${routeName}.twig`);
@@ -39866,7 +39956,7 @@ function sampleLiteral(fieldType) {
39866
39956
  }
39867
39957
  }
39868
39958
  function emitModelTest(model, table2, fields) {
39869
- const flds = fields.length > 0 ? fields : [["name", "string"]];
39959
+ const flds = fields.length > 0 ? fields : DEFAULT_FIELDS.map(([f, t]) => [f, t]);
39870
39960
  const payload = flds.map(([f, t]) => `${f}: ${sampleLiteral(t)}`).join(", ");
39871
39961
  const stringField = flds.find(([, t]) => ["string", "str", "text"].includes((t || "string").toLowerCase()))?.[0];
39872
39962
  const valueAssert = stringField ? `
@@ -115,6 +115,21 @@ export function parseFields(fieldsStr: string): Array<[string, string]> {
115
115
  return result;
116
116
  }
117
117
 
118
+ // Called without --fields, the generators fall back to a single `name` string
119
+ // column. That default MUST be materialised here, in one place, and then flow
120
+ // into the model, the migration, the form, the view and the test alike. It used
121
+ // to live only inside the model template, so `generate model X` / `generate
122
+ // crud X` wrote a model declaring `name` while the migration - built from the
123
+ // parsed field list, which was empty - created only id + created_at. The first
124
+ // write then failed with "table x has no column named name".
125
+ export const DEFAULT_FIELDS: ReadonlyArray<[string, string]> = [["name", "string"]];
126
+
127
+ /** Parsed --fields, or the default single `name` column when none given. */
128
+ export function fieldsOrDefault(fieldsStr: string): Array<[string, string]> {
129
+ const parsed = parseFields(fieldsStr);
130
+ return parsed.length > 0 ? parsed : DEFAULT_FIELDS.map(([f, t]) => [f, t] as [string, string]);
131
+ }
132
+
118
133
  export function parseCliArgs(args: string[]): { flags: Record<string, string | boolean>; positional: string[] } {
119
134
  // Boolean-only flags that never take a value argument.
120
135
  const booleanFlags = new Set([
@@ -290,7 +305,7 @@ export async function generate(what: string, name: string, extraArgs: string[] =
290
305
  // ── Model ───────────────────────────────────────────────────────────
291
306
 
292
307
  function generateModel(name: string, flags: Record<string, string | boolean>, emitTest = true): void {
293
- const fields = parseFields((flags.fields as string) || "");
308
+ const fields = fieldsOrDefault((flags.fields as string) || "");
294
309
  const table = toTableName(name);
295
310
  const dir = resolve("src/models");
296
311
  ensureDir(dir);
@@ -300,13 +315,9 @@ function generateModel(name: string, flags: Record<string, string | boolean>, em
300
315
  const fieldLines: string[] = [
301
316
  ` id: { type: "integer" as const, primaryKey: true, autoIncrement: true },`,
302
317
  ];
303
- if (fields.length > 0) {
304
- for (const [fname, ftype] of fields) {
305
- const info = FIELD_TYPE_MAP[ftype] || FIELD_TYPE_MAP.string;
306
- fieldLines.push(` ${fname}: { type: ${info.orm} as const },`);
307
- }
308
- } else {
309
- fieldLines.push(` name: { type: "string" as const },`);
318
+ for (const [fname, ftype] of fields) {
319
+ const info = FIELD_TYPE_MAP[ftype] || FIELD_TYPE_MAP.string;
320
+ fieldLines.push(` ${fname}: { type: ${info.orm} as const },`);
310
321
  }
311
322
  fieldLines.push(` created_at: { type: "datetime" as const },`);
312
323
 
@@ -328,7 +339,11 @@ ${fieldLines.join("\n")}
328
339
  // (below) proves the schema through the real ORM, so the migration sub-call
329
340
  // does NOT also co-emit a migration test (emitTest=false).
330
341
  if (!flags["no-migration"]) {
331
- generateMigration(`create_${table}`, flags, fields.length > 0 ? fields : undefined, table, false);
342
+ // Always hand over the RESOLVED field list. Passing `undefined` when the
343
+ // parsed list was empty made the migration fall back to its own
344
+ // parseFields() - also empty - so the table got only id + created_at while
345
+ // the model above declared `name`, and the first write 500'd.
346
+ generateMigration(`create_${table}`, flags, fields, table, false);
332
347
  }
333
348
 
334
349
  // Co-emit a real SQLite roundtrip test next to the model. Composite
@@ -417,7 +432,12 @@ ${modelImportBase}${secureOptOut(isPublic)}export const meta = { summary: "Creat
417
432
  export default async function (req: Tina4Request, res: Tina4Response) {
418
433
  ${extend("validate / business rules before persist",
419
434
  `e.g. reject invalid input; ground: tina4_context("validate before create", "nodejs")`)} const item = new ${model}(req.body as Record<string, unknown>);
420
- await item.save();
435
+ // save() returns false on failure rather than throwing - check it, or a failed
436
+ // write is reported to the client as a 201 carrying unsaved data.
437
+ if ((await item.save()) === false) {
438
+ res.json({ error: "Could not create ${singular}" }, 400);
439
+ return;
440
+ }
421
441
  res.json({ data: item.toObject() }, 201);
422
442
  }
423
443
  `,
@@ -499,7 +519,12 @@ export default async function (req: Tina4Request, res: Tina4Response) {
499
519
  }
500
520
  ${extend("guard which fields / who may update",
501
521
  `e.g. enforce ownership; ground: tina4_context("authorize update", "nodejs")`)} Object.assign(item, req.body as Record<string, unknown>);
502
- await item.save();
522
+ // save() returns false on failure rather than throwing - check it, or a failed
523
+ // write is reported to the client as a 200 carrying unsaved data.
524
+ if ((await item.save()) === false) {
525
+ res.json({ error: "Could not update ${singular}" }, 400);
526
+ return;
527
+ }
503
528
  res.json({ data: item.toObject() });
504
529
  }
505
530
  `,
@@ -873,7 +898,7 @@ void test${titleName};
873
898
  // ── Form ────────────────────────────────────────────────────────────
874
899
 
875
900
  function generateForm(name: string, flags: Record<string, string | boolean>): void {
876
- const fields = parseFields((flags.fields as string) || "");
901
+ const fields = fieldsOrDefault((flags.fields as string) || "");
877
902
  const table = toTableName(name);
878
903
  const routeName = toPlural(table);
879
904
 
@@ -890,9 +915,8 @@ function generateForm(name: string, flags: Record<string, string | boolean>): vo
890
915
  const path = join(dir, `${table}.twig`);
891
916
 
892
917
  // Build form fields
893
- const fieldEntries = fields.length > 0 ? fields : [["name", "string"] as [string, string]];
894
918
  let fieldHtml = "";
895
- for (const [fname, ftype] of fieldEntries) {
919
+ for (const [fname, ftype] of fields) {
896
920
  const itype = inputTypes[ftype] || "text";
897
921
  const label = fname.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
898
922
  const step = ["float", "numeric", "decimal"].includes(ftype) ? ' step="0.01"' : "";
@@ -946,11 +970,11 @@ function generateForm(name: string, flags: Record<string, string | boolean>): vo
946
970
  // ── View ────────────────────────────────────────────────────────────
947
971
 
948
972
  function generateView(name: string, flags: Record<string, string | boolean>): void {
949
- const fields = parseFields((flags.fields as string) || "");
973
+ const fields = fieldsOrDefault((flags.fields as string) || "");
950
974
  const table = toTableName(name);
951
975
  const routeName = toPlural(table);
952
976
 
953
- const cols = fields.length > 0 ? fields.map(([f]) => f) : ["name"];
977
+ const cols = fields.map(([f]) => f);
954
978
 
955
979
  const dir = resolve("src/templates/pages");
956
980
  ensureDir(dir);
@@ -1591,7 +1615,9 @@ function sampleLiteral(fieldType: string): string {
1591
1615
 
1592
1616
  /** model → real SQLite roundtrip (create / read back / missing → null). */
1593
1617
  function emitModelTest(model: string, table: string, fields: Array<[string, string]>): void {
1594
- const flds = fields.length > 0 ? fields : [["name", "string"] as [string, string]];
1618
+ // Reuse the single DEFAULT_FIELDS constant rather than re-stating the literal,
1619
+ // so the co-emitted test can never describe a shape the model does not have.
1620
+ const flds = fields.length > 0 ? fields : DEFAULT_FIELDS.map(([f, t]) => [f, t] as [string, string]);
1595
1621
  const payload = flds.map(([f, t]) => `${f}: ${sampleLiteral(t)}`).join(", ");
1596
1622
  const stringField = flds.find(([, t]) => ["string", "str", "text"].includes((t || "string").toLowerCase()))?.[0];
1597
1623
  const valueAssert = stringField
@@ -13792,7 +13792,7 @@ function _generateFormToken(descriptor = "") {
13792
13792
  function _generateFormTokenValue(descriptor = "") {
13793
13793
  return new SafeString(_buildFormTokenJwt(descriptor));
13794
13794
  }
13795
- var SafeString, JSON_UNSAFE_RE, JSON_UNSAFE_MAP, NUMERIC_RE, METHOD_CALL_RE, FN_CALL_RE, IS_NOT_RE, IS_RE, NOT_IN_RE, IN_RE, DIVISIBLE_BY_RE, FILTER_WITH_ARGS_RE, FILTER_COMPARISON_RE, TITLE_WORD_RE, STRIP_TAGS_RE, FORMAT_RE, LEADING_WS_RE, TRAILING_WS_RE, THOUSANDS_RE, LIVE_RE, LIVE_WS_RE, LIVE_SRC_RE, filterChainCache, pathParseCache, TEMPLATE_CACHE_MAX, TOKEN_RE, RAW_BLOCK_RE, VarRef, BUILTIN_FILTERS, _formTokenSessionId, Frond;
13795
+ var SafeString, KNOWN_TAGS, TERMINATOR_TAGS, JSON_UNSAFE_RE, JSON_UNSAFE_MAP, NUMERIC_RE, METHOD_CALL_RE, FN_CALL_RE, IS_NOT_RE, IS_RE, NOT_IN_RE, IN_RE, DIVISIBLE_BY_RE, FILTER_WITH_ARGS_RE, FILTER_COMPARISON_RE, TITLE_WORD_RE, STRIP_TAGS_RE, FORMAT_RE, LEADING_WS_RE, TRAILING_WS_RE, THOUSANDS_RE, LIVE_RE, LIVE_WS_RE, LIVE_SRC_RE, filterChainCache, pathParseCache, TEMPLATE_CACHE_MAX, TOKEN_RE, RAW_BLOCK_RE, VarRef, BUILTIN_FILTERS, _formTokenSessionId, Frond;
13796
13796
  var init_engine = __esm({
13797
13797
  "../frond/src/engine.ts"() {
13798
13798
  "use strict";
@@ -13804,6 +13804,37 @@ var init_engine = __esm({
13804
13804
  return this.value;
13805
13805
  }
13806
13806
  };
13807
+ KNOWN_TAGS = /* @__PURE__ */ new Set([
13808
+ "autoescape",
13809
+ "block",
13810
+ "cache",
13811
+ "extends",
13812
+ "for",
13813
+ "from",
13814
+ "if",
13815
+ "import",
13816
+ "include",
13817
+ "live",
13818
+ "macro",
13819
+ "raw",
13820
+ "set",
13821
+ "spaceless"
13822
+ ]);
13823
+ TERMINATOR_TAGS = /* @__PURE__ */ new Set([
13824
+ "elif",
13825
+ "else",
13826
+ "elseif",
13827
+ "endautoescape",
13828
+ "endblock",
13829
+ "endcache",
13830
+ "endfor",
13831
+ "endif",
13832
+ "endlive",
13833
+ "endmacro",
13834
+ "endraw",
13835
+ "endset",
13836
+ "endspaceless"
13837
+ ]);
13807
13838
  JSON_UNSAFE_RE = /[<>&'\u2028\u2029]/g;
13808
13839
  JSON_UNSAFE_MAP = {
13809
13840
  "<": "\\u003c",
@@ -14458,8 +14489,11 @@ var init_engine = __esm({
14458
14489
  i = skip;
14459
14490
  }
14460
14491
  } else if (tag === "set") {
14492
+ const isBlockSet = !content.includes("=");
14461
14493
  if (this._sandbox && this._allowedTags !== null && !this._allowedTags.has("set")) {
14462
- i++;
14494
+ i = isBlockSet ? this.skipBlock(tokens, i, "set", "endset") : i + 1;
14495
+ } else if (isBlockSet) {
14496
+ i = this.handleSetBlock(tokens, i, context);
14463
14497
  } else {
14464
14498
  this.handleSet(content, context);
14465
14499
  i++;
@@ -14501,6 +14535,11 @@ var init_engine = __esm({
14501
14535
  i++;
14502
14536
  } else {
14503
14537
  i++;
14538
+ if (tag !== "" && !TERMINATOR_TAGS.has(tag)) {
14539
+ throw new Error(
14540
+ `Frond: unknown tag "${tag}" -- known tags are: ${[...KNOWN_TAGS].sort().join(", ")}`
14541
+ );
14542
+ }
14504
14543
  }
14505
14544
  if (stripA && i < tokens.length && tokens[i][0] === "TEXT") {
14506
14545
  tokens[i] = ["TEXT", tokens[i][1].replace(LEADING_WS_RE, "")];
@@ -15397,6 +15436,47 @@ var init_engine = __esm({
15397
15436
  }
15398
15437
  return html;
15399
15438
  }
15439
+ /**
15440
+ * {% set name %}...{% endset %} -- render the body and bind it.
15441
+ *
15442
+ * Emits nothing itself. The captured value is a SafeString because it is
15443
+ * template output that has already been escaped on the way in; re-escaping it
15444
+ * at {{ name }} would double-encode every entity. Twig and Jinja2 both mark the
15445
+ * capture safe. Returns the index just past {% endset %}.
15446
+ */
15447
+ handleSetBlock(tokens, start2, context) {
15448
+ const [content] = stripTag(tokens[start2][1]);
15449
+ const name = (content.split(/\s+/)[1] || "").trim();
15450
+ const bodyTokens = [];
15451
+ let i = start2 + 1;
15452
+ let depth = 0;
15453
+ while (i < tokens.length) {
15454
+ if (tokens[i][0] === "BLOCK") {
15455
+ const [tagContent] = stripTag(tokens[i][1]);
15456
+ const tag = tagContent.split(/\s+/)[0] || "";
15457
+ if (tag === "set" && !tagContent.includes("=")) {
15458
+ depth++;
15459
+ bodyTokens.push(tokens[i]);
15460
+ } else if (tag === "endset") {
15461
+ if (depth === 0) {
15462
+ i++;
15463
+ break;
15464
+ }
15465
+ depth--;
15466
+ bodyTokens.push(tokens[i]);
15467
+ } else {
15468
+ bodyTokens.push(tokens[i]);
15469
+ }
15470
+ } else {
15471
+ bodyTokens.push(tokens[i]);
15472
+ }
15473
+ i++;
15474
+ }
15475
+ if (name) {
15476
+ context[name] = new SafeString(this.renderTokens([...bodyTokens], context));
15477
+ }
15478
+ return i;
15479
+ }
15400
15480
  handleSpaceless(tokens, start2, context) {
15401
15481
  const bodyTokens = [];
15402
15482
  let i = start2 + 1;
@@ -985,7 +985,7 @@ ${t.traceback||""}`;window.__switchTab("chat"),setTimeout(()=>{window.__prefillC
985
985
  <span class="thread-pip"></span>
986
986
  <span class="thread-title">${g(t.title)}</span>
987
987
  ${o}
988
- </div>`}).join("")}let cd=!1;async function Qa(i=0){for(let e=0;;e++)try{Se=await NZ(),cd=!1,EQ();return}catch(t){if(e>=i){cd=!0,console.error("refreshThreadList failed",t);return}await new Promise(n=>setTimeout(n,250*2**e))}}function HZ(i){const e=document.getElementById("editor-ai-messages");if(!e)return;const t=rt.get(i)||[];if(!t.length){e.innerHTML='<div class="ai-msg ai-bot" style="opacity:0.6">Start the conversation…</div>';return}e.innerHTML="";for(const n of t){const r=document.createElement("div");r.className=`ai-msg ai-${n.role==="user"?"user":"bot"}`,n.role==="user"?r.textContent=n.content:r.innerHTML=fs(n.content),e.appendChild(r)}e.scrollTop=e.scrollHeight}async function AQ(i,e=!1){if(!(!e&&rt.has(i)))try{const t=await FZ(i);rt.set(i,t)}catch(t){console.error(`loadThreadMessages(${i}) failed`,t),rt.set(i,[])}}async function dd(i){if(i!==ve){ve=i;try{localStorage.setItem(WQ,i)}catch{}EQ(),await AQ(i),HZ(i)}}async function KZ(){try{const i=await cs();Se.push(i),rt.set(i.id,[]),await dd(i.id);const e=document.getElementById("editor-ai-input");e==null||e.focus()}catch(i){AZ(`<span style="color:var(--danger)">Couldn't create thread: ${g(String((i==null?void 0:i.message)||i))}</span>`,"bot")}}async function JZ(i){const e=document.querySelector(`.thread-row[data-thread-id="${CSS.escape(i)}"]`),t=e==null?void 0:e.querySelector(".thread-title");if(!e||!t)return;const n=t.textContent||"",r=document.createElement("input");r.className="thread-title-edit",r.value=n,t.replaceWith(r),r.focus(),r.select();const s=a=>{const l=document.createElement("span");l.className="thread-title",l.textContent=a,r.replaceWith(l)},o=async()=>{const a=r.value.trim();if(!a||a===n){s(n);return}try{const l=await ga(i,{title:a}),O=Se.find(c=>c.id===i);O&&(O.title=l.title),s(l.title)}catch(l){console.error("rename failed",l),s(n)}};r.addEventListener("blur",()=>{o()}),r.addEventListener("keydown",a=>{a.key==="Enter"?(a.preventDefault(),r.blur()):a.key==="Escape"&&(a.preventDefault(),s(n))})}async function ez(){if(await Qa(4),!Se.length){ve=null,er();return}try{const i=localStorage.getItem(WQ);i&&Se.some(e=>e.id===i)&&(ve=i)}catch{}ya()}window.__editorThreadNew=()=>{KZ()},window.__editorThreadSwitch=i=>{dd(i)},window.__editorThreadRename=i=>{JZ(i)};const tz={done:"DONE",awaiting_customer:"AWAITING YOU",wont_do:"WONT DO",blocked:"BLOCKED",feedback:"NEW FEEDBACK",idle:"IDLE",running:"RUNNING"};function hd(i){const e=tz[i]||i.toUpperCase();return`<span class="status-pill" data-status="${g(i)}">${g(e)}</span>`}function fd(i){if(!i)return"";let e;if(/^\d+Z$/.test(i)?e=new Date(parseInt(i,10)*1e3):e=new Date(i),isNaN(e.getTime()))return i;const t=n=>String(n).padStart(2,"0");return`${t(e.getDate())}/${t(e.getMonth()+1)}/${e.getFullYear()} ${t(e.getHours())}:${t(e.getMinutes())}`}let Jn=null;function ya(){const i=e=>document.getElementById(e);i("threads-pane-head-list").hidden=!1,i("threads-pane-head-detail").hidden=!0,i("threads-list-view").hidden=!1,i("threads-detail-view").hidden=!0,Qa().then(er)}async function ba(i){await dd(i);const e=Se.find(s=>s.id===i);if(!e)return;const t=s=>document.getElementById(s);t("threads-pane-head-list").hidden=!0,t("threads-pane-head-detail").hidden=!1,t("threads-list-view").hidden=!0,t("threads-detail-view").hidden=!1,t("threads-detail-title").textContent=e.title||"Thread";const n=t("threads-detail-meta"),r=e.sender?`<span>📨 from ${g(e.sender)}</span>`:"";n.innerHTML=`${hd(e.status_hint||"idle")} <span>${g(fd(e.last_message_at))}</span> ${r}`,pd(i),setTimeout(()=>{var s;return(s=t("threads-reply-input"))==null?void 0:s.focus()},30)}async function iz(){if(!ve)return;const i=ve;try{await ga(i,{archived:!0,closure_reason:"done"});const e=Se.find(t=>t.id===i);e&&(e.archived=!0,e.closure_reason="done"),ve=null,ya()}catch(e){console.error("archive failed",e)}}async function nz(i){try{await ga(i,{archived:!0});const e=Se.find(t=>t.id===i);e&&(e.archived=!0),ve===i?(ve=null,ya()):er()}catch(e){console.error("archive-from-list failed",e)}}function er(){const i=document.getElementById("threads-rows");if(!i)return;if(!Se.length){i.innerHTML=cd?`<div class="threads-empty">
988
+ </div>`}).join("")}let cd=!1;async function Qa(i=0){for(let e=0;;e++)try{Se=await NZ(),cd=!1,EQ();return}catch(t){if(e>=i){cd=!0,console.error("refreshThreadList failed",t);return}await new Promise(n=>setTimeout(n,250*2**e))}}function HZ(i){const e=document.getElementById("editor-ai-messages");if(!e)return;const t=rt.get(i)||[];if(!t.length){e.innerHTML='<div class="ai-msg ai-bot" style="opacity:0.6">Start the conversation…</div>';return}e.innerHTML="";for(const n of t){const r=document.createElement("div");r.className=`ai-msg ai-${n.role==="user"?"user":"bot"}`,n.role==="user"?r.textContent=n.content:r.innerHTML=fs(n.content),e.appendChild(r)}e.scrollTop=e.scrollHeight}async function AQ(i,e=!1){if(!(!e&&rt.has(i)))try{const t=await FZ(i);rt.set(i,t)}catch(t){console.error(`loadThreadMessages(${i}) failed`,t),rt.set(i,[])}}async function dd(i){if(i!==ve){ve=i;try{localStorage.setItem(WQ,i)}catch{}EQ(),await AQ(i),HZ(i)}}async function KZ(){try{const i=await cs();Se.push(i),rt.set(i.id,[]),await dd(i.id);const e=document.getElementById("editor-ai-input");e==null||e.focus()}catch(i){AZ(`<span style="color:var(--danger)">Couldn't create thread: ${g(String((i==null?void 0:i.message)||i))}</span>`,"bot")}}async function JZ(i){const e=document.querySelector(`.thread-row[data-thread-id="${CSS.escape(i)}"]`),t=e==null?void 0:e.querySelector(".thread-title");if(!e||!t)return;const n=t.textContent||"",r=document.createElement("input");r.className="thread-title-edit",r.value=n,t.replaceWith(r),r.focus(),r.select();const s=a=>{const l=document.createElement("span");l.className="thread-title",l.textContent=a,r.replaceWith(l)},o=async()=>{const a=r.value.trim();if(!a||a===n){s(n);return}try{const l=await ga(i,{title:a}),O=Se.find(c=>c.id===i);O&&(O.title=l.title),s(l.title)}catch(l){console.error("rename failed",l),s(n)}};r.addEventListener("blur",()=>{o()}),r.addEventListener("keydown",a=>{a.key==="Enter"?(a.preventDefault(),r.blur()):a.key==="Escape"&&(a.preventDefault(),s(n))})}async function ez(){if(await Qa(4),!Se.length){ve=null,er();return}try{const i=localStorage.getItem(WQ);i&&Se.some(e=>e.id===i)&&(ve=i)}catch{}ya()}window.__editorThreadNew=()=>{KZ()},window.__editorThreadSwitch=i=>{dd(i)},window.__editorThreadRename=i=>{JZ(i)};const tz={done:"DONE",awaiting_customer:"AWAITING YOU",wont_do:"WONT DO",blocked:"BLOCKED",feedback:"NEW FEEDBACK",idle:"IDLE",running:"RUNNING"};function hd(i){const e=tz[i]||i.toUpperCase();return`<span class="status-pill" data-status="${g(i)}">${g(e)}</span>`}function fd(i){if(!i)return"";let e;if(/^\d+Z$/.test(i)?e=new Date(parseInt(i,10)*1e3):e=new Date(i),isNaN(e.getTime()))return i;const t=n=>String(n).padStart(2,"0");return`${t(e.getDate())}/${t(e.getMonth()+1)}/${e.getFullYear()} ${t(e.getHours())}:${t(e.getMinutes())}`}let Jn=null;function ya(){const i=s=>document.getElementById(s),e=i("threads-pane-head-list");e&&(e.hidden=!1);const t=i("threads-pane-head-detail");t&&(t.hidden=!0);const n=i("threads-list-view");n&&(n.hidden=!1);const r=i("threads-detail-view");r&&(r.hidden=!0),Qa().then(er)}async function ba(i){await dd(i);const e=Se.find(c=>c.id===i);if(!e)return;const t=c=>document.getElementById(c),n=t("threads-pane-head-list");n&&(n.hidden=!0);const r=t("threads-pane-head-detail");r&&(r.hidden=!1);const s=t("threads-list-view");s&&(s.hidden=!0);const o=t("threads-detail-view");o&&(o.hidden=!1);const a=t("threads-detail-title");a&&(a.textContent=e.title||"Thread");const l=t("threads-detail-meta"),O=e.sender?`<span>📨 from ${g(e.sender)}</span>`:"";l&&(l.innerHTML=`${hd(e.status_hint||"idle")} <span>${g(fd(e.last_message_at))}</span> ${O}`),pd(i),setTimeout(()=>{var c;return(c=t("threads-reply-input"))==null?void 0:c.focus()},30)}async function iz(){if(!ve)return;const i=ve;try{await ga(i,{archived:!0,closure_reason:"done"});const e=Se.find(t=>t.id===i);e&&(e.archived=!0,e.closure_reason="done"),ve=null,ya()}catch(e){console.error("archive failed",e)}}async function nz(i){try{await ga(i,{archived:!0});const e=Se.find(t=>t.id===i);e&&(e.archived=!0),ve===i?(ve=null,ya()):er()}catch(e){console.error("archive-from-list failed",e)}}function er(){const i=document.getElementById("threads-rows");if(!i)return;if(!Se.length){i.innerHTML=cd?`<div class="threads-empty">
989
989
  <div style="margin-bottom:0.6rem">Can't reach the agent — threads unavailable.</div>
990
990
  <button type="button" class="action-pill"
991
991
  onclick="window.__threadsRetry()">Retry</button>
@@ -1012,7 +1012,7 @@ ${t.traceback||""}`;window.__switchTab("chat"),setTimeout(()=>{window.__prefillC
1012
1012
  <span>severity: ${g(e.severity||"-")}</span>
1013
1013
  </div>
1014
1014
  <div style="margin-top:0.3rem">${g(e.summary||"")}</div>
1015
- </div>`}catch{return fs(i)}}async function az(){try{const i=await cs();Se.push(i),rt.set(i.id,[]),await ba(i.id)}catch(i){console.error("threadsNew failed",i)}}async function lz(){if(!ve)return;const i=Se.find(t=>t.id===ve);if(!i)return;const e=window.prompt("Rename thread:",i.title);if(!(e==null||e.trim()===""||e===i.title))try{const t=await ga(ve,{title:e.trim()});i.title=t.title;const n=document.getElementById("threads-detail-title");n&&(n.textContent=t.title),er()}catch(t){console.error("rename failed",t)}}function Oz(i){return i?i.status_hint==="done"||i.status_hint==="wont_do"||i.closure_reason==="done"||i.closure_reason==="wont_do":!1}async function md(){const i=document.getElementById("threads-reply-input");if(!i)return;let e=i.value.trim();if(!e)return;i.value="";const t=e.match(/^new topic:\s*(.*)$/is);if(t){const o=t[1].trim();try{const a=await cs(o.slice(0,80)||void 0);if(Se.push(a),rt.set(a.id,[]),await ba(a.id),!o)return;e=o}catch(a){console.error("New Topic spawn failed",a);return}}if(!t&&ve&&Oz(Se.find(o=>o.id===ve)))try{const o=await cs(e.slice(0,80)||void 0);Se.push(o),rt.set(o.id,[]),await ba(o.id)}catch(o){console.error("auto-new-thread on done reply failed",o);return}if(!ve)try{const o=await cs(e.slice(0,80));Se.push(o),rt.set(o.id,[]),ve=o.id}catch(o){console.error("auto-create failed",o);return}const n=ve,r=rt.get(n)||[];r.push({id:`local-${Date.now()}`,role:"user",content:e,timestamp:new Date().toISOString(),thread_id:n}),rt.set(n,r),pd(n),$a.add(n),er();const s=document.getElementById("threads-chat");Jn==null||Jn.abort(),Jn=new AbortController;try{await pz(e,n,Jn.signal,s)}catch(o){if((o==null?void 0:o.name)!=="AbortError"){const a=document.createElement("div");a.className="ai-msg ai-bot",a.style.color="var(--danger,#f38ba8)",a.textContent=`Connection failed: ${(o==null?void 0:o.message)||o}`,s==null||s.appendChild(a)}}finally{$a.delete(n),Jn=null;try{await AQ(n,!0)}catch{}await Qa(),pd(n);const o=Se.find(a=>a.id===n);if(o){const a=document.getElementById("threads-detail-meta"),l=o.sender?`<span>📨 from ${g(o.sender)}</span>`:"";a.innerHTML=`${hd(o.status_hint||"idle")} <span>${g(fd(o.last_message_at))}</span> ${l}`}}}queueMicrotask(()=>{const i=document.getElementById("threads-reply-form");i&&i.addEventListener("submit",t=>{t.preventDefault(),md()});const e=document.getElementById("threads-reply-input");e&&e.addEventListener("keydown",t=>{t.key==="Enter"&&!t.shiftKey&&(t.preventDefault(),t.stopPropagation(),md())})}),window.__threadsShowList=()=>ya(),window.__threadsShowDetail=i=>{ba(i)},window.__threadsNew=()=>{az()},window.__threadsRetry=()=>{Qa(2).then(er)},window.__threadsRenameActive=()=>{lz()};let un=!1;async function jQ(){const i=document.getElementById("plans-panel"),e=document.getElementById("plans-toggle-btn");i&&(un=!un,i.hidden=!un,e==null||e.classList.toggle("active",un),un&&await cz())}async function cz(){const i=document.getElementById("plans-rows");if(i){i.innerHTML='<div class="threads-empty">Loading plans…</div>';try{const e=await Mi("plan_list",{}),t=e.ok&&Array.isArray(e.result)?e.result:[];if(!t.length){i.innerHTML='<div class="threads-empty">No plans yet — they appear here when the planner agent creates one.</div>';return}const n=[...t].sort((r,s)=>String(s.name||s.file||"").localeCompare(String(r.name||r.file||"")));i.innerHTML=n.map(r=>{var h,f;const s=String(r.name||r.file||""),o=String(r.path||`plan/${s}`),a=String(r.title||s).slice(0,60),l=r.steps_done??((h=r.progress)==null?void 0:h.done)??0,O=r.steps_total??((f=r.progress)==null?void 0:f.total)??0,c=O>0?`${l}/${O} steps`:"",d=r.is_current||r.current?" · ★ current":"";return`<div class="plan-row" onclick="window.__plansOpen('${g(o)}')" title="Open ${g(o)} in editor">
1015
+ </div>`}catch{return fs(i)}}async function az(){try{const i=await cs();Se.push(i),rt.set(i.id,[]),await ba(i.id)}catch(i){console.error("threadsNew failed",i)}}async function lz(){if(!ve)return;const i=Se.find(t=>t.id===ve);if(!i)return;const e=window.prompt("Rename thread:",i.title);if(!(e==null||e.trim()===""||e===i.title))try{const t=await ga(ve,{title:e.trim()});i.title=t.title;const n=document.getElementById("threads-detail-title");n&&(n.textContent=t.title),er()}catch(t){console.error("rename failed",t)}}function Oz(i){return i?i.status_hint==="done"||i.status_hint==="wont_do"||i.closure_reason==="done"||i.closure_reason==="wont_do":!1}async function md(){const i=document.getElementById("threads-reply-input");if(!i)return;let e=i.value.trim();if(!e)return;i.value="";const t=e.match(/^new topic:\s*(.*)$/is);if(t){const o=t[1].trim();try{const a=await cs(o.slice(0,80)||void 0);if(Se.push(a),rt.set(a.id,[]),await ba(a.id),!o)return;e=o}catch(a){console.error("New Topic spawn failed",a);return}}if(!t&&ve&&Oz(Se.find(o=>o.id===ve)))try{const o=await cs(e.slice(0,80)||void 0);Se.push(o),rt.set(o.id,[]),await ba(o.id)}catch(o){console.error("auto-new-thread on done reply failed",o);return}if(!ve)try{const o=await cs(e.slice(0,80));Se.push(o),rt.set(o.id,[]),ve=o.id}catch(o){console.error("auto-create failed",o);return}const n=ve,r=rt.get(n)||[];r.push({id:`local-${Date.now()}`,role:"user",content:e,timestamp:new Date().toISOString(),thread_id:n}),rt.set(n,r),pd(n),$a.add(n),er();const s=document.getElementById("threads-chat");Jn==null||Jn.abort(),Jn=new AbortController;try{await pz(e,n,Jn.signal,s)}catch(o){if((o==null?void 0:o.name)!=="AbortError"){const a=document.createElement("div");a.className="ai-msg ai-bot",a.style.color="var(--danger,#f38ba8)",a.textContent=`Connection failed: ${(o==null?void 0:o.message)||o}`,s==null||s.appendChild(a)}}finally{$a.delete(n),Jn=null;try{await AQ(n,!0)}catch{}await Qa(),pd(n);const o=Se.find(l=>l.id===n),a=document.getElementById("threads-detail-meta");if(o&&a){const l=o.sender?`<span>📨 from ${g(o.sender)}</span>`:"";a.innerHTML=`${hd(o.status_hint||"idle")} <span>${g(fd(o.last_message_at))}</span> ${l}`}}}queueMicrotask(()=>{const i=document.getElementById("threads-reply-form");i&&i.addEventListener("submit",t=>{t.preventDefault(),md()});const e=document.getElementById("threads-reply-input");e&&e.addEventListener("keydown",t=>{t.key==="Enter"&&!t.shiftKey&&(t.preventDefault(),t.stopPropagation(),md())})}),window.__threadsShowList=()=>ya(),window.__threadsShowDetail=i=>{ba(i)},window.__threadsNew=()=>{az()},window.__threadsRetry=()=>{Qa(2).then(er)},window.__threadsRenameActive=()=>{lz()};let un=!1;async function jQ(){const i=document.getElementById("plans-panel"),e=document.getElementById("plans-toggle-btn");i&&(un=!un,i.hidden=!un,e==null||e.classList.toggle("active",un),un&&await cz())}async function cz(){const i=document.getElementById("plans-rows");if(i){i.innerHTML='<div class="threads-empty">Loading plans…</div>';try{const e=await Mi("plan_list",{}),t=e.ok&&Array.isArray(e.result)?e.result:[];if(!t.length){i.innerHTML='<div class="threads-empty">No plans yet — they appear here when the planner agent creates one.</div>';return}const n=[...t].sort((r,s)=>String(s.name||s.file||"").localeCompare(String(r.name||r.file||"")));i.innerHTML=n.map(r=>{var h,f;const s=String(r.name||r.file||""),o=String(r.path||`plan/${s}`),a=String(r.title||s).slice(0,60),l=r.steps_done??((h=r.progress)==null?void 0:h.done)??0,O=r.steps_total??((f=r.progress)==null?void 0:f.total)??0,c=O>0?`${l}/${O} steps`:"",d=r.is_current||r.current?" · ★ current":"";return`<div class="plan-row" onclick="window.__plansOpen('${g(o)}')" title="Open ${g(o)} in editor">
1016
1016
  <div class="plan-name">${g(a)}</div>
1017
1017
  <div class="plan-meta">${g(s)}${c?" · "+g(c):""}${d}</div>
1018
1018
  </div>`}).join("")}catch{i.innerHTML='<div class="threads-empty" style="color:var(--danger,#f38ba8)">Failed to load plans</div>'}}}async function dz(i){var t;try{await ui(i)}catch(n){console.error("plansOpen failed",n)}un=!1;const e=document.getElementById("plans-panel");e&&(e.hidden=!0),(t=document.getElementById("plans-toggle-btn"))==null||t.classList.remove("active")}window.__plansToggle=()=>{jQ()},window.__plansOpen=i=>{dz(i)};let ds=!1;async function hz(){const i=document.getElementById("grounding-panel"),e=document.getElementById("grounding-toggle-btn");i&&(un&&jQ(),ds=!ds,i.hidden=!ds,e==null||e.classList.toggle("active",ds),ds&&await MQ())}async function MQ(){const i=document.getElementById("grounding-body");if(!i)return;i.innerHTML='<div class="threads-empty">Loading…</div>';let e={};try{const r=await fetch("/__dev/api/grounding/status");r.ok&&(e=await r.json())}catch{}const t=g(e.url||"https://mcp.tina4.com"),n=e.configured?`<span style="color:var(--success,#a6e3a1)">&#9679; Configured</span> <span style="opacity:0.6">(…${g(e.last4||"")})</span>`:'<span style="color:var(--warn,#f9e2af)">&#9675; Not set</span> — using local corpus fallback';i.innerHTML=`
@@ -10,6 +10,37 @@ var SafeString = class {
10
10
  return this.value;
11
11
  }
12
12
  };
13
+ var KNOWN_TAGS = /* @__PURE__ */ new Set([
14
+ "autoescape",
15
+ "block",
16
+ "cache",
17
+ "extends",
18
+ "for",
19
+ "from",
20
+ "if",
21
+ "import",
22
+ "include",
23
+ "live",
24
+ "macro",
25
+ "raw",
26
+ "set",
27
+ "spaceless"
28
+ ]);
29
+ var TERMINATOR_TAGS = /* @__PURE__ */ new Set([
30
+ "elif",
31
+ "else",
32
+ "elseif",
33
+ "endautoescape",
34
+ "endblock",
35
+ "endcache",
36
+ "endfor",
37
+ "endif",
38
+ "endlive",
39
+ "endmacro",
40
+ "endraw",
41
+ "endset",
42
+ "endspaceless"
43
+ ]);
13
44
  function jsonText(value) {
14
45
  try {
15
46
  const text = JSON.stringify(value);
@@ -1668,8 +1699,11 @@ var Frond = class _Frond {
1668
1699
  i = skip;
1669
1700
  }
1670
1701
  } else if (tag === "set") {
1702
+ const isBlockSet = !content.includes("=");
1671
1703
  if (this._sandbox && this._allowedTags !== null && !this._allowedTags.has("set")) {
1672
- i++;
1704
+ i = isBlockSet ? this.skipBlock(tokens, i, "set", "endset") : i + 1;
1705
+ } else if (isBlockSet) {
1706
+ i = this.handleSetBlock(tokens, i, context);
1673
1707
  } else {
1674
1708
  this.handleSet(content, context);
1675
1709
  i++;
@@ -1711,6 +1745,11 @@ var Frond = class _Frond {
1711
1745
  i++;
1712
1746
  } else {
1713
1747
  i++;
1748
+ if (tag !== "" && !TERMINATOR_TAGS.has(tag)) {
1749
+ throw new Error(
1750
+ `Frond: unknown tag "${tag}" -- known tags are: ${[...KNOWN_TAGS].sort().join(", ")}`
1751
+ );
1752
+ }
1714
1753
  }
1715
1754
  if (stripA && i < tokens.length && tokens[i][0] === "TEXT") {
1716
1755
  tokens[i] = ["TEXT", tokens[i][1].replace(LEADING_WS_RE, "")];
@@ -2607,6 +2646,47 @@ var Frond = class _Frond {
2607
2646
  }
2608
2647
  return html;
2609
2648
  }
2649
+ /**
2650
+ * {% set name %}...{% endset %} -- render the body and bind it.
2651
+ *
2652
+ * Emits nothing itself. The captured value is a SafeString because it is
2653
+ * template output that has already been escaped on the way in; re-escaping it
2654
+ * at {{ name }} would double-encode every entity. Twig and Jinja2 both mark the
2655
+ * capture safe. Returns the index just past {% endset %}.
2656
+ */
2657
+ handleSetBlock(tokens, start, context) {
2658
+ const [content] = stripTag(tokens[start][1]);
2659
+ const name = (content.split(/\s+/)[1] || "").trim();
2660
+ const bodyTokens = [];
2661
+ let i = start + 1;
2662
+ let depth = 0;
2663
+ while (i < tokens.length) {
2664
+ if (tokens[i][0] === "BLOCK") {
2665
+ const [tagContent] = stripTag(tokens[i][1]);
2666
+ const tag = tagContent.split(/\s+/)[0] || "";
2667
+ if (tag === "set" && !tagContent.includes("=")) {
2668
+ depth++;
2669
+ bodyTokens.push(tokens[i]);
2670
+ } else if (tag === "endset") {
2671
+ if (depth === 0) {
2672
+ i++;
2673
+ break;
2674
+ }
2675
+ depth--;
2676
+ bodyTokens.push(tokens[i]);
2677
+ } else {
2678
+ bodyTokens.push(tokens[i]);
2679
+ }
2680
+ } else {
2681
+ bodyTokens.push(tokens[i]);
2682
+ }
2683
+ i++;
2684
+ }
2685
+ if (name) {
2686
+ context[name] = new SafeString(this.renderTokens([...bodyTokens], context));
2687
+ }
2688
+ return i;
2689
+ }
2610
2690
  handleSpaceless(tokens, start, context) {
2611
2691
  const bodyTokens = [];
2612
2692
  let i = start + 1;
@@ -34,6 +34,31 @@ class SafeString {
34
34
  toString() { return this.value; }
35
35
  }
36
36
 
37
+ /**
38
+ * Every tag that OPENS a construct.
39
+ *
40
+ * An unknown tag is a typo, and 3.13.89 makes it throw rather than render its
41
+ * body: a mistyped guard -- {% iff is_admin %} instead of {% if is_admin %} --
42
+ * used to render the gated content UNCONDITIONALLY, so a reviewer saw a guard
43
+ * that was not there. Twig and Jinja2 both raise on an unknown tag; Frond now
44
+ * does too. There is no user-extension point for tags in any of the four
45
+ * frameworks, so an unknown name is always a mistake, never a plugin.
46
+ */
47
+ const KNOWN_TAGS = new Set([
48
+ "autoescape", "block", "cache", "extends", "for", "from", "if", "import",
49
+ "include", "live", "macro", "raw", "set", "spaceless",
50
+ ]);
51
+
52
+ /**
53
+ * Terminators and branch keywords. These reach the tag dispatch only when stray
54
+ * (their own collector consumes them in the normal case), and a stray one keeps
55
+ * the old render-nothing behaviour -- see the comment at the throw.
56
+ */
57
+ const TERMINATOR_TAGS = new Set([
58
+ "elif", "else", "elseif", "endautoescape", "endblock", "endcache", "endfor",
59
+ "endif", "endlive", "endmacro", "endraw", "endset", "endspaceless",
60
+ ]);
61
+
37
62
  /**
38
63
  * Serialize a value to compact JSON text that is always valid JSON.
39
64
  *
@@ -2077,8 +2102,16 @@ export class Frond {
2077
2102
  i = skip;
2078
2103
  }
2079
2104
  } else if (tag === "set") {
2105
+ // An assignment has an "="; without one this is the BLOCK form,
2106
+ // {% set name %}...{% endset %}, which captures its rendered body. A
2107
+ // bare includes() is exact here, not a shortcut: the block form's tag
2108
+ // content is only ever "set <name>", so an "=" anywhere -- even inside
2109
+ // a quoted value like {% set m = "a = b" %} -- means assignment.
2110
+ const isBlockSet = !content.includes("=");
2080
2111
  if (this._sandbox && this._allowedTags !== null && !this._allowedTags.has("set")) {
2081
- i++;
2112
+ i = isBlockSet ? this.skipBlock(tokens, i, "set", "endset") : i + 1;
2113
+ } else if (isBlockSet) {
2114
+ i = this.handleSetBlock(tokens, i, context);
2082
2115
  } else {
2083
2116
  this.handleSet(content, context);
2084
2117
  i++;
@@ -2120,6 +2153,16 @@ export class Frond {
2120
2153
  i++; // Already handled
2121
2154
  } else {
2122
2155
  i++;
2156
+ if (tag !== "" && !TERMINATOR_TAGS.has(tag)) {
2157
+ throw new Error(
2158
+ `Frond: unknown tag "${tag}" -- known tags are: ${[...KNOWN_TAGS].sort().join(", ")}`,
2159
+ );
2160
+ }
2161
+ // An empty tag ({% %}) or a stray terminator (an {% endif %} with
2162
+ // no {% if %}): no output.
2163
+ // Malformed, but it has always rendered nothing, and nothing is the
2164
+ // safe answer -- unlike an unknown tag it cannot expose content that
2165
+ // was meant to be gated.
2123
2166
  }
2124
2167
 
2125
2168
  if (stripA && i < tokens.length && tokens[i][0] === "TEXT") {
@@ -3121,6 +3164,50 @@ export class Frond {
3121
3164
  return html;
3122
3165
  }
3123
3166
 
3167
+ /**
3168
+ * {% set name %}...{% endset %} -- render the body and bind it.
3169
+ *
3170
+ * Emits nothing itself. The captured value is a SafeString because it is
3171
+ * template output that has already been escaped on the way in; re-escaping it
3172
+ * at {{ name }} would double-encode every entity. Twig and Jinja2 both mark the
3173
+ * capture safe. Returns the index just past {% endset %}.
3174
+ */
3175
+ private handleSetBlock(tokens: Token[], start: number, context: Record<string, unknown>): number {
3176
+ const [content] = stripTag(tokens[start][1]);
3177
+ const name = (content.split(/\s+/)[1] || "").trim();
3178
+
3179
+ const bodyTokens: Token[] = [];
3180
+ let i = start + 1;
3181
+ let depth = 0;
3182
+ while (i < tokens.length) {
3183
+ if (tokens[i][0] === "BLOCK") {
3184
+ const [tagContent] = stripTag(tokens[i][1]);
3185
+ const tag = tagContent.split(/\s+/)[0] || "";
3186
+ if (tag === "set" && !tagContent.includes("=")) {
3187
+ depth++;
3188
+ bodyTokens.push(tokens[i]);
3189
+ } else if (tag === "endset") {
3190
+ if (depth === 0) {
3191
+ i++;
3192
+ break;
3193
+ }
3194
+ depth--;
3195
+ bodyTokens.push(tokens[i]);
3196
+ } else {
3197
+ bodyTokens.push(tokens[i]);
3198
+ }
3199
+ } else {
3200
+ bodyTokens.push(tokens[i]);
3201
+ }
3202
+ i++;
3203
+ }
3204
+
3205
+ if (name) {
3206
+ context[name] = new SafeString(this.renderTokens([...bodyTokens], context));
3207
+ }
3208
+ return i;
3209
+ }
3210
+
3124
3211
  private handleSpaceless(tokens: Token[], start: number, context: Record<string, unknown>): [string, number] {
3125
3212
  const bodyTokens: Token[] = [];
3126
3213
  let i = start + 1;
@@ -4428,7 +4428,7 @@ function _generateFormToken(descriptor = "") {
4428
4428
  function _generateFormTokenValue(descriptor = "") {
4429
4429
  return new SafeString(_buildFormTokenJwt(descriptor));
4430
4430
  }
4431
- var SafeString, JSON_UNSAFE_RE, JSON_UNSAFE_MAP, NUMERIC_RE, METHOD_CALL_RE, FN_CALL_RE, IS_NOT_RE, IS_RE, NOT_IN_RE, IN_RE, DIVISIBLE_BY_RE, FILTER_WITH_ARGS_RE, FILTER_COMPARISON_RE, TITLE_WORD_RE, STRIP_TAGS_RE, FORMAT_RE, LEADING_WS_RE, TRAILING_WS_RE, THOUSANDS_RE, LIVE_RE, LIVE_WS_RE, LIVE_SRC_RE, filterChainCache, pathParseCache, TEMPLATE_CACHE_MAX, TOKEN_RE, RAW_BLOCK_RE, VarRef, BUILTIN_FILTERS, _formTokenSessionId, Frond;
4431
+ var SafeString, KNOWN_TAGS, TERMINATOR_TAGS, JSON_UNSAFE_RE, JSON_UNSAFE_MAP, NUMERIC_RE, METHOD_CALL_RE, FN_CALL_RE, IS_NOT_RE, IS_RE, NOT_IN_RE, IN_RE, DIVISIBLE_BY_RE, FILTER_WITH_ARGS_RE, FILTER_COMPARISON_RE, TITLE_WORD_RE, STRIP_TAGS_RE, FORMAT_RE, LEADING_WS_RE, TRAILING_WS_RE, THOUSANDS_RE, LIVE_RE, LIVE_WS_RE, LIVE_SRC_RE, filterChainCache, pathParseCache, TEMPLATE_CACHE_MAX, TOKEN_RE, RAW_BLOCK_RE, VarRef, BUILTIN_FILTERS, _formTokenSessionId, Frond;
4432
4432
  var init_engine = __esm({
4433
4433
  "../frond/src/engine.ts"() {
4434
4434
  "use strict";
@@ -4440,6 +4440,37 @@ var init_engine = __esm({
4440
4440
  return this.value;
4441
4441
  }
4442
4442
  };
4443
+ KNOWN_TAGS = /* @__PURE__ */ new Set([
4444
+ "autoescape",
4445
+ "block",
4446
+ "cache",
4447
+ "extends",
4448
+ "for",
4449
+ "from",
4450
+ "if",
4451
+ "import",
4452
+ "include",
4453
+ "live",
4454
+ "macro",
4455
+ "raw",
4456
+ "set",
4457
+ "spaceless"
4458
+ ]);
4459
+ TERMINATOR_TAGS = /* @__PURE__ */ new Set([
4460
+ "elif",
4461
+ "else",
4462
+ "elseif",
4463
+ "endautoescape",
4464
+ "endblock",
4465
+ "endcache",
4466
+ "endfor",
4467
+ "endif",
4468
+ "endlive",
4469
+ "endmacro",
4470
+ "endraw",
4471
+ "endset",
4472
+ "endspaceless"
4473
+ ]);
4443
4474
  JSON_UNSAFE_RE = /[<>&'\u2028\u2029]/g;
4444
4475
  JSON_UNSAFE_MAP = {
4445
4476
  "<": "\\u003c",
@@ -5094,8 +5125,11 @@ var init_engine = __esm({
5094
5125
  i = skip;
5095
5126
  }
5096
5127
  } else if (tag === "set") {
5128
+ const isBlockSet = !content.includes("=");
5097
5129
  if (this._sandbox && this._allowedTags !== null && !this._allowedTags.has("set")) {
5098
- i++;
5130
+ i = isBlockSet ? this.skipBlock(tokens, i, "set", "endset") : i + 1;
5131
+ } else if (isBlockSet) {
5132
+ i = this.handleSetBlock(tokens, i, context);
5099
5133
  } else {
5100
5134
  this.handleSet(content, context);
5101
5135
  i++;
@@ -5137,6 +5171,11 @@ var init_engine = __esm({
5137
5171
  i++;
5138
5172
  } else {
5139
5173
  i++;
5174
+ if (tag !== "" && !TERMINATOR_TAGS.has(tag)) {
5175
+ throw new Error(
5176
+ `Frond: unknown tag "${tag}" -- known tags are: ${[...KNOWN_TAGS].sort().join(", ")}`
5177
+ );
5178
+ }
5140
5179
  }
5141
5180
  if (stripA && i < tokens.length && tokens[i][0] === "TEXT") {
5142
5181
  tokens[i] = ["TEXT", tokens[i][1].replace(LEADING_WS_RE, "")];
@@ -6033,6 +6072,47 @@ var init_engine = __esm({
6033
6072
  }
6034
6073
  return html;
6035
6074
  }
6075
+ /**
6076
+ * {% set name %}...{% endset %} -- render the body and bind it.
6077
+ *
6078
+ * Emits nothing itself. The captured value is a SafeString because it is
6079
+ * template output that has already been escaped on the way in; re-escaping it
6080
+ * at {{ name }} would double-encode every entity. Twig and Jinja2 both mark the
6081
+ * capture safe. Returns the index just past {% endset %}.
6082
+ */
6083
+ handleSetBlock(tokens, start2, context) {
6084
+ const [content] = stripTag(tokens[start2][1]);
6085
+ const name = (content.split(/\s+/)[1] || "").trim();
6086
+ const bodyTokens = [];
6087
+ let i = start2 + 1;
6088
+ let depth = 0;
6089
+ while (i < tokens.length) {
6090
+ if (tokens[i][0] === "BLOCK") {
6091
+ const [tagContent] = stripTag(tokens[i][1]);
6092
+ const tag = tagContent.split(/\s+/)[0] || "";
6093
+ if (tag === "set" && !tagContent.includes("=")) {
6094
+ depth++;
6095
+ bodyTokens.push(tokens[i]);
6096
+ } else if (tag === "endset") {
6097
+ if (depth === 0) {
6098
+ i++;
6099
+ break;
6100
+ }
6101
+ depth--;
6102
+ bodyTokens.push(tokens[i]);
6103
+ } else {
6104
+ bodyTokens.push(tokens[i]);
6105
+ }
6106
+ } else {
6107
+ bodyTokens.push(tokens[i]);
6108
+ }
6109
+ i++;
6110
+ }
6111
+ if (name) {
6112
+ context[name] = new SafeString(this.renderTokens([...bodyTokens], context));
6113
+ }
6114
+ return i;
6115
+ }
6036
6116
  handleSpaceless(tokens, start2, context) {
6037
6117
  const bodyTokens = [];
6038
6118
  let i = start2 + 1;