@lazyingart/agintiflow 0.20.130 → 0.20.132

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 CHANGED
@@ -103,7 +103,7 @@ Provider signup and key pages:
103
103
  | Qwen / DashScope | [https://bailian.console.aliyun.com/](https://bailian.console.aliyun.com/) | `https://dashscope-intl.aliyuncs.com/compatible-mode/v1` |
104
104
  | GRS AI image tools | [https://grsai.ai/dashboard/api-keys](https://grsai.ai/dashboard/api-keys) | Configure with `/auxiliary grsai` or `aginti login grsai` |
105
105
 
106
- The CLI quietly auto-starts or reuses the local web UI from the same project. It tries `http://127.0.0.1:3210` first, then `3211`, `3212`, and so on if the port is already occupied by another project. The active URL is shown in the CLI launch header. If startup is blocked, stale, or unavailable, the same header row shows the recovery hint; run `/webapp [port]` inside the CLI to retry, `/webapp stop [port]` to stop the compatible local webapp, or `/webapp restart [port]` to stop and relaunch the local webapp with the current project and canonical `~/.agintiflow` session home. After a successful `aginti update` or accepted startup auto-update, AgInTiFlow also restarts the compatible local webapp so artifact serving uses the updated package.
106
+ The CLI quietly auto-starts or reuses the local web UI from the same project. It tries `http://127.0.0.1:3210` first, then `3211`, `3212`, and so on if the port is already occupied by another project. The active URL is shown in the CLI launch header. If startup is blocked, stale, or unavailable, the same header row shows the recovery hint; run `/webapp [port]` inside the CLI to retry, `/webapp stop [port]` to stop the compatible local webapp, or `/webapp restart [port]` to stop and relaunch the local webapp with the current project and canonical `~/.agintiflow` session home. Use `/webapp disable` or `aginti webapp disable` to persistently disable automatic webapp startup and update-time restarts; use `/webapp enable` or `aginti webapp enable` to restore them. After a successful `aginti update` or accepted startup auto-update, AgInTiFlow restarts the compatible local webapp only when webapp auto-start is enabled.
107
107
 
108
108
  Package installation also makes a best-effort, non-blocking webapp initialization. Install never fails because the optional local webapp could not start.
109
109
 
@@ -111,6 +111,8 @@ Launch the web UI explicitly when you want a foreground web server:
111
111
 
112
112
  ```bash
113
113
  aginti webapp
114
+ aginti webapp disable
115
+ aginti webapp enable
114
116
  aginti webapp stop
115
117
  aginti webapp restart
116
118
  aginti web --port 3210
@@ -146,7 +148,7 @@ aginti --language de
146
148
  | Goal | Command |
147
149
  | --- | --- |
148
150
  | Start interactive chat | `aginti` or `aginti chat` |
149
- | Start local web app | Auto-starts with `aginti`; detached command is `aginti webapp`; stop with `aginti webapp stop`; restart with `aginti webapp restart`; foreground mode is `aginti web --port 3210` |
151
+ | Start local web app | Auto-starts with `aginti`; detached command is `aginti webapp`; disable/enable auto-start with `aginti webapp disable` / `aginti webapp enable`; stop with `aginti webapp stop`; restart with `aginti webapp restart`; foreground mode is `aginti web --port 3210` |
150
152
  | Save provider keys | `aginti auth`, `/auth`, `/login` |
151
153
  | Review current repo | `/review [focus]` |
152
154
  | Toggle SCS quality gate | `/scs` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lazyingart/agintiflow",
3
- "version": "0.20.130",
3
+ "version": "0.20.132",
4
4
  "type": "module",
5
5
  "description": "AgInTiFlow is a project-aware agent workspace for hybrid wet-dry R&D, hardware-aware intelligence, software automation, and industrial workflows.",
6
6
  "license": "Apache-2.0",
@@ -66,6 +66,7 @@
66
66
  "scripts/smoke-platform.js",
67
67
  "scripts/smoke-perception-research.js",
68
68
  "scripts/smoke-permission-modes.js",
69
+ "scripts/smoke-runtime-compat.js",
69
70
  "scripts/smoke-skills.js",
70
71
  "scripts/smoke-skillmesh.js",
71
72
  "scripts/smoke-tmux-tools.js",
@@ -104,6 +105,7 @@
104
105
  "smoke:platform": "node scripts/smoke-platform.js",
105
106
  "smoke:perception-research": "node scripts/smoke-perception-research.js",
106
107
  "smoke:permission-modes": "node scripts/smoke-permission-modes.js",
108
+ "smoke:runtime-compat": "node scripts/smoke-runtime-compat.js",
107
109
  "smoke:tmux-tools": "node scripts/smoke-tmux-tools.js",
108
110
  "smoke:web-api": "node scripts/smoke-web-api.js",
109
111
  "smoke:web-autostart": "node scripts/smoke-web-autostart.js",
@@ -114,7 +116,7 @@
114
116
  "postinstall": "node scripts/postinstall-webapp.js",
115
117
  "supervision:seed": "node scripts/seed-supervised-homework.js",
116
118
  "storage:migrate": "node bin/aginti-cli.js storage migrate",
117
- "test": "npm run check && npm run smoke:autoupdate && npm run smoke:web-api && npm run smoke:web-autostart && npm run smoke:webapp-command && npm run smoke:web-port-fallback && npm run smoke:docker-command && npm run smoke:coding-tools && npm run smoke:dynamic-step-budget && npm run smoke:aaps-adapter && npm run smoke:auxiliary-tools && npm run smoke:perception-research && npm run smoke:auth && npm run smoke:canvas-artifacts && npm run smoke:capabilities && npm run smoke:model-roles && npm run smoke:platform && npm run smoke:permission-modes && npm run smoke:skills && npm run smoke:skillmesh && npm run smoke:tmux-tools && npm run smoke:cli-chat && npm run smoke:inbox",
119
+ "test": "npm run check && npm run smoke:runtime-compat && npm run smoke:autoupdate && npm run smoke:web-api && npm run smoke:web-autostart && npm run smoke:webapp-command && npm run smoke:web-port-fallback && npm run smoke:docker-command && npm run smoke:coding-tools && npm run smoke:dynamic-step-budget && npm run smoke:aaps-adapter && npm run smoke:auxiliary-tools && npm run smoke:perception-research && npm run smoke:auth && npm run smoke:canvas-artifacts && npm run smoke:capabilities && npm run smoke:model-roles && npm run smoke:platform && npm run smoke:permission-modes && npm run smoke:skills && npm run smoke:skillmesh && npm run smoke:tmux-tools && npm run smoke:cli-chat && npm run smoke:inbox",
118
120
  "pack:dry-run": "npm pack --dry-run",
119
121
  "smoke:capabilities": "node scripts/smoke-capabilities.js"
120
122
  },
@@ -0,0 +1,110 @@
1
+ #!/usr/bin/env node
2
+ import assert from "node:assert/strict";
3
+ import { spawn } from "node:child_process";
4
+ import fs from "node:fs/promises";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+
9
+ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
10
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "agintiflow-runtime-compat-"));
11
+ const agintiflowHome = path.join(tempRoot, ".agintiflow-home");
12
+ process.env.AGINTIFLOW_HOME = agintiflowHome;
13
+ process.env.AGINTIFLOW_FORCE_JSON_DB = "1";
14
+
15
+ const { formatNodeSqliteRecovery, nodeSqliteStatus } = await import("../src/sqlite.js");
16
+ const { WebDatabase } = await import("../src/web-db.js");
17
+ const { deleteSessionIndex, listSessionIndex, renameSessionIndex, upsertSessionIndex } = await import("../src/session-index.js");
18
+
19
+ const sqliteStatus = nodeSqliteStatus();
20
+ assert.equal(sqliteStatus.ok, false, "forced sqlite fallback should report unavailable node:sqlite");
21
+ assert.equal(sqliteStatus.forcedJson, true, "forced sqlite fallback should be marked as forced");
22
+ const recovery = formatNodeSqliteRecovery(sqliteStatus);
23
+ assert.match(recovery, /Node\.js 22\+/);
24
+ assert.match(recovery, /npm install -g @lazyingart\/agintiflow@latest/);
25
+
26
+ const db = new WebDatabase(tempRoot);
27
+ assert.equal(db.driver, "json", "WebDatabase should use JSON fallback when node:sqlite is unavailable");
28
+
29
+ const preferences = db.getPreferences();
30
+ db.savePreferences({ ...preferences, model: "runtime-compat-model" });
31
+ assert.equal(db.getPreferences().model, "runtime-compat-model", "JSON fallback preferences should persist");
32
+
33
+ const session = {
34
+ sessionId: "runtime-compat-session",
35
+ projectRoot: tempRoot,
36
+ commandCwd: tempRoot,
37
+ provider: "mock",
38
+ model: "mock-model",
39
+ goal: "runtime compatibility",
40
+ title: "Runtime Compatibility",
41
+ status: "finished",
42
+ startedAt: "2026-05-14T00:00:00.000Z",
43
+ updatedAt: "2026-05-14T00:01:00.000Z",
44
+ result: "ok",
45
+ };
46
+ db.upsertSession(session);
47
+ assert.equal(db.getSession(session.sessionId)?.title, "Runtime Compatibility", "JSON fallback session should persist");
48
+ assert.equal(db.listSessions(5).length, 1, "JSON fallback session listing should work");
49
+ assert.equal(db.renameSession(session.sessionId, "Renamed Runtime Compatibility"), true, "JSON fallback rename should work");
50
+ assert.equal(db.getSession(session.sessionId)?.title, "Renamed Runtime Compatibility", "JSON fallback rename should persist");
51
+ assert.equal(db.deleteSession(session.sessionId), true, "JSON fallback delete should work");
52
+ assert.equal(db.getSession(session.sessionId), null, "JSON fallback delete should remove the session");
53
+
54
+ assert.equal(upsertSessionIndex(session), true, "JSON fallback global index upsert should work");
55
+ assert.equal(listSessionIndex({ projectRoot: tempRoot }).length, 1, "JSON fallback global index listing should work");
56
+ assert.equal(renameSessionIndex(session.sessionId, "Indexed Runtime Compatibility"), true, "JSON fallback global index rename should work");
57
+ assert.equal(deleteSessionIndex(session.sessionId), true, "JSON fallback global index delete should work");
58
+
59
+ const port = 45200 + Math.floor(Math.random() * 500);
60
+ const server = spawn(process.execPath, [path.join(repoRoot, "bin/aginti-cli.js"), "web", "--port", String(port), "--host", "127.0.0.1"], {
61
+ cwd: tempRoot,
62
+ env: {
63
+ ...process.env,
64
+ AGINTIFLOW_HOME: agintiflowHome,
65
+ AGINTIFLOW_RUNTIME_DIR: tempRoot,
66
+ AGINTIFLOW_FORCE_JSON_DB: "1",
67
+ },
68
+ stdio: ["ignore", "pipe", "pipe"],
69
+ });
70
+
71
+ let stdout = "";
72
+ let stderr = "";
73
+ server.stdout.on("data", (chunk) => {
74
+ stdout += chunk.toString();
75
+ });
76
+ server.stderr.on("data", (chunk) => {
77
+ stderr += chunk.toString();
78
+ });
79
+
80
+ function delay(ms) {
81
+ return new Promise((resolve) => setTimeout(resolve, ms));
82
+ }
83
+
84
+ async function waitForHealth() {
85
+ const deadline = Date.now() + 15000;
86
+ while (Date.now() < deadline) {
87
+ if (server.exitCode !== null) break;
88
+ try {
89
+ const response = await fetch(`http://127.0.0.1:${port}/health`);
90
+ const health = await response.json();
91
+ if (health.ok) return health;
92
+ } catch {
93
+ await delay(250);
94
+ }
95
+ }
96
+ throw new Error(`forced JSON web health failed. stdout=${stdout.slice(-500)} stderr=${stderr.slice(-500)}`);
97
+ }
98
+
99
+ try {
100
+ const health = await waitForHealth();
101
+ assert.equal(health.storageDriver, "json", "web health should advertise JSON fallback storage");
102
+ assert.equal(health.runtimeDir, tempRoot, "web runtime should use the requested project root");
103
+ } finally {
104
+ if (server.exitCode === null && !server.killed) {
105
+ server.kill("SIGTERM");
106
+ await new Promise((resolve) => server.once("exit", resolve));
107
+ }
108
+ }
109
+
110
+ console.log("runtime compatibility smoke ok");
@@ -14,6 +14,10 @@ function delay(ms) {
14
14
  return new Promise((resolve) => setTimeout(resolve, ms));
15
15
  }
16
16
 
17
+ function occurrenceCount(value, pattern) {
18
+ return String(value || "").split(pattern).length - 1;
19
+ }
20
+
17
21
  async function waitFor(predicate, child, label, output) {
18
22
  const deadline = Date.now() + 12000;
19
23
  while (Date.now() < deadline) {
@@ -74,6 +78,13 @@ async function runCase({ port, env = {}, expectHeader, label }) {
74
78
  if (path.resolve(health.agintiflowHome) !== path.resolve(path.join(runtimeDir, `.agintiflow-web-home-${label}`))) {
75
79
  throw new Error(`webapp command inherited the wrong home for ${label}: ${JSON.stringify(health)}`);
76
80
  }
81
+ child.stdin.write(`/webapp disable\n`);
82
+ await waitFor(() => output.stdout.includes("webapp auto-start=disabled"), child, `${label} /webapp disable command`, output);
83
+ const disabledCount = occurrenceCount(output.stdout, "webapp auto-start=disabled");
84
+ child.stdin.write(`/webapp status\n`);
85
+ await waitFor(() => occurrenceCount(output.stdout, "webapp auto-start=disabled") > disabledCount, child, `${label} /webapp status disabled command`, output);
86
+ child.stdin.write(`/webapp enable\n`);
87
+ await waitFor(() => output.stdout.includes("webapp auto-start=enabled"), child, `${label} /webapp enable command`, output);
77
88
  child.stdin.write(`/webapp stop ${port}\n`);
78
89
  await waitFor(() => output.stdout.includes(`webapp=http://127.0.0.1:${port} stopped`), child, `${label} /webapp stop command`, output);
79
90
  let stopped = false;
package/src/cli.js CHANGED
@@ -37,7 +37,7 @@ import { handleSkillMeshCommand } from "./skillmesh.js";
37
37
  import { handleAapsCliCommand } from "./aaps-adapter.js";
38
38
  import { formatInstructionTemplateList, normalizeInstructionTemplate } from "./behavior-contract.js";
39
39
  import { applyPermissionMode, normalizePermissionMode } from "./permission-modes.js";
40
- import { ensureAgintiWebApp, stopAgintiWebApp } from "./web-autostart.js";
40
+ import { ensureAgintiWebApp, readWebAppPreference, stopAgintiWebApp, writeWebAppPreference } from "./web-autostart.js";
41
41
  import { dockerHostInstallPlan, formatDockerSetupText, summarizeDockerSetup } from "./docker-setup.js";
42
42
  import fs from "node:fs/promises";
43
43
  import path from "node:path";
@@ -403,7 +403,7 @@ export function parseArgs(argv) {
403
403
  parts.push(...argv.slice(i + 1));
404
404
  break;
405
405
  }
406
- if ((arg === "web" || arg === "--web") && ["restart", "stop"].includes(String(argv[i + 1] || "").toLowerCase())) {
406
+ if ((arg === "web" || arg === "--web") && ["restart", "stop", "enable", "disable", "status"].includes(String(argv[i + 1] || "").toLowerCase())) {
407
407
  result.webapp = true;
408
408
  result.webAction = String(argv[i + 1] || "").toLowerCase();
409
409
  i += 1;
@@ -902,7 +902,7 @@ async function restartWebAppAfterUpdate({ commandCwd = process.cwd(), language =
902
902
  preferredPort: process.env.AGINTI_WEB_PORT || process.env.PORT || 3210,
903
903
  language,
904
904
  restart: true,
905
- respectAutoStartDisable: false,
905
+ respectAutoStartDisable: true,
906
906
  }).catch((error) => ({ ok: false, error: error instanceof Error ? error.message : String(error), url: "" }));
907
907
  }
908
908
 
@@ -1098,6 +1098,7 @@ function parseInitOptions(argv = []) {
1098
1098
  function printDoctorReport(report) {
1099
1099
  console.log(`AgInTiFlow ${report.package.version} (npm latest: ${report.package.npmLatest})`);
1100
1100
  console.log(`node=${report.node.version} ok=${report.node.ok}`);
1101
+ console.log(`nodeSqlite=${report.node.sqlite?.ok ? "available" : "fallback-json"} storage=${report.node.sqlite?.ok ? "sqlite" : "json"}`);
1101
1102
  console.log(`platform=${report.platform.label} family=${report.platform.linuxFamily || report.platform.platform}`);
1102
1103
  console.log(`project=${report.project.root}`);
1103
1104
  console.log(`instructions=${report.project.instructionsPath} present=${report.project.instructionsPresent}`);
@@ -1126,6 +1127,10 @@ function printDoctorReport(report) {
1126
1127
  console.log("platform setup hints:");
1127
1128
  for (const hint of report.platform.setupHints) console.log(`- ${hint}`);
1128
1129
  }
1130
+ if (report.node.recovery?.length) {
1131
+ console.log("node runtime recovery:");
1132
+ for (const line of report.node.recovery) console.log(`- ${line}`);
1133
+ }
1129
1134
  }
1130
1135
 
1131
1136
  async function readStdin() {
@@ -1949,8 +1954,8 @@ export async function main(argv = process.argv.slice(2)) {
1949
1954
 
1950
1955
  if (args.webapp) {
1951
1956
  const action = String(args.webAction || "start").toLowerCase();
1952
- if (!["start", "stop", "restart", "reuse"].includes(action)) {
1953
- console.error("Usage: aginti webapp [start|stop|restart|reuse] [--port 3210] [--host 127.0.0.1]");
1957
+ if (!["start", "stop", "restart", "reuse", "enable", "disable", "status"].includes(action)) {
1958
+ console.error("Usage: aginti webapp [start|stop|restart|reuse|enable|disable|status] [--port 3210] [--host 127.0.0.1]");
1954
1959
  process.exit(1);
1955
1960
  }
1956
1961
  const webOptions = {
@@ -1960,6 +1965,16 @@ export async function main(argv = process.argv.slice(2)) {
1960
1965
  host: args.host || process.env.AGINTI_WEB_HOST || "127.0.0.1",
1961
1966
  preferredPort: args.port || process.env.AGINTI_WEB_PORT || 3210,
1962
1967
  };
1968
+ if (action === "enable" || action === "disable" || action === "status") {
1969
+ const preference =
1970
+ action === "status"
1971
+ ? await readWebAppPreference({ home: webOptions.home })
1972
+ : await writeWebAppPreference({ home: webOptions.home, autoStart: action === "enable" });
1973
+ console.log(`webapp auto-start: ${preference.autoStart ? "enabled" : "disabled"}`);
1974
+ console.log(`preference: ${preference.path}`);
1975
+ if (!preference.autoStart) console.log("Use `aginti webapp stop` or `/webapp stop` to stop a currently running webapp.");
1976
+ return;
1977
+ }
1963
1978
  const result = await (action === "stop"
1964
1979
  ? stopAgintiWebApp(webOptions)
1965
1980
  : ensureAgintiWebApp({
package/src/i18n.js CHANGED
@@ -139,7 +139,7 @@ const TRANSLATIONS = {
139
139
  helpSkills: "List Markdown skills selected for a topic.",
140
140
  helpSkillMesh: "Manage strict reviewed skill sharing.",
141
141
  helpProfile: "Set task profile, e.g. code, website, latex, maintenance.",
142
- helpWebapp: "Start, reuse, stop, or restart the local webapp and print its URL.",
142
+ helpWebapp: "Start, reuse, stop, restart, or configure the local webapp.",
143
143
  helpWebSearch: "Enable or disable the web_search tool.",
144
144
  helpEnableScs: "Toggle Student-Committee-Supervisor gated execution.",
145
145
  helpScouts: "Enable parallel DeepSeek scouts and set scout count.",
@@ -45,7 +45,7 @@ import {
45
45
  permissionModeForApprovalCategory,
46
46
  permissionModeLabel,
47
47
  } from "./permission-modes.js";
48
- import { ensureAgintiWebApp, stopAgintiWebApp } from "./web-autostart.js";
48
+ import { ensureAgintiWebApp, readWebAppPreference, stopAgintiWebApp, writeWebAppPreference } from "./web-autostart.js";
49
49
 
50
50
  const useColor = Boolean(input.isTTY && output.isTTY && process.env.AGINTIFLOW_NO_COLOR !== "1");
51
51
  const ansi = {
@@ -881,7 +881,7 @@ function printHelp() {
881
881
  ` ${command("/skills [query]", "List Markdown skills selected for a topic.", "helpSkills")}`,
882
882
  ` ${command("/skillmesh [status|off|record|share|sync]", "Manage strict reviewed skill sharing.", "helpSkillMesh")}`,
883
883
  ` ${command("/profile <name>", "Set task profile, e.g. code, website, latex, maintenance.", "helpProfile")}`,
884
- ` ${command("/webapp [port|start|stop|restart|reuse]", "Start, reuse, stop, or restart the local webapp and print its URL.", "helpWebapp")}`,
884
+ ` ${command("/webapp [port|start|stop|restart|reuse|enable|disable|status]", "Start, reuse, stop, restart, or configure the local webapp.", "helpWebapp")}`,
885
885
  ` ${command("/web-search on|off", "Enable or disable the web_search tool.", "helpWebSearch")}`,
886
886
  ` ${command("/web-research <query>", "Run a sourced web_research turn with persisted evidence.", "helpWebSearch")}`,
887
887
  ` ${command("/image-read <path> [question]", "Run read_image on a workspace screenshot/image.", "helpWebSearch")}`,
@@ -3011,17 +3011,26 @@ async function handleCommand(line, state, packageDir) {
3011
3011
  }
3012
3012
  if (command === "webapp" || command === "web") {
3013
3013
  const words = value.split(/\s+/).filter(Boolean);
3014
- const action = words.find((word) => ["start", "stop", "restart", "reuse"].includes(word.toLowerCase()))?.toLowerCase() || "start";
3014
+ const action = words.find((word) => ["start", "stop", "restart", "reuse", "enable", "disable", "status"].includes(word.toLowerCase()))?.toLowerCase() || "start";
3015
3015
  const restart = words.some((word) => word.toLowerCase() === "restart");
3016
3016
  const portValue = words.find((word) => /^\d+$/.test(word));
3017
3017
  const port = Number(portValue) || Number(process.env.AGINTI_WEB_PORT || process.env.PORT || 3210);
3018
- printSystemLine(action === "stop" ? "webapp=stopping" : restart ? "webapp=restarting" : "webapp=starting");
3019
3018
  const webOptions = {
3020
3019
  packageDir,
3021
3020
  cwd: state.commandCwd || process.cwd(),
3022
3021
  host: process.env.AGINTI_WEB_HOST || process.env.HOST || "127.0.0.1",
3023
3022
  preferredPort: port,
3024
3023
  };
3024
+ if (action === "enable" || action === "disable" || action === "status") {
3025
+ const preference =
3026
+ action === "status"
3027
+ ? await readWebAppPreference()
3028
+ : await writeWebAppPreference({ autoStart: action === "enable" });
3029
+ printSystemLine(`webapp auto-start=${preference.autoStart ? "enabled" : "disabled"}`);
3030
+ if (!preference.autoStart) printSystemLine("webapp disabled for future starts; use /webapp stop to stop a running webapp");
3031
+ return true;
3032
+ }
3033
+ printSystemLine(action === "stop" ? "webapp=stopping" : restart ? "webapp=restarting" : "webapp=starting");
3025
3034
  const result = await (action === "stop"
3026
3035
  ? stopAgintiWebApp(webOptions)
3027
3036
  : ensureAgintiWebApp({
package/src/platform.js CHANGED
@@ -58,6 +58,7 @@ export function platformSetupHints(info = platformInfo()) {
58
58
  if (info.isWsl) {
59
59
  return [
60
60
  "Use the Linux/WSL shell as the primary AgInTiFlow environment; keep projects under the WSL filesystem for best file and Docker performance.",
61
+ "Use Node.js 22+ from nvm/fnm/NodeSource; if an old ~/.npm-global/bin/aginti is earlier on PATH, reinstall AgInTiFlow after switching Node.",
61
62
  "Use Docker Desktop with WSL integration or Docker Engine inside WSL for docker-workspace mode.",
62
63
  "For LaTeX, reuse WSL latexmk/pdflatex when installed, or use the companion Docker sandbox image.",
63
64
  ];
@@ -65,6 +66,7 @@ export function platformSetupHints(info = platformInfo()) {
65
66
  if (info.isMac) {
66
67
  return [
67
68
  "Use Node.js 22+ from Homebrew, nvm, fnm, or the official installer.",
69
+ "After changing Node versions, verify `node -v`, `which aginti`, and reinstall with `npm install -g @lazyingart/agintiflow@latest` so the CLI uses the same Node that installed it.",
68
70
  "Use Docker Desktop or Colima for docker-workspace mode; the Ubuntu Docker installer is intentionally not used on macOS.",
69
71
  "For LaTeX, reuse MacTeX/BasicTeX when latexmk and pdflatex are on PATH; otherwise use the companion Docker sandbox image.",
70
72
  "Use Homebrew for optional host tools such as ripgrep, git, python, and latexmk when you choose host mode.",
@@ -80,6 +82,7 @@ export function platformSetupHints(info = platformInfo()) {
80
82
  if (info.linuxFamily === "debian") {
81
83
  return [
82
84
  "Use Node.js 22+ from nvm/fnm, NodeSource, or distro packages.",
85
+ "After changing Node versions, verify `node -v`, `which aginti`, and reinstall with `npm install -g @lazyingart/agintiflow@latest` to avoid stale npm-global shims.",
83
86
  "Docker can be installed with scripts/install-docker-ubuntu.sh on Ubuntu/Debian-like hosts.",
84
87
  "For LaTeX, reuse host latexmk/pdflatex when available; otherwise use the companion Docker sandbox image.",
85
88
  ];
package/src/project.js CHANGED
@@ -6,6 +6,7 @@ import { promisify } from "node:util";
6
6
  import { listAgentWrappers } from "./tool-wrappers.js";
7
7
  import { getDockerSandboxStatus } from "./docker-sandbox.js";
8
8
  import { platformInfo, platformLabel, platformSetupHints } from "./platform.js";
9
+ import { nodeSqliteRecoveryLines, nodeSqliteStatus } from "./sqlite.js";
9
10
  import { buildAgintiInstructions, normalizeInstructionTemplate } from "./behavior-contract.js";
10
11
  import {
11
12
  LEGACY_PROJECT_SESSIONS_DIR_NAME,
@@ -758,6 +759,7 @@ export async function doctorReport(projectRoot, packageVersion, config) {
758
759
  const paths = projectPaths(projectRoot);
759
760
  const keyStatus = providerKeyStatus(projectRoot);
760
761
  const platform = platformInfo();
762
+ const sqliteStatus = nodeSqliteStatus();
761
763
  const [sessions, dockerStatus, latestVersion, instructions] = await Promise.all([
762
764
  listProjectSessions(projectRoot, 8),
763
765
  getDockerSandboxStatus(config).catch((error) => ({ ok: false, error: error.message })),
@@ -775,6 +777,8 @@ export async function doctorReport(projectRoot, packageVersion, config) {
775
777
  node: {
776
778
  version: process.version,
777
779
  ok: Number(process.versions.node.split(".")[0]) >= 22,
780
+ sqlite: sqliteStatus,
781
+ recovery: sqliteStatus.ok ? [] : nodeSqliteRecoveryLines(sqliteStatus),
778
782
  },
779
783
  platform: {
780
784
  ...platform,
@@ -17,6 +17,7 @@ export function globalSessionPaths(sessionId = "") {
17
17
  home,
18
18
  sessionsDir,
19
19
  indexDbPath: path.join(sessionsDir, "index.sqlite"),
20
+ indexJsonPath: path.join(sessionsDir, "index.json"),
20
21
  sessionDir: sessionId ? path.join(sessionsDir, sessionId) : "",
21
22
  };
22
23
  }
@@ -29,7 +30,8 @@ export function isSafeSessionId(sessionId) {
29
30
  function ensureIndexDb() {
30
31
  const paths = globalSessionPaths();
31
32
  fs.mkdirSync(paths.sessionsDir, { recursive: true });
32
- const DatabaseSync = loadDatabaseSync();
33
+ const DatabaseSync = loadDatabaseSync({ optional: true });
34
+ if (!DatabaseSync) return null;
33
35
  const db = new DatabaseSync(paths.indexDbPath);
34
36
  db.exec(`
35
37
  CREATE TABLE IF NOT EXISTS sessions (
@@ -57,14 +59,86 @@ function ensureIndexDb() {
57
59
  return db;
58
60
  }
59
61
 
62
+ function emptyIndexState() {
63
+ return {
64
+ sessions: {},
65
+ };
66
+ }
67
+
68
+ function readIndexJson() {
69
+ const paths = globalSessionPaths();
70
+ fs.mkdirSync(paths.sessionsDir, { recursive: true });
71
+ try {
72
+ const parsed = JSON.parse(fs.readFileSync(paths.indexJsonPath, "utf8"));
73
+ return {
74
+ ...emptyIndexState(),
75
+ ...parsed,
76
+ sessions: parsed && typeof parsed.sessions === "object" && parsed.sessions ? parsed.sessions : {},
77
+ };
78
+ } catch {
79
+ return emptyIndexState();
80
+ }
81
+ }
82
+
83
+ function writeIndexJson(state) {
84
+ const paths = globalSessionPaths();
85
+ fs.mkdirSync(paths.sessionsDir, { recursive: true });
86
+ const tmpPath = `${paths.indexJsonPath}.${process.pid}.${Date.now()}.tmp`;
87
+ fs.writeFileSync(tmpPath, `${JSON.stringify(state, null, 2)}\n`);
88
+ fs.renameSync(tmpPath, paths.indexJsonPath);
89
+ }
90
+
91
+ function normalizeIndexRecord(record = {}, sessionId = "") {
92
+ const now = new Date().toISOString();
93
+ const paths = globalSessionPaths(sessionId);
94
+ const createdAt = record.createdAt || record.startedAt || record.created_at || now;
95
+ const updatedAt = record.updatedAt || record.updated_at || createdAt || now;
96
+ return {
97
+ sessionId,
98
+ projectRoot: String(record.projectRoot || record.project_root || ""),
99
+ commandCwd: String(record.commandCwd || record.command_cwd || record.projectRoot || record.project_root || ""),
100
+ projectSessionsDir: String(record.projectSessionsDir || record.project_sessions_dir || ""),
101
+ sessionDir: String(record.sessionDir || record.session_dir || paths.sessionDir),
102
+ provider: String(record.provider || ""),
103
+ model: String(record.model || ""),
104
+ goal: String(record.goal || ""),
105
+ title: String(record.title || ""),
106
+ status: String(record.status || ""),
107
+ createdAt,
108
+ updatedAt,
109
+ endedAt: record.endedAt || record.ended_at || null,
110
+ result: String(record.result || ""),
111
+ error: String(record.error || ""),
112
+ };
113
+ }
114
+
60
115
  export function upsertSessionIndex(record = {}) {
61
116
  const sessionId = String(record.sessionId || record.session_id || "").trim();
62
117
  if (!isSafeSessionId(sessionId)) return false;
63
- const now = new Date().toISOString();
64
118
  const db = ensureIndexDb();
119
+ const normalized = normalizeIndexRecord(record, sessionId);
120
+ if (!db) {
121
+ const state = readIndexJson();
122
+ const previous = state.sessions[sessionId] || {};
123
+ state.sessions[sessionId] = {
124
+ ...previous,
125
+ ...normalized,
126
+ projectRoot: normalized.projectRoot || previous.projectRoot || "",
127
+ commandCwd: normalized.commandCwd || previous.commandCwd || "",
128
+ projectSessionsDir: normalized.projectSessionsDir || previous.projectSessionsDir || "",
129
+ sessionDir: normalized.sessionDir || previous.sessionDir || globalSessionPaths(sessionId).sessionDir,
130
+ provider: normalized.provider || previous.provider || "",
131
+ model: normalized.model || previous.model || "",
132
+ goal: normalized.goal || previous.goal || "",
133
+ title: normalized.title || previous.title || "",
134
+ status: normalized.status || previous.status || "",
135
+ result: normalized.result || previous.result || "",
136
+ error: normalized.error || previous.error || "",
137
+ };
138
+ writeIndexJson(state);
139
+ return true;
140
+ }
65
141
  const paths = globalSessionPaths(sessionId);
66
- const createdAt = record.createdAt || record.startedAt || record.created_at || now;
67
- const updatedAt = record.updatedAt || record.updated_at || createdAt || now;
68
142
  db.prepare(
69
143
  `INSERT INTO sessions (
70
144
  session_id, project_root, command_cwd, project_sessions_dir, session_dir,
@@ -86,20 +160,20 @@ export function upsertSessionIndex(record = {}) {
86
160
  error = CASE WHEN excluded.error != '' THEN excluded.error ELSE sessions.error END`
87
161
  ).run(
88
162
  sessionId,
89
- String(record.projectRoot || record.project_root || ""),
90
- String(record.commandCwd || record.command_cwd || record.projectRoot || record.project_root || ""),
91
- String(record.projectSessionsDir || record.project_sessions_dir || ""),
92
- String(record.sessionDir || record.session_dir || paths.sessionDir),
93
- String(record.provider || ""),
94
- String(record.model || ""),
95
- String(record.goal || ""),
96
- String(record.title || ""),
97
- String(record.status || ""),
98
- createdAt,
99
- updatedAt,
100
- record.endedAt || record.ended_at || null,
101
- String(record.result || ""),
102
- String(record.error || "")
163
+ normalized.projectRoot,
164
+ normalized.commandCwd,
165
+ normalized.projectSessionsDir,
166
+ normalized.sessionDir || paths.sessionDir,
167
+ normalized.provider,
168
+ normalized.model,
169
+ normalized.goal,
170
+ normalized.title,
171
+ normalized.status,
172
+ normalized.createdAt,
173
+ normalized.updatedAt,
174
+ normalized.endedAt,
175
+ normalized.result,
176
+ normalized.error
103
177
  );
104
178
  return true;
105
179
  }
@@ -107,6 +181,14 @@ export function upsertSessionIndex(record = {}) {
107
181
  export function renameSessionIndex(sessionId, title) {
108
182
  if (!isSafeSessionId(sessionId)) return false;
109
183
  const db = ensureIndexDb();
184
+ if (!db) {
185
+ const state = readIndexJson();
186
+ if (!state.sessions[sessionId]) return false;
187
+ state.sessions[sessionId].title = String(title || "").trim();
188
+ state.sessions[sessionId].updatedAt = new Date().toISOString();
189
+ writeIndexJson(state);
190
+ return true;
191
+ }
110
192
  const result = db
111
193
  .prepare("UPDATE sessions SET title = ?, updated_at = ? WHERE session_id = ?")
112
194
  .run(String(title || "").trim(), new Date().toISOString(), sessionId);
@@ -116,6 +198,13 @@ export function renameSessionIndex(sessionId, title) {
116
198
  export function deleteSessionIndex(sessionId) {
117
199
  if (!isSafeSessionId(sessionId)) return false;
118
200
  const db = ensureIndexDb();
201
+ if (!db) {
202
+ const state = readIndexJson();
203
+ if (!state.sessions[sessionId]) return false;
204
+ delete state.sessions[sessionId];
205
+ writeIndexJson(state);
206
+ return true;
207
+ }
119
208
  const result = db.prepare("DELETE FROM sessions WHERE session_id = ?").run(sessionId);
120
209
  return result.changes > 0;
121
210
  }
@@ -123,6 +212,18 @@ export function deleteSessionIndex(sessionId) {
123
212
  export function listSessionIndex({ projectRoot = "", commandCwd = "", limit = 100 } = {}) {
124
213
  const db = ensureIndexDb();
125
214
  const maxRows = Math.min(Math.max(Number(limit) || 100, 1), 1000);
215
+ if (!db) {
216
+ const resolvedProjectRoot = projectRoot ? path.resolve(projectRoot) : "";
217
+ const resolvedCommandCwd = commandCwd ? path.resolve(commandCwd) : "";
218
+ return Object.values(readIndexJson().sessions)
219
+ .filter((session) => {
220
+ if (resolvedProjectRoot && session.projectRoot !== resolvedProjectRoot) return false;
221
+ if (resolvedCommandCwd && session.commandCwd !== resolvedCommandCwd) return false;
222
+ return true;
223
+ })
224
+ .sort((left, right) => String(right.updatedAt || "").localeCompare(String(left.updatedAt || "")))
225
+ .slice(0, maxRows);
226
+ }
126
227
  const columns = `session_id AS sessionId, project_root AS projectRoot, command_cwd AS commandCwd, project_sessions_dir AS projectSessionsDir,
127
228
  session_dir AS sessionDir, provider, model, goal, title, status,
128
229
  created_at AS createdAt, updated_at AS updatedAt, ended_at AS endedAt, result, error`;
package/src/sqlite.js CHANGED
@@ -2,18 +2,126 @@ import { createRequire } from "node:module";
2
2
 
3
3
  const require = createRequire(import.meta.url);
4
4
  let cachedDatabaseSync = null;
5
+ let cachedUnavailableError = null;
5
6
 
6
- export function loadDatabaseSync() {
7
- if (cachedDatabaseSync) return cachedDatabaseSync;
7
+ export const NODE_SQLITE_UNAVAILABLE_CODE = "AGINTIFLOW_NODE_SQLITE_UNAVAILABLE";
8
+
9
+ function nodeMajorVersion(version = process.versions.node) {
10
+ return Number(String(version || "").split(".")[0]) || 0;
11
+ }
12
+
13
+ function requireNodeSqliteSilently() {
8
14
  const originalEmitWarning = process.emitWarning;
9
15
  process.emitWarning = function filteredSqliteWarning(warning, ...args) {
10
16
  if (String(warning || "").includes("SQLite is an experimental feature")) return;
11
17
  return originalEmitWarning.call(process, warning, ...args);
12
18
  };
13
19
  try {
14
- cachedDatabaseSync = require("node:sqlite").DatabaseSync;
15
- return cachedDatabaseSync;
20
+ return require("node:sqlite");
16
21
  } finally {
17
22
  process.emitWarning = originalEmitWarning;
18
23
  }
19
24
  }
25
+
26
+ export function nodeSqliteStatus() {
27
+ const nodeVersion = process.version;
28
+ const nodeMajor = nodeMajorVersion();
29
+ const forcedJson = process.env.AGINTIFLOW_FORCE_JSON_DB === "1" || process.env.AGINTIFLOW_FORCE_SQLITE_UNAVAILABLE === "1";
30
+ if (forcedJson) {
31
+ return {
32
+ ok: false,
33
+ forcedJson: true,
34
+ nodeVersion,
35
+ nodeMajor,
36
+ requiredNodeMajor: 22,
37
+ code: NODE_SQLITE_UNAVAILABLE_CODE,
38
+ message: "AGINTIFLOW_FORCE_JSON_DB is enabled; using JSON fallback storage.",
39
+ };
40
+ }
41
+ try {
42
+ requireNodeSqliteSilently();
43
+ return {
44
+ ok: true,
45
+ forcedJson: false,
46
+ nodeVersion,
47
+ nodeMajor,
48
+ requiredNodeMajor: 22,
49
+ code: "",
50
+ message: "node:sqlite is available.",
51
+ };
52
+ } catch (error) {
53
+ return {
54
+ ok: false,
55
+ forcedJson: false,
56
+ nodeVersion,
57
+ nodeMajor,
58
+ requiredNodeMajor: 22,
59
+ code: NODE_SQLITE_UNAVAILABLE_CODE,
60
+ errorCode: error?.code || "",
61
+ message: error?.message || String(error),
62
+ };
63
+ }
64
+ }
65
+
66
+ export function nodeSqliteRecoveryLines(status = nodeSqliteStatus()) {
67
+ const lines = [
68
+ `AgInTiFlow web/session SQLite storage needs Node.js 22+ with node:sqlite; current node is ${status.nodeVersion || process.version}.`,
69
+ "AgInTiFlow will use a project-local JSON fallback when possible, but Node 22+ is still recommended for the full durable session index.",
70
+ "Recommended fix after installing Node 22+: npm install -g @lazyingart/agintiflow@latest",
71
+ ];
72
+
73
+ if (process.platform === "win32") {
74
+ lines.push("Windows: prefer WSL2 with Node 22+ and Docker Desktop WSL integration, or install Node 22+ with the official installer/nvm-windows.");
75
+ } else {
76
+ lines.push('nvm quick fix: export NVM_DIR="$HOME/.nvm"; . "$NVM_DIR/nvm.sh"; nvm install 22; nvm alias default 22; nvm use 22');
77
+ lines.push("If `which aginti` points to ~/.npm-global/bin/aginti after switching Node, remove the stale install or put the nvm bin directory before ~/.npm-global/bin in PATH.");
78
+ }
79
+
80
+ if (process.platform === "darwin") {
81
+ lines.push("macOS alternative: brew install node@22, then reinstall AgInTiFlow globally with that node on PATH.");
82
+ } else if (process.platform === "linux") {
83
+ lines.push("Linux alternative: install Node 22+ from NodeSource/fnm/nvm/distro packages, then open a new shell and verify `node -v` and `which aginti`.");
84
+ }
85
+
86
+ return lines;
87
+ }
88
+
89
+ export function formatNodeSqliteRecovery(status = nodeSqliteStatus()) {
90
+ return nodeSqliteRecoveryLines(status).join("\n");
91
+ }
92
+
93
+ export class AgintiSqliteUnavailableError extends Error {
94
+ constructor(cause, status = nodeSqliteStatus()) {
95
+ super(formatNodeSqliteRecovery(status));
96
+ this.name = "AgintiSqliteUnavailableError";
97
+ this.code = NODE_SQLITE_UNAVAILABLE_CODE;
98
+ this.status = status;
99
+ if (cause) this.cause = cause;
100
+ }
101
+ }
102
+
103
+ function unavailableError(cause = null) {
104
+ cachedUnavailableError = new AgintiSqliteUnavailableError(cause);
105
+ return cachedUnavailableError;
106
+ }
107
+
108
+ export function loadDatabaseSync(options = {}) {
109
+ if (cachedUnavailableError) {
110
+ if (options.optional) return null;
111
+ throw cachedUnavailableError;
112
+ }
113
+ if (cachedDatabaseSync) return cachedDatabaseSync;
114
+ if (process.env.AGINTIFLOW_FORCE_JSON_DB === "1" || process.env.AGINTIFLOW_FORCE_SQLITE_UNAVAILABLE === "1") {
115
+ const error = unavailableError(new Error("Forced JSON database fallback."));
116
+ if (options.optional) return null;
117
+ throw error;
118
+ }
119
+ try {
120
+ cachedDatabaseSync = requireNodeSqliteSilently().DatabaseSync;
121
+ return cachedDatabaseSync;
122
+ } catch (error) {
123
+ const wrapped = unavailableError(error);
124
+ if (options.optional) return null;
125
+ throw wrapped;
126
+ }
127
+ }
@@ -1,4 +1,5 @@
1
1
  import { execFile, spawn } from "node:child_process";
2
+ import fs from "node:fs/promises";
2
3
  import http from "node:http";
3
4
  import net from "node:net";
4
5
  import os from "node:os";
@@ -54,6 +55,32 @@ function resolveRuntimeDir(cwd = "") {
54
55
  return path.resolve(process.env.AGINTIFLOW_WEB_RUNTIME_DIR || cwd || process.cwd());
55
56
  }
56
57
 
58
+ function webPreferencePath(home = "") {
59
+ return path.join(resolveWebHome(home), "webapp.json");
60
+ }
61
+
62
+ export async function readWebAppPreference({ home = "" } = {}) {
63
+ const file = webPreferencePath(home);
64
+ try {
65
+ const data = JSON.parse(await fs.readFile(file, "utf8"));
66
+ return { autoStart: data.autoStart !== false, path: file };
67
+ } catch {
68
+ return { autoStart: true, path: file };
69
+ }
70
+ }
71
+
72
+ export async function writeWebAppPreference({ home = "", autoStart = true } = {}) {
73
+ const file = webPreferencePath(home);
74
+ await fs.mkdir(path.dirname(file), { recursive: true });
75
+ await fs.writeFile(file, `${JSON.stringify({ autoStart: Boolean(autoStart), updatedAt: new Date().toISOString() }, null, 2)}\n`, "utf8");
76
+ return await readWebAppPreference({ home });
77
+ }
78
+
79
+ async function webAutoStartDisabled(home = "") {
80
+ if (process.env.AGINTI_NO_WEB_AUTO_START === "1" || process.env.AGINTIFLOW_NO_WEB_AUTO_START === "1") return true;
81
+ return (await readWebAppPreference({ home })).autoStart === false;
82
+ }
83
+
57
84
  function fetchHealthDetails(host, port, timeoutMs = 450) {
58
85
  return new Promise((resolve) => {
59
86
  const req = http.get(`${webUrl(host, port)}/health`, { timeout: timeoutMs }, (res) => {
@@ -274,7 +301,7 @@ export async function ensureAgintiWebApp({
274
301
  restart = false,
275
302
  respectAutoStartDisable = true,
276
303
  } = {}) {
277
- if (respectAutoStartDisable && (process.env.AGINTI_NO_WEB_AUTO_START === "1" || process.env.AGINTIFLOW_NO_WEB_AUTO_START === "1")) {
304
+ if (respectAutoStartDisable && (await webAutoStartDisabled(home))) {
278
305
  return { ok: false, disabled: true, url: "" };
279
306
  }
280
307
 
package/src/web-db.js CHANGED
@@ -9,6 +9,38 @@ import { permissionModeDefaults } from "./permission-modes.js";
9
9
 
10
10
  const PREFERENCES_SCHEMA_VERSION = 8;
11
11
 
12
+ function jsonStatePath(dbPath) {
13
+ return dbPath.replace(/\.sqlite$/i, ".json");
14
+ }
15
+
16
+ function emptyJsonState() {
17
+ return {
18
+ preferences: {},
19
+ sessions: {},
20
+ };
21
+ }
22
+
23
+ function readJsonFile(filePath) {
24
+ try {
25
+ const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
26
+ return {
27
+ ...emptyJsonState(),
28
+ ...parsed,
29
+ preferences: parsed && typeof parsed.preferences === "object" && parsed.preferences ? parsed.preferences : {},
30
+ sessions: parsed && typeof parsed.sessions === "object" && parsed.sessions ? parsed.sessions : {},
31
+ };
32
+ } catch {
33
+ return emptyJsonState();
34
+ }
35
+ }
36
+
37
+ function writeJsonFile(filePath, state) {
38
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
39
+ const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
40
+ fs.writeFileSync(tmpPath, `${JSON.stringify(state, null, 2)}\n`);
41
+ fs.renameSync(tmpPath, filePath);
42
+ }
43
+
12
44
  function defaultPreferences(baseDir) {
13
45
  const presets = getModelPresets();
14
46
  const roles = getModelRoleDefaults();
@@ -63,33 +95,40 @@ export class WebDatabase {
63
95
  this.paths = projectPaths(this.baseDir);
64
96
  this.dbDir = this.paths.sessionsDir;
65
97
  this.dbPath = this.paths.sessionDbPath;
98
+ this.jsonPath = jsonStatePath(this.dbPath);
66
99
  fs.mkdirSync(this.dbDir, { recursive: true });
67
- const DatabaseSync = loadDatabaseSync();
68
- this.db = new DatabaseSync(this.dbPath);
69
- this.db.exec(`
70
- CREATE TABLE IF NOT EXISTS preferences (
71
- key TEXT PRIMARY KEY,
72
- value TEXT NOT NULL,
73
- updated_at TEXT NOT NULL
74
- );
100
+ const DatabaseSync = loadDatabaseSync({ optional: true });
101
+ this.driver = DatabaseSync ? "sqlite" : "json";
102
+ this.db = DatabaseSync ? new DatabaseSync(this.dbPath) : null;
103
+ if (this.db) {
104
+ this.db.exec(`
105
+ CREATE TABLE IF NOT EXISTS preferences (
106
+ key TEXT PRIMARY KEY,
107
+ value TEXT NOT NULL,
108
+ updated_at TEXT NOT NULL
109
+ );
75
110
 
76
- CREATE TABLE IF NOT EXISTS sessions (
77
- session_id TEXT PRIMARY KEY,
78
- provider TEXT NOT NULL,
79
- model TEXT NOT NULL,
80
- goal TEXT NOT NULL,
81
- status TEXT NOT NULL,
82
- started_at TEXT NOT NULL,
83
- updated_at TEXT NOT NULL,
84
- ended_at TEXT,
85
- result TEXT,
86
- error TEXT
87
- );
88
- `);
111
+ CREATE TABLE IF NOT EXISTS sessions (
112
+ session_id TEXT PRIMARY KEY,
113
+ provider TEXT NOT NULL,
114
+ model TEXT NOT NULL,
115
+ goal TEXT NOT NULL,
116
+ status TEXT NOT NULL,
117
+ started_at TEXT NOT NULL,
118
+ updated_at TEXT NOT NULL,
119
+ ended_at TEXT,
120
+ result TEXT,
121
+ error TEXT
122
+ );
123
+ `);
124
+ } else if (!fs.existsSync(this.jsonPath)) {
125
+ writeJsonFile(this.jsonPath, emptyJsonState());
126
+ }
89
127
  this.migrate();
90
128
  }
91
129
 
92
130
  migrate() {
131
+ if (!this.db) return;
93
132
  const sessionColumns = this.db.prepare("PRAGMA table_info(sessions)").all();
94
133
  if (!sessionColumns.some((column) => column.name === "title")) {
95
134
  this.db.exec("ALTER TABLE sessions ADD COLUMN title TEXT NOT NULL DEFAULT ''");
@@ -108,12 +147,29 @@ export class WebDatabase {
108
147
  }
109
148
  }
110
149
 
150
+ readJsonState() {
151
+ return readJsonFile(this.jsonPath);
152
+ }
153
+
154
+ writeJsonState(state) {
155
+ writeJsonFile(this.jsonPath, state);
156
+ }
157
+
111
158
  getPreferences() {
159
+ if (!this.db) {
160
+ const raw = this.readJsonState().preferences.ui;
161
+ if (!raw) return defaultPreferences(this.baseDir);
162
+ return this.normalizePreferences(raw);
163
+ }
112
164
  const row = this.db.prepare("SELECT value FROM preferences WHERE key = ?").get("ui");
113
165
  if (!row) return defaultPreferences(this.baseDir);
114
166
 
167
+ return this.normalizePreferences(row.value);
168
+ }
169
+
170
+ normalizePreferences(value) {
115
171
  try {
116
- const parsed = JSON.parse(row.value);
172
+ const parsed = typeof value === "string" ? JSON.parse(value) : value;
117
173
  const preferences = {
118
174
  ...defaultPreferences(this.baseDir),
119
175
  ...parsed,
@@ -177,6 +233,13 @@ export class WebDatabase {
177
233
  savePreferences(preferences) {
178
234
  const value = JSON.stringify(preferences);
179
235
  const updatedAt = new Date().toISOString();
236
+ if (!this.db) {
237
+ const state = this.readJsonState();
238
+ state.preferences.ui = value;
239
+ state.preferences.uiUpdatedAt = updatedAt;
240
+ this.writeJsonState(state);
241
+ return;
242
+ }
180
243
  this.db
181
244
  .prepare(
182
245
  `INSERT INTO preferences (key, value, updated_at)
@@ -192,6 +255,35 @@ export class WebDatabase {
192
255
  const commandCwd = session.commandCwd || projectRoot;
193
256
  const projectSessionsDir = session.projectSessionsDir || this.paths.sessionsDir;
194
257
  const sessionDir = session.sessionDir || path.join(this.paths.globalSessionsDir, session.sessionId);
258
+ const record = {
259
+ sessionId: session.sessionId,
260
+ projectRoot,
261
+ commandCwd,
262
+ projectSessionsDir,
263
+ sessionDir,
264
+ provider: session.provider,
265
+ model: session.model,
266
+ goal: session.goal,
267
+ title: session.title || "",
268
+ status: session.status,
269
+ startedAt: session.startedAt,
270
+ updatedAt,
271
+ endedAt: session.endedAt || null,
272
+ result: session.result || "",
273
+ error: session.error || "",
274
+ };
275
+ if (!this.db) {
276
+ const state = this.readJsonState();
277
+ const previous = state.sessions[session.sessionId] || {};
278
+ state.sessions[session.sessionId] = {
279
+ ...previous,
280
+ ...record,
281
+ title: record.title || previous.title || "",
282
+ };
283
+ this.writeJsonState(state);
284
+ this.syncSessionIndex(record);
285
+ return;
286
+ }
195
287
  this.db
196
288
  .prepare(
197
289
  `INSERT INTO sessions (
@@ -230,15 +322,14 @@ export class WebDatabase {
230
322
  session.result || "",
231
323
  session.error || ""
232
324
  );
325
+ this.syncSessionIndex(record);
326
+ }
327
+
328
+ syncSessionIndex(record) {
233
329
  try {
234
330
  upsertSessionIndex({
235
- ...session,
236
- projectRoot,
237
- commandCwd,
238
- projectSessionsDir,
239
- sessionDir,
240
- createdAt: session.startedAt,
241
- updatedAt,
331
+ ...record,
332
+ createdAt: record.startedAt,
242
333
  });
243
334
  } catch {
244
335
  // The project-local database remains the fallback if the global index cannot be updated.
@@ -246,6 +337,9 @@ export class WebDatabase {
246
337
  }
247
338
 
248
339
  getSession(sessionId) {
340
+ if (!this.db) {
341
+ return this.readJsonState().sessions[sessionId] || null;
342
+ }
249
343
  return (
250
344
  this.db
251
345
  .prepare(
@@ -273,6 +367,12 @@ export class WebDatabase {
273
367
  }
274
368
 
275
369
  listSessions(limit = 20) {
370
+ if (!this.db) {
371
+ const maxRows = Math.min(Math.max(Number(limit) || 20, 1), 1000);
372
+ return Object.values(this.readJsonState().sessions)
373
+ .sort((left, right) => String(right.updatedAt || "").localeCompare(String(left.updatedAt || "")))
374
+ .slice(0, maxRows);
375
+ }
276
376
  return this.db
277
377
  .prepare(
278
378
  `SELECT
@@ -300,6 +400,19 @@ export class WebDatabase {
300
400
 
301
401
  renameSession(sessionId, title) {
302
402
  const updatedAt = new Date().toISOString();
403
+ if (!this.db) {
404
+ const state = this.readJsonState();
405
+ if (!state.sessions[sessionId]) return false;
406
+ state.sessions[sessionId].title = title;
407
+ state.sessions[sessionId].updatedAt = updatedAt;
408
+ this.writeJsonState(state);
409
+ try {
410
+ renameSessionIndex(sessionId, title);
411
+ } catch {
412
+ // Keep the project-local JSON store as the source of truth.
413
+ }
414
+ return true;
415
+ }
303
416
  const result = this.db
304
417
  .prepare("UPDATE sessions SET title = ?, updated_at = ? WHERE session_id = ?")
305
418
  .run(title, updatedAt, sessionId);
@@ -308,6 +421,18 @@ export class WebDatabase {
308
421
  }
309
422
 
310
423
  deleteSession(sessionId) {
424
+ if (!this.db) {
425
+ const state = this.readJsonState();
426
+ if (!state.sessions[sessionId]) return false;
427
+ delete state.sessions[sessionId];
428
+ this.writeJsonState(state);
429
+ try {
430
+ deleteSessionIndex(sessionId);
431
+ } catch {
432
+ // Keep the project-local JSON store as the source of truth.
433
+ }
434
+ return true;
435
+ }
311
436
  const result = this.db.prepare("DELETE FROM sessions WHERE session_id = ?").run(sessionId);
312
437
  if (result.changes > 0) deleteSessionIndex(sessionId);
313
438
  return result.changes > 0;
package/web.js CHANGED
@@ -1397,6 +1397,7 @@ app.get("/health", (_req, res) => {
1397
1397
  sessionsDir,
1398
1398
  projectSessionsDir,
1399
1399
  packageDir,
1400
+ storageDriver: db.driver || "sqlite",
1400
1401
  });
1401
1402
  });
1402
1403