tina4-nodejs 3.12.10 → 3.13.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/CLAUDE.md CHANGED
@@ -1,10 +1,10 @@
1
- # CLAUDE.md — AI Developer Guide for tina4-nodejs (v3.12.10)
1
+ # CLAUDE.md — AI Developer Guide for tina4-nodejs (v3.13.0)
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.12.10 — 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.0 — 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
 
@@ -548,7 +548,7 @@ r.group("/api/v1", (g) => {
548
548
 
549
549
  ## Module: Database (`packages/orm/src/database.ts`)
550
550
 
551
- Full Database API. The same instance covers all five drivers (sqlite, postgres, mysql, mssql, firebird) — pick the driver via `DATABASE_URL` or pass a `DatabaseConfig` to `initDatabase()`.
551
+ Full Database API. The same instance covers all five drivers (sqlite, postgres, mysql, mssql, firebird) — pick the driver via `TINA4_DATABASE_URL` or pass a `DatabaseConfig` to `initDatabase()`.
552
552
 
553
553
  ```typescript
554
554
  import { initDatabase, Database, DatabaseResult } from "@tina4/orm";
@@ -956,26 +956,26 @@ import { Router } from "./router.js"; // .js even though the file is .ts
956
956
  ## Database Configuration
957
957
 
958
958
  ### Connection string format
959
- Set `DATABASE_URL` in your `.env` file using `driver://host:port/database` format:
959
+ Set `TINA4_DATABASE_URL` in your `.env` file using `driver://host:port/database` format:
960
960
 
961
961
  ```bash
962
962
  # SQLite (default if nothing configured)
963
- DATABASE_URL=sqlite:///path/to/db.sqlite
964
- DATABASE_URL=sqlite://./data/tina4.db
963
+ TINA4_DATABASE_URL=sqlite:///path/to/db.sqlite
964
+ TINA4_DATABASE_URL=sqlite://./data/tina4.db
965
965
 
966
966
  # PostgreSQL
967
- DATABASE_URL=postgres://localhost:5432/mydb
968
- DATABASE_URL=postgresql://localhost:5432/mydb
967
+ TINA4_DATABASE_URL=postgres://localhost:5432/mydb
968
+ TINA4_DATABASE_URL=postgresql://localhost:5432/mydb
969
969
 
970
970
  # MySQL
971
- DATABASE_URL=mysql://localhost:3306/mydb
971
+ TINA4_DATABASE_URL=mysql://localhost:3306/mydb
972
972
 
973
973
  # MSSQL / SQL Server (both schemes work)
974
- DATABASE_URL=mssql://localhost:1433/mydb
975
- DATABASE_URL=sqlserver://localhost:1433/mydb
974
+ TINA4_DATABASE_URL=mssql://localhost:1433/mydb
975
+ TINA4_DATABASE_URL=sqlserver://localhost:1433/mydb
976
976
 
977
977
  # Firebird
978
- DATABASE_URL=firebird://localhost:3050/mydb
978
+ TINA4_DATABASE_URL=firebird://localhost:3050/mydb
979
979
  ```
980
980
 
981
981
  ### Credentials
@@ -983,15 +983,15 @@ Credentials can be embedded in the URL or provided separately:
983
983
 
984
984
  ```bash
985
985
  # In the URL
986
- DATABASE_URL=postgres://user:pass@localhost:5432/mydb
986
+ TINA4_DATABASE_URL=postgres://user:pass@localhost:5432/mydb
987
987
 
988
988
  # Or as separate env vars (merged when URL has no credentials)
989
- DATABASE_URL=postgres://localhost:5432/mydb
990
- DATABASE_USERNAME=myuser
991
- DATABASE_PASSWORD=mypass
989
+ TINA4_DATABASE_URL=postgres://localhost:5432/mydb
990
+ TINA4_DATABASE_USERNAME=myuser
991
+ TINA4_DATABASE_PASSWORD=mypass
992
992
  ```
993
993
 
994
- Credential priority: `config.user` > `config.username` > `DATABASE_USERNAME` env var.
994
+ Credential priority: `config.user` > `config.username` > `TINA4_DATABASE_USERNAME` env var.
995
995
 
996
996
  ### Programmatic configuration
997
997
  ```typescript
package/package.json CHANGED
@@ -3,11 +3,20 @@
3
3
 
4
4
 
5
5
 
6
- "version": "3.12.10",
6
+ "version": "3.13.0",
7
7
 
8
8
  "type": "module",
9
- "description": "Tina4 for Node.js/TypeScript 54 built-in features, zero dependencies",
10
- "keywords": ["tina4", "framework", "web", "api", "orm", "graphql", "websocket", "typescript"],
9
+ "description": "Tina4 for Node.js/TypeScript \u2014 54 built-in features, zero dependencies",
10
+ "keywords": [
11
+ "tina4",
12
+ "framework",
13
+ "web",
14
+ "api",
15
+ "orm",
16
+ "graphql",
17
+ "websocket",
18
+ "typescript"
19
+ ],
11
20
  "homepage": "https://tina4.com/nodejs",
12
21
  "repository": {
13
22
  "type": "git",
@@ -59,4 +68,4 @@
59
68
  "tsx": "^4.19.0",
60
69
  "esbuild": "^0.24.0"
61
70
  }
62
- }
71
+ }
@@ -10,8 +10,14 @@
10
10
  */
11
11
  import { existsSync, readdirSync, readFileSync } from "node:fs";
12
12
  import { join, resolve } from "node:path";
13
+ import { loadEnv } from "../../../core/src/dotenv.js";
13
14
 
14
15
  export async function runMigrations(migrationDir?: string): Promise<void> {
16
+ // Load .env before initialising the DB so DATABASE_URL/TINA4_DATABASE_URL
17
+ // from the project's .env is visible. Without this the migrate command
18
+ // falls back to ./data/tina4.db regardless of what the project configured.
19
+ loadEnv();
20
+
15
21
  const dir = resolve(migrationDir ?? "migrations");
16
22
 
17
23
  if (!existsSync(dir)) {
@@ -40,11 +46,15 @@ export async function runMigrations(migrationDir?: string): Promise<void> {
40
46
  process.exit(1);
41
47
  }
42
48
 
43
- // Ensure database is initialised (uses DATABASE_URL or defaults to sqlite)
49
+ // Ensure database is initialised (uses TINA4_DATABASE_URL/DATABASE_URL or
50
+ // defaults to sqlite). initDatabase() is async — MUST be awaited, otherwise
51
+ // setAdapter() has not run by the time ensureMigrationTable() asks for the
52
+ // adapter and the whole CLI crashes with "No database adapter configured."
44
53
  try {
45
- initDatabase();
46
- } catch {
47
- // Adapter may already be set ignore
54
+ await initDatabase();
55
+ } catch (err) {
56
+ console.error(` Error initialising database: ${err instanceof Error ? err.message : String(err)}`);
57
+ process.exit(1);
48
58
  }
49
59
 
50
60
  ensureMigrationTable();
@@ -9,8 +9,13 @@
9
9
  * tina4 migrate:rollback ./path/to/migrations
10
10
  */
11
11
  import { resolve } from "node:path";
12
+ import { loadEnv } from "../../../core/src/dotenv.js";
12
13
 
13
14
  export async function migrateRollback(migrationDir?: string): Promise<void> {
15
+ // .env must load before initDatabase() — otherwise DATABASE_URL from the
16
+ // project's .env is invisible and we silently fall back to ./data/tina4.db.
17
+ loadEnv();
18
+
14
19
  const dir = resolve(migrationDir ?? "migrations");
15
20
 
16
21
  let initDatabase: typeof import("../../../orm/src/index.js").initDatabase;
@@ -29,11 +34,14 @@ export async function migrateRollback(migrationDir?: string): Promise<void> {
29
34
  process.exit(1);
30
35
  }
31
36
 
32
- // Ensure database is initialised
37
+ // Ensure database is initialised — MUST await; initDatabase() is async
38
+ // and calls setAdapter() inside the promise. Without await, the next call
39
+ // to getAdapter() throws "No database adapter configured."
33
40
  try {
34
- initDatabase();
35
- } catch {
36
- // Adapter may already be set ignore
41
+ await initDatabase();
42
+ } catch (err) {
43
+ console.error(` Error initialising database: ${err instanceof Error ? err.message : String(err)}`);
44
+ process.exit(1);
37
45
  }
38
46
 
39
47
  ensureMigrationTable();
@@ -6,8 +6,12 @@
6
6
  * tina4 migrate:status ./path/to/migrations
7
7
  */
8
8
  import { resolve } from "node:path";
9
+ import { loadEnv } from "../../../core/src/dotenv.js";
9
10
 
10
11
  export async function migrateStatus(migrationDir?: string): Promise<void> {
12
+ // .env must load before initDatabase() so the project's DATABASE_URL is seen.
13
+ loadEnv();
14
+
11
15
  const dir = resolve(migrationDir ?? "migrations");
12
16
 
13
17
  let initDatabase: typeof import("../../../orm/src/index.js").initDatabase;
@@ -24,11 +28,13 @@ export async function migrateStatus(migrationDir?: string): Promise<void> {
24
28
  process.exit(1);
25
29
  }
26
30
 
27
- // Ensure database is initialised
31
+ // Ensure database is initialised — MUST await; initDatabase() is async
32
+ // and calls setAdapter() inside. Without await, getAdapter() throws.
28
33
  try {
29
- initDatabase();
30
- } catch {
31
- // Adapter may already be set ignore
34
+ await initDatabase();
35
+ } catch (err) {
36
+ console.error(` Error initialising database: ${err instanceof Error ? err.message : String(err)}`);
37
+ process.exit(1);
32
38
  }
33
39
 
34
40
  ensureMigrationTable();
@@ -0,0 +1,96 @@
1
+ (function(){"use strict";(()=>{try{const t=window.location.pathname||"";return t.startsWith("/__dev")||t.startsWith("/__feedback")}catch{return!1}})()?console.info("tina4-feedback-widget: skipping on developer path"):window.__tina4FeedbackLoaded?console.warn("tina4-feedback-widget already loaded; skipping"):(window.__tina4FeedbackLoaded=!0,b());function b(){const c=(getComputedStyle(document.documentElement).getPropertyValue("--primary")||"").trim()||"#3b82f6";h(c);const l=m();document.body.appendChild(l);let e=null,u;const r=[];l.addEventListener("click",()=>{if(e){e.remove(),e=null,l.style.display="";return}e=g(),document.body.appendChild(e),l.style.display="none",setTimeout(()=>e?.querySelector("textarea")?.focus(),0)});function g(){const o=document.createElement("div");o.className="tina4-fb-modal",o.innerHTML=`
2
+ <div class="tina4-fb-head">
3
+ <span class="tina4-fb-title">Tell us what's not working</span>
4
+ <button type="button" class="tina4-fb-close" aria-label="Close">×</button>
5
+ </div>
6
+ <div class="tina4-fb-context">
7
+ <span>📍 ${p(location.pathname+location.search)}</span>
8
+ <span>📐 ${window.innerWidth}×${window.innerHeight}</span>
9
+ </div>
10
+ <div class="tina4-fb-chat" role="log"></div>
11
+ <form class="tina4-fb-form">
12
+ <textarea
13
+ rows="3"
14
+ placeholder="What's hard to use here? Be specific — which field, which button, what you expected."
15
+ aria-label="Feedback message"
16
+ ></textarea>
17
+ <button type="submit" class="tina4-fb-send">Send</button>
18
+ </form>
19
+ `,o.querySelector(".tina4-fb-close")?.addEventListener("click",()=>{o.remove(),e=null,l.style.display=""});const a=o.querySelector("form");return a.addEventListener("submit",n=>{n.preventDefault();const i=a.querySelector("textarea"),f=i.value.trim();f&&(i.value="",x(f))}),s(o),o}function s(o){const a=o.querySelector(".tina4-fb-chat");if(a){if(!r.length){a.innerHTML=`<div class="tina4-fb-hint">Your feedback lands directly with the team — no email loop. We'll ask a quick follow-up if we need to.</div>`;return}a.innerHTML=r.map(n=>`<div class="tina4-fb-msg ${n.role==="user"?"tina4-fb-user":"tina4-fb-ai"}">${p(n.text)}</div>`).join(""),a.scrollTop=a.scrollHeight}}async function x(o){if(!e)return;r.push({role:"user",text:o}),s(e),d(e,!0);const a={message:o,context:{url:location.pathname+location.search,viewport:`${window.innerWidth}x${window.innerHeight}`,ua:navigator.userAgent},conversation_id:u};let n;try{const i=await fetch("/__feedback/api/turn",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(a)});if(n=await i.json(),!i.ok){const f=n?.error||`HTTP ${i.status}`;r.push({role:"ai",text:`Couldn't send: ${f}`}),s(e),d(e,!1);return}}catch(i){r.push({role:"ai",text:`Network issue: ${i?.message||i}`}),s(e),d(e,!1);return}if("ask"in n)u=n.conversation_id,r.push({role:"ai",text:n.ask}),s(e),d(e,!1),e?.querySelector("textarea")?.focus();else if("final"in n)r.push({role:"ai",text:`Thanks — filed as: "${n.final.title}". The team will take it from here.`}),s(e),d(e,!1),u=void 0,r.length=0,setTimeout(()=>{e?.remove(),e=null,l.style.display=""},4500);else{const i=n?.error||"unexpected response";r.push({role:"ai",text:`Issue: ${i}`}),s(e),d(e,!1)}}function d(o,a){const n=o.querySelector(".tina4-fb-send"),i=o.querySelector("textarea");n&&(n.disabled=a,n.textContent=a?"Sending…":"Send"),i&&(i.disabled=a)}}function m(){const t=document.createElement("button");return t.type="button",t.className="tina4-fb-btn",t.setAttribute("aria-label","Send feedback"),t.innerHTML="💬",t.title="Tell us what's not working",t}function h(t){const c=document.createElement("style");c.id="tina4-fb-styles",c.textContent=`
20
+ .tina4-fb-btn {
21
+ position: fixed; bottom: 1.25rem; right: 1.25rem;
22
+ width: 48px; height: 48px; border-radius: 50%; border: none;
23
+ background: ${t}; color: white; font-size: 1.4rem;
24
+ box-shadow: 0 4px 12px rgba(0,0,0,0.18); cursor: pointer;
25
+ z-index: 2147483646; transition: transform 0.15s, box-shadow 0.15s;
26
+ display: flex; align-items: center; justify-content: center;
27
+ line-height: 1; padding: 0;
28
+ }
29
+ .tina4-fb-btn:hover { transform: scale(1.06); box-shadow: 0 6px 16px rgba(0,0,0,0.22); }
30
+ .tina4-fb-btn:active { transform: scale(0.96); }
31
+ .tina4-fb-modal {
32
+ position: fixed; bottom: 5rem; right: 1.25rem;
33
+ width: 340px; max-height: 480px; display: flex; flex-direction: column;
34
+ background: #1e1e2e; color: #cdd6f4;
35
+ border: 1px solid #313244; border-radius: 8px;
36
+ box-shadow: 0 8px 32px rgba(0,0,0,0.35);
37
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
38
+ font-size: 0.85rem; z-index: 2147483647;
39
+ animation: tina4-fb-in 0.18s ease-out;
40
+ }
41
+ @keyframes tina4-fb-in {
42
+ from { opacity: 0; transform: translateY(8px); }
43
+ to { opacity: 1; transform: translateY(0); }
44
+ }
45
+ .tina4-fb-head {
46
+ display: flex; align-items: center; justify-content: space-between;
47
+ padding: 0.6rem 0.8rem; border-bottom: 1px solid #313244;
48
+ }
49
+ .tina4-fb-title { font-weight: 600; font-size: 0.9rem; }
50
+ .tina4-fb-close {
51
+ background: transparent; border: none; color: #9399b2;
52
+ font-size: 1.4rem; line-height: 1; cursor: pointer; padding: 0 0.2rem;
53
+ }
54
+ .tina4-fb-close:hover { color: #cdd6f4; }
55
+ .tina4-fb-context {
56
+ display: flex; gap: 0.6rem; padding: 0.4rem 0.8rem;
57
+ font-size: 0.7rem; color: #9399b2;
58
+ border-bottom: 1px solid #313244;
59
+ font-family: ui-monospace, "SF Mono", Menlo, monospace;
60
+ }
61
+ .tina4-fb-chat {
62
+ flex: 1; overflow-y: auto; padding: 0.5rem 0.8rem;
63
+ display: flex; flex-direction: column; gap: 0.4rem;
64
+ min-height: 80px; max-height: 280px;
65
+ }
66
+ .tina4-fb-hint {
67
+ font-size: 0.75rem; color: #9399b2; line-height: 1.4; padding: 0.3rem 0;
68
+ }
69
+ .tina4-fb-msg {
70
+ padding: 0.4rem 0.6rem; border-radius: 6px;
71
+ max-width: 85%; word-wrap: break-word; line-height: 1.35;
72
+ }
73
+ .tina4-fb-user { align-self: flex-end; background: ${t}; color: white; }
74
+ .tina4-fb-ai { align-self: flex-start; background: #313244; }
75
+ .tina4-fb-form {
76
+ display: flex; flex-direction: column; gap: 0.4rem;
77
+ padding: 0.5rem 0.8rem 0.8rem; border-top: 1px solid #313244;
78
+ }
79
+ .tina4-fb-form textarea {
80
+ width: 100%; box-sizing: border-box; resize: vertical;
81
+ min-height: 60px; font-family: inherit; font-size: 0.82rem;
82
+ padding: 0.4rem 0.5rem; border: 1px solid #313244;
83
+ background: #11111b; color: #cdd6f4; border-radius: 4px;
84
+ line-height: 1.3;
85
+ }
86
+ .tina4-fb-form textarea:focus {
87
+ outline: none; border-color: ${t};
88
+ }
89
+ .tina4-fb-send {
90
+ align-self: flex-end; padding: 0.35rem 0.9rem;
91
+ background: ${t}; color: white; border: none; border-radius: 4px;
92
+ font-size: 0.8rem; font-weight: 500; cursor: pointer;
93
+ }
94
+ .tina4-fb-send:disabled { opacity: 0.55; cursor: wait; }
95
+ .tina4-fb-send:hover:not(:disabled) { filter: brightness(1.1); }
96
+ `,document.head.appendChild(c)}function p(t){return t.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#39;")}})();
@@ -79,12 +79,19 @@ export function getToken(
79
79
  }
80
80
 
81
81
  /**
82
- * Validate a JWT token and return the decoded payload, or false if invalid/expired.
82
+ * Validate a JWT token. Returns the decoded payload on success, `null` if
83
+ * invalid/expired/malformed.
83
84
  *
84
- * Secret is always read from `process.env.TINA4_SECRET`.
85
+ * 3.13.0 return type changed from `boolean` to `Record<string, unknown> | null`.
86
+ * Matches the convention used by `jsonwebtoken` and the Python / PHP / Ruby
87
+ * Auth.validToken signatures shipped at the same time. Legacy
88
+ * `if (validToken(t))` patterns keep working because a non-null object is
89
+ * truthy and null is falsy.
90
+ *
91
+ * Secret is read from `process.env.TINA4_SECRET` when not passed explicitly.
85
92
  * Algorithm is read from `process.env.TINA4_JWT_ALGORITHM` (default "HS256").
86
93
  */
87
- export function validToken(token: string, secret?: string, algorithm?: string): boolean {
94
+ export function validToken(token: string, secret?: string, algorithm?: string): Record<string, unknown> | null {
88
95
  const resolvedSecret = secret ?? process.env.TINA4_SECRET ?? "";
89
96
  if (!resolvedSecret) {
90
97
  console.warn("Auth: TINA4_SECRET not set in .env — using blank secret (insecure)");
@@ -92,24 +99,24 @@ export function validToken(token: string, secret?: string, algorithm?: string):
92
99
  const resolvedAlgorithm = algorithm ?? process.env.TINA4_JWT_ALGORITHM ?? "HS256";
93
100
  try {
94
101
  const parts = token.split(".");
95
- if (parts.length !== 3) return false;
102
+ if (parts.length !== 3) return null;
96
103
 
97
104
  const [h, p, sig] = parts;
98
105
  const signingInput = `${h}.${p}`;
99
106
 
100
107
  if (!verifySignature(signingInput, sig, resolvedSecret, resolvedAlgorithm)) {
101
- return false;
108
+ return null;
102
109
  }
103
110
 
104
111
  const payload = JSON.parse(base64urlDecode(p).toString()) as Record<string, unknown>;
105
112
 
106
113
  if (typeof payload.exp === "number" && Date.now() / 1000 > payload.exp) {
107
- return false;
114
+ return null;
108
115
  }
109
116
 
110
- return true;
117
+ return payload;
111
118
  } catch {
112
- return false;
119
+ return null;
113
120
  }
114
121
  }
115
122
 
@@ -18,6 +18,7 @@ import type { RouteHandler } from "./types.js";
18
18
  import { DevMailbox } from "./devMailbox.js";
19
19
  import { isTruthy } from "./dotenv.js";
20
20
  import { quickMetrics, fullAnalysis, fileDetail } from "./metrics.js";
21
+ import { registerFeedbackRoutes } from "./feedback.js";
21
22
 
22
23
  const cpuCount = osCpus().length;
23
24
 
@@ -433,6 +434,12 @@ export class DevAdmin {
433
434
  // Register error handlers to feed the ErrorTracker
434
435
  ErrorTracker.register();
435
436
 
437
+ // Customer feedback widget routes — gated at request time by
438
+ // TINA4_ENABLE_FEEDBACK + TINA4_FEEDBACK_WHITELIST. The handlers
439
+ // themselves are always registered (so toggling env vars doesn't
440
+ // require a server restart) but each request re-checks the gate.
441
+ registerFeedbackRoutes(router);
442
+
436
443
  const routes: Array<{ method: string; pattern: string; handler: RouteHandler }> = [
437
444
  // Dashboard
438
445
  { method: "GET", pattern: "/__dev", handler: handleDashboard },
@@ -478,8 +485,18 @@ export class DevAdmin {
478
485
  { method: "POST", pattern: "/__dev/api/websockets/disconnect", handler: handleWebsocketsDisconnect },
479
486
  // Tools
480
487
  { method: "POST", pattern: "/__dev/api/tool", handler: handleTool },
481
- // Chat
488
+ // Chat — proxies to Rust agent /chat (SSE passthrough). Forwards
489
+ // active_file and any other body keys verbatim. See proxyToSupervisor.
482
490
  { method: "POST", pattern: "/__dev/api/chat", handler: handleChat },
491
+ // Threads — proxies to Rust agent /threads. Mirrors Python's
492
+ // _api_threads + _api_threads_sub.
493
+ { method: "GET", pattern: "/__dev/api/threads", handler: handleThreads },
494
+ { method: "POST", pattern: "/__dev/api/threads", handler: handleThreads },
495
+ { method: "GET", pattern: "/__dev/api/threads/{id}", handler: handleThreadsSub },
496
+ { method: "PATCH", pattern: "/__dev/api/threads/{id}", handler: handleThreadsSub },
497
+ { method: "DELETE", pattern: "/__dev/api/threads/{id}", handler: handleThreadsSub },
498
+ { method: "GET", pattern: "/__dev/api/threads/{id}/messages", handler: handleThreadsSub },
499
+ { method: "POST", pattern: "/__dev/api/threads/{id}/messages", handler: handleThreadsSub },
483
500
  // Connections
484
501
  { method: "GET", pattern: "/__dev/api/connections", handler: handleConnections },
485
502
  { method: "POST", pattern: "/__dev/api/connections/test", handler: handleConnectionsTest },
@@ -623,6 +640,22 @@ const handleReload: RouteHandler = async (req, res) => {
623
640
  _reloadFile = (body?.file as string) || "";
624
641
  const reloadType = (body?.type as string) || "reload";
625
642
  console.log(` External reload trigger: ${reloadType}${_reloadFile ? ` (${_reloadFile})` : ""}`);
643
+
644
+ // Re-discover so new files in src/routes/ register without a server restart.
645
+ // rediscoverRoutes() is idempotent — already-loaded files are skipped, only
646
+ // the new ones run. Add the freshly-discovered routes to the default router.
647
+ try {
648
+ const { rediscoverRoutes } = await import("./routeDiscovery.js");
649
+ const newRoutes = await rediscoverRoutes();
650
+ if (newRoutes.length > 0) {
651
+ const { defaultRouter } = await import("./router.js");
652
+ for (const route of newRoutes) defaultRouter.addRoute(route);
653
+ console.log(` Re-discovered ${newRoutes.length} new route(s) on reload`);
654
+ }
655
+ } catch (err) {
656
+ console.error(` Re-discover on reload failed:`, err);
657
+ }
658
+
626
659
  res.json({ ok: true, type: reloadType });
627
660
  };
628
661
 
@@ -1155,19 +1188,204 @@ const handleTool: RouteHandler = (req, res) => {
1155
1188
  res.json({ tool, status: "executed", message: `Tool '${tool}' executed (stub)`, timestamp: new Date().toISOString() });
1156
1189
  };
1157
1190
 
1191
+ // -- Supervisor proxy helpers --
1192
+
1193
+ /**
1194
+ * Return the base URL for the co-located Rust agent server.
1195
+ *
1196
+ * Mirrors Python's `_supervisor_base_url()` in
1197
+ * `tina4_python/dev_admin/__init__.py`. Resolution order:
1198
+ * 1. `TINA4_SUPERVISOR_URL` — explicit full URL.
1199
+ * 2. `TINA4_AGENT_PORT` — explicit port on 127.0.0.1.
1200
+ * 3. `PORT` + 2000 — auto-derived (matches `tina4 serve` agent port).
1201
+ * 4. Fallback `http://127.0.0.1:9145` — matches standalone `tina4 agent`.
1202
+ */
1203
+ export function supervisorBaseUrl(): string {
1204
+ const explicit = (process.env.TINA4_SUPERVISOR_URL ?? "").replace(/\/+$/, "");
1205
+ if (explicit) return explicit;
1206
+ const agentPort = (process.env.TINA4_AGENT_PORT ?? "").trim();
1207
+ if (/^\d+$/.test(agentPort)) return `http://127.0.0.1:${parseInt(agentPort, 10)}`;
1208
+ const fwPort = (process.env.PORT ?? "").trim();
1209
+ if (/^\d+$/.test(fwPort)) return `http://127.0.0.1:${parseInt(fwPort, 10) + 2000}`;
1210
+ return "http://127.0.0.1:9145";
1211
+ }
1212
+
1213
+ /**
1214
+ * Forward a dev-admin request to the Rust agent server.
1215
+ *
1216
+ * Mirrors Python's `_proxy_to_supervisor()`. Strips the `/__dev/api` prefix,
1217
+ * forwards method/body/query verbatim to `<base>{downstreamPath}`, and pipes
1218
+ * the response back. SSE (`text/event-stream`) is streamed chunk-by-chunk so
1219
+ * progress events reach the SPA live instead of after the full multi-agent
1220
+ * run completes. When the agent is unreachable we respond with 503 and a
1221
+ * hint so the SPA can show a useful error.
1222
+ */
1223
+ async function proxyToSupervisor(
1224
+ req: any,
1225
+ res: any,
1226
+ downstreamPath: string,
1227
+ ): Promise<void> {
1228
+ const base = supervisorBaseUrl();
1229
+
1230
+ // Forward query string verbatim
1231
+ let qs = "";
1232
+ try {
1233
+ const reqUrl = new URL(req.url ?? "/", "http://localhost");
1234
+ if (reqUrl.search) qs = reqUrl.search;
1235
+ } catch { /* ignore */ }
1236
+ const target = `${base}${downstreamPath}${qs}`;
1237
+
1238
+ const method = (req.method ?? "GET").toUpperCase();
1239
+
1240
+ // Build the body for methods that carry one
1241
+ let bodyText: string | undefined;
1242
+ if (method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE") {
1243
+ const body = (req as any).body;
1244
+ if (body !== undefined && body !== null) {
1245
+ if (typeof body === "string") {
1246
+ bodyText = body;
1247
+ } else if (typeof body === "object") {
1248
+ // SPA→agent convention fixup (matches Python): `/execute` sends
1249
+ // plan_file as a bare filename but the rust agent expects a
1250
+ // project-relative path. Prepend `plan/` when no slash is present.
1251
+ let outBody: any = body;
1252
+ if (!Array.isArray(body)) {
1253
+ const pf = (body as any).plan_file;
1254
+ if (typeof pf === "string" && pf && !pf.includes("/")) {
1255
+ outBody = { ...body, plan_file: `plan/${pf}` };
1256
+ }
1257
+ }
1258
+ bodyText = JSON.stringify(outBody);
1259
+ }
1260
+ }
1261
+ }
1262
+
1263
+ // Heavy multi-agent endpoints get a generous timeout; metadata-only
1264
+ // /supervise/* and /threads/* calls return fast.
1265
+ const timeoutMs = downstreamPath === "/execute" || downstreamPath === "/chat" ? 600_000 : 30_000;
1266
+ const ctrl = new AbortController();
1267
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
1268
+
1269
+ let upstream: Response;
1270
+ try {
1271
+ upstream = await fetch(target, {
1272
+ method,
1273
+ headers: { "Content-Type": "application/json" },
1274
+ body: bodyText,
1275
+ signal: ctrl.signal,
1276
+ });
1277
+ } catch (e) {
1278
+ clearTimeout(timer);
1279
+ res.json(
1280
+ {
1281
+ error: "supervisor unavailable",
1282
+ detail: (e as Error).message,
1283
+ hint: "Run `tina4 serve` (starts the agent server) or set TINA4_SUPERVISOR_URL",
1284
+ },
1285
+ 503,
1286
+ );
1287
+ return;
1288
+ }
1289
+
1290
+ const ct = (upstream.headers.get("content-type") ?? "").toLowerCase();
1291
+
1292
+ // SSE / event-stream — stream chunks through as they arrive.
1293
+ if (ct.includes("text/event-stream")) {
1294
+ res.raw.writeHead(upstream.status || 200, {
1295
+ "Content-Type": upstream.headers.get("content-type") ?? "text/event-stream",
1296
+ "Cache-Control": "no-cache",
1297
+ Connection: "keep-alive",
1298
+ });
1299
+ if (typeof (res.raw as any).flushHeaders === "function") {
1300
+ (res.raw as any).flushHeaders();
1301
+ }
1302
+ if (!upstream.body) {
1303
+ res.raw.end();
1304
+ clearTimeout(timer);
1305
+ return;
1306
+ }
1307
+ const reader = upstream.body.getReader();
1308
+ try {
1309
+ while (true) {
1310
+ const { done, value } = await reader.read();
1311
+ if (done) break;
1312
+ if (value) res.raw.write(Buffer.from(value));
1313
+ }
1314
+ } finally {
1315
+ clearTimeout(timer);
1316
+ res.raw.end();
1317
+ }
1318
+ return;
1319
+ }
1320
+
1321
+ clearTimeout(timer);
1322
+
1323
+ // JSON / other — drain the body and return as before.
1324
+ const raw = await upstream.text();
1325
+ const status = upstream.status || 200;
1326
+ try {
1327
+ res.json(JSON.parse(raw), status);
1328
+ } catch {
1329
+ // Non-JSON upstream — pass through as text with the same status.
1330
+ res.raw.writeHead(status, {
1331
+ "Content-Type": upstream.headers.get("content-type") ?? "text/plain; charset=utf-8",
1332
+ });
1333
+ res.raw.end(raw);
1334
+ }
1335
+ }
1336
+
1158
1337
  // -- Chat handler --
1338
+ //
1339
+ // Proxies POST /__dev/api/chat → Rust agent `POST /chat`. The SPA's Chat
1340
+ // view POSTs `{message, settings?, thread_id?, active_file?, files?}` and
1341
+ // expects an SSE stream of `event: status / message / done` chunks.
1342
+ // active_file (and any other body keys) are forwarded verbatim.
1343
+ const handleChat: RouteHandler = async (req, res) => {
1344
+ await proxyToSupervisor(req, res, "/chat");
1345
+ };
1346
+
1347
+ // -- Threads handlers --
1159
1348
 
1160
- const handleChat: RouteHandler = (req, res) => {
1161
- const message = (req as any).body?.message ?? "";
1162
- if (!message) {
1163
- res.json({ error: "Missing message parameter" });
1349
+ /**
1350
+ * Proxy /__dev/api/threads Rust agent /threads.
1351
+ * GET list threads
1352
+ * POST create thread
1353
+ * Method-multiplexed — anything else gets a 405.
1354
+ */
1355
+ const handleThreads: RouteHandler = async (req, res) => {
1356
+ const method = (req.method ?? "GET").toUpperCase();
1357
+ if (method !== "GET" && method !== "POST") {
1358
+ res.json({ error: "method not allowed" }, 405);
1164
1359
  return;
1165
1360
  }
1166
- // Placeholder AI chat response
1167
- res.json({
1168
- reply: `AI chat is not yet configured. You said: "${message}"`,
1169
- timestamp: new Date().toISOString(),
1170
- });
1361
+ await proxyToSupervisor(req, res, "/threads");
1362
+ };
1363
+
1364
+ /**
1365
+ * Proxy /__dev/api/threads/{id}[/messages] → Rust agent.
1366
+ *
1367
+ * Strips the dev-admin prefix and forwards the remaining path verbatim so
1368
+ * /__dev/api/threads/abc/messages becomes /threads/abc/messages on the
1369
+ * agent side. Mirrors Python's `_api_threads_sub`.
1370
+ */
1371
+ const handleThreadsSub: RouteHandler = async (req, res) => {
1372
+ let pathname = "";
1373
+ try {
1374
+ pathname = new URL(req.url ?? "/", "http://localhost").pathname;
1375
+ } catch {
1376
+ pathname = req.url ?? "";
1377
+ }
1378
+ const prefix = "/__dev/api";
1379
+ if (!pathname.startsWith(prefix)) {
1380
+ res.json({ error: "not found" }, 404);
1381
+ return;
1382
+ }
1383
+ const suffix = pathname.slice(prefix.length); // "/threads/abc[/messages]"
1384
+ if (!suffix.startsWith("/threads/")) {
1385
+ res.json({ error: "not found" }, 404);
1386
+ return;
1387
+ }
1388
+ await proxyToSupervisor(req, res, suffix);
1171
1389
  };
1172
1390
 
1173
1391
  // ---------------------------------------------------------------------------