@lazyingart/agintiflow 0.20.198 → 0.20.199

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/bin/aginti-cli.js CHANGED
@@ -1,7 +1,22 @@
1
1
  #!/usr/bin/env node
2
- import { main } from "../src/cli.js";
2
+ import fs from "node:fs";
3
3
 
4
- main().catch((error) => {
4
+ const argv = process.argv.slice(2);
5
+
6
+ function fail(error) {
5
7
  console.error(error);
6
8
  process.exit(1);
7
- });
9
+ }
10
+
11
+ try {
12
+ if (["--version", "version", "-v"].includes(argv[0])) {
13
+ const packageJson = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8"));
14
+ console.log(packageJson.version);
15
+ } else {
16
+ import("../src/cli.js")
17
+ .then(({ main }) => main(argv))
18
+ .catch(fail);
19
+ }
20
+ } catch (error) {
21
+ fail(error);
22
+ }
@@ -8,6 +8,30 @@ AgInTiFlow keeps CLI and web runs equivalent by using a project-local session in
8
8
  - Canonical session store: `~/.agintiflow/sessions/<session-id>/`.
9
9
  - Runtime inbox: `~/.agintiflow/sessions/<session-id>/inbox.jsonl`.
10
10
 
11
+ ## Persistence Guarantees
12
+
13
+ Long-lived processes reuse one SQLite session-index connection and prepared
14
+ statement set per resolved `AGINTIFLOW_HOME`. The index enables WAL mode and a
15
+ bounded busy timeout so CLI, web, and bridge processes can update the
16
+ rebuildable index without repeatedly reopening and migrating the database.
17
+ Call `closeSessionIndexConnections()` in tests or embedding hosts that switch
18
+ runtime homes inside one process.
19
+
20
+ Each `SessionStore` memoizes directory/pointer initialization and serializes
21
+ its event appends. Concurrent callers therefore retain call order without
22
+ recreating the session pointer for every event. `state.json` remains an atomic,
23
+ fsynced save boundary. A missing state file is resumably absent; malformed JSON
24
+ raises `SESSION_STATE_CORRUPT` instead of silently looking like a new session.
25
+
26
+ ## Machine Run Input
27
+
28
+ `aginti run` uses deterministic input precedence: explicit `--stdin` reads
29
+ standard input, otherwise a positional prompt wins, and piped standard input is
30
+ used only when no positional prompt exists. This keeps positional automation
31
+ working in subprocesses whose stdin is non-interactive while preserving both
32
+ explicit and implicit pipe workflows. `aginti --version` is handled by the
33
+ lightweight launcher without loading the full agent and web runtime.
34
+
11
35
  When a run is active, the web chat and `aginti queue <session-id> "..."` append messages to the inbox instead of trying to mutate the running process directly. The web API exposes `GET /api/sessions/:id/inbox`, `POST /api/sessions/:id/inbox`, `PATCH /api/sessions/:id/inbox/:itemId`, and `DELETE /api/sessions/:id/inbox/:itemId` so browser users can inspect, edit, or remove pending pipe messages before the runner consumes them. The runner drains the inbox at safe boundaries: before each model step and after tool execution. This mirrors the event-queue style used by mature agent UIs while keeping the backend decoupled from any specific frontend.
12
36
 
13
37
  The interactive CLI keeps the input panel visible while a run is working. Enter sends the current draft as an ASAP pipe message and displays it as `→`; the runner drains those messages before normal inbox items and before after-finish queued prompts. Tab stores the draft as an after-finish queue item and displays it as `↳`; those prompts run only after the current run completes. Alt+Up moves the last pending `→` message back into the editor, and Shift+Left moves the last pending `↳` message back into the editor. Idle Esc is ignored so it does not redraw the prompt into the transcript. During a run, Esc waits when `→` pipe messages are still pending and stops the run only when no ASAP pipe message is pending; Ctrl+C always stops. The current command cwd is rendered below the input panel in both idle and running states.
@@ -0,0 +1,46 @@
1
+ # LabCanvas ChatOps Fallback
2
+
3
+ AgInTiFlow is a reasoning and tool-supervision fallback for LabCanvas. LabCanvas remains the owner of chat transport, schedules, exact-source media resolution, durable task state, routine selection, artifact validation, and delivery.
4
+
5
+ ## Contract
6
+
7
+ LabCanvas should pass one bounded task packet containing:
8
+
9
+ - exact current request and source-chat identity;
10
+ - latest same-chat interruptions and a small amount of attributed context;
11
+ - one selected routine and its contract paths;
12
+ - current deterministic preflight and stage state;
13
+ - exact artifact directory and irreversible-action gates.
14
+
15
+ AgInTi should read `AGENTS.md` and the selected routine contract, then call established commands. It should not invent a second scheduler, publication pipeline, media downloader, CAD generator, or delivery mechanism.
16
+
17
+ The default provider chain is `deepseek,localllm`. Switching providers is safe only when the first provider failed before inference or tool execution. Never replay an unknown task failure or timeout on another provider because the first attempt may already have caused a side effect.
18
+
19
+ ## Evidence Scope
20
+
21
+ ChatOps prompts may include a trusted single-line marker:
22
+
23
+ ```text
24
+ AGINTI_EVIDENCE_SCOPE_JSON: {"mode":"chat-response","request":"Produce only the requested chat response."}
25
+ ```
26
+
27
+ or:
28
+
29
+ ```text
30
+ AGINTI_EVIDENCE_SCOPE_JSON: {"mode":"task","request":"Create the requested PDF from the supplied evidence."}
31
+ ```
32
+
33
+ For `chat-response`, ordinary conversation and routing do not require file or command evidence. For `task`, evidence requirements are inferred only from the exact request, not from surrounding wrapper prose. Artifact requests still require real artifacts.
34
+
35
+ ## Local Context Recovery
36
+
37
+ LocalLLM planning compacts oversized goals to a bounded head-and-tail representation. Runtime compaction retains the first request and latest interruptions. A `LocalContextBudgetError` triggers one compact-and-retry cycle at the same step and records private recovery events. It does not authorize replaying task side effects.
38
+
39
+ Validate with:
40
+
41
+ ```bash
42
+ npm run check
43
+ npm run smoke:context-budget-recovery
44
+ npm run smoke:truthful-completion
45
+ ```
46
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lazyingart/agintiflow",
3
- "version": "0.20.198",
3
+ "version": "0.20.199",
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",
@@ -86,6 +86,7 @@
86
86
  "scripts/local-first-agent-eval.mjs",
87
87
  "scripts/fixtures/local-first-agent-eval-fixtures.mjs",
88
88
  "scripts/smoke-run-stdin.js",
89
+ "scripts/smoke-runtime-core.js",
89
90
  "scripts/smoke-mcp.js",
90
91
  "scripts/fixtures/mcp-stdio-smoke-server.mjs",
91
92
  "scripts/smoke-model-roles.js",
@@ -121,7 +122,7 @@
121
122
  "scripts": {
122
123
  "start": "node run.js",
123
124
  "web": "node web.js",
124
- "check": "node --check run.js && node --check web.js && node --check bin/aginti-cli.js && node --check bin/aginti-public-research.js && node --check bin/aginti-safe-chat.js && node --check src/*.js && node --check src/mcp/*.js && node --check public/*.js && node --check scripts/postinstall-webapp.js && node --check scripts/seed-supervised-homework.js && node --check scripts/smoke-agentlink.js && node --check scripts/smoke-execution-policy.js && node --check scripts/smoke-math-rendering.js && node --check scripts/smoke-mcp.js && node --check scripts/smoke-public-research-wrapper.js && node --check scripts/smoke-safe-chat.js && node --check scripts/smoke-web-ui.js && node --check scripts/smoke-scs-evidence-visibility.js && node --check scripts/fixtures/mcp-stdio-smoke-server.mjs",
125
+ "check": "node --check run.js && node --check web.js && node --check bin/aginti-cli.js && node --check bin/aginti-public-research.js && node --check bin/aginti-safe-chat.js && node --check src/*.js && node --check src/mcp/*.js && node --check public/*.js && node --check scripts/postinstall-webapp.js && node --check scripts/seed-supervised-homework.js && node --check scripts/smoke-agentlink.js && node --check scripts/smoke-execution-policy.js && node --check scripts/smoke-math-rendering.js && node --check scripts/smoke-mcp.js && node --check scripts/smoke-public-research-wrapper.js && node --check scripts/smoke-runtime-core.js && node --check scripts/smoke-safe-chat.js && node --check scripts/smoke-web-ui.js && node --check scripts/smoke-scs-evidence-visibility.js && node --check scripts/fixtures/mcp-stdio-smoke-server.mjs",
125
126
  "setup:toolchain-docker": "scripts/setup-agent-toolchain-docker.sh",
126
127
  "smoke:coding-tools": "node scripts/smoke-coding-tools.js",
127
128
  "smoke:dynamic-step-budget": "node scripts/smoke-dynamic-step-budget.js",
@@ -140,8 +141,10 @@
140
141
  "smoke:inbox": "node scripts/smoke-inbox.js",
141
142
  "smoke:long-jobs": "node scripts/smoke-long-jobs.js",
142
143
  "smoke:run-stdin": "node scripts/smoke-run-stdin.js",
144
+ "smoke:runtime-core": "node scripts/smoke-runtime-core.js",
143
145
  "smoke:localllm-auto-max": "node scripts/smoke-localllm-auto-max.js",
144
146
  "smoke:local-resource-policy": "node scripts/smoke-local-resource-policy.js",
147
+ "smoke:context-budget-recovery": "node scripts/smoke-context-budget-recovery.js",
145
148
  "smoke:localllm-model-tiers": "node scripts/smoke-localllm-model-tiers.js",
146
149
  "smoke:localllm-provider": "node scripts/smoke-localllm-provider.js",
147
150
  "smoke:progressive-tools": "node scripts/smoke-progressive-tool-selection.js",
@@ -171,7 +174,7 @@
171
174
  "storage:migrate": "node bin/aginti-cli.js storage migrate",
172
175
  "publish:env": "node scripts/npm-publish-from-env.js publish --access public",
173
176
  "publish:env:whoami": "node scripts/npm-publish-from-env.js whoami",
174
- "test": "npm run check && npm run smoke:localllm-provider && npm run smoke:localllm-model-tiers && npm run smoke:localllm-auto-max && npm run smoke:local-resource-policy && npm run smoke:session-runtime && npm run smoke:progressive-tools && npm run smoke:truthful-completion && npm run smoke:writing-specialist-routing && npm run eval:local-first-agent && npm run smoke:runtime-compat && npm run smoke:autoupdate && npm run smoke:web-api && npm run smoke:math-rendering && npm run smoke:web-ui && 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:execution-policy && npm run smoke:aaps-adapter && npm run smoke:auxiliary-tools && npm run smoke:perception-research && npm run smoke:public-research && npm run smoke:safe-chat && npm run smoke:auth && npm run smoke:agentlink && npm run smoke:canvas-artifacts && npm run smoke:capabilities && npm run smoke:mcp && 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:long-jobs && npm run smoke:run-stdin && npm run smoke:cli-chat && npm run smoke:inbox",
177
+ "test": "npm run check && npm run smoke:localllm-provider && npm run smoke:localllm-model-tiers && npm run smoke:localllm-auto-max && npm run smoke:local-resource-policy && npm run smoke:context-budget-recovery && npm run smoke:session-runtime && npm run smoke:runtime-core && npm run smoke:progressive-tools && npm run smoke:truthful-completion && npm run smoke:writing-specialist-routing && npm run eval:local-first-agent && npm run smoke:runtime-compat && npm run smoke:autoupdate && npm run smoke:web-api && npm run smoke:math-rendering && npm run smoke:web-ui && 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:execution-policy && npm run smoke:aaps-adapter && npm run smoke:auxiliary-tools && npm run smoke:perception-research && npm run smoke:public-research && npm run smoke:safe-chat && npm run smoke:auth && npm run smoke:agentlink && npm run smoke:canvas-artifacts && npm run smoke:capabilities && npm run smoke:mcp && 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:long-jobs && npm run smoke:run-stdin && npm run smoke:cli-chat && npm run smoke:inbox",
175
178
  "pack:dry-run": "npm pack --dry-run",
176
179
  "smoke:capabilities": "node scripts/smoke-capabilities.js"
177
180
  },
@@ -7,10 +7,8 @@ import { fileURLToPath } from "node:url";
7
7
 
8
8
  const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
9
9
  const home = await fs.mkdtemp(path.join(os.tmpdir(), "aginti-run-stdin-"));
10
- const command = [
11
- path.join(root, "bin", "aginti-cli.js"),
12
- "run",
13
- "--stdin",
10
+ const cliPath = path.join(root, "bin", "aginti-cli.js");
11
+ const machineOptions = [
14
12
  "--json",
15
13
  "--provider",
16
14
  "mock",
@@ -27,48 +25,70 @@ const command = [
27
25
  "host",
28
26
  ];
29
27
 
30
- const output = await new Promise((resolve, reject) => {
31
- const child = spawn(process.execPath, command, {
32
- cwd: home,
33
- stdio: ["pipe", "pipe", "pipe"],
34
- env: {
35
- ...process.env,
36
- AGINTIFLOW_HOME: path.join(home, ".agintiflow"),
37
- AGINTIFLOW_RUNTIME_DIR: "",
38
- AGINTIFLOW_NO_WEB_AUTO_START: "1",
39
- AGINTI_LANGUAGE: "en",
40
- },
28
+ async function runMachine(label, runArgs, stdin = "") {
29
+ const command = [cliPath, "run", ...runArgs];
30
+ const output = await new Promise((resolve, reject) => {
31
+ const child = spawn(process.execPath, command, {
32
+ cwd: home,
33
+ stdio: ["pipe", "pipe", "pipe"],
34
+ env: {
35
+ ...process.env,
36
+ AGINTIFLOW_HOME: path.join(home, ".agintiflow"),
37
+ AGINTIFLOW_RUNTIME_DIR: "",
38
+ AGINTIFLOW_NO_WEB_AUTO_START: "1",
39
+ AGINTI_LANGUAGE: "en",
40
+ },
41
+ });
42
+ let stdout = "";
43
+ let stderr = "";
44
+ const timer = setTimeout(() => {
45
+ child.kill("SIGKILL");
46
+ reject(new Error(`${label} timed out\nstdout=${stdout}\nstderr=${stderr}`));
47
+ }, process.env.CI ? 90000 : 45000);
48
+ child.stdout.on("data", (chunk) => {
49
+ stdout += String(chunk);
50
+ });
51
+ child.stderr.on("data", (chunk) => {
52
+ stderr += String(chunk);
53
+ });
54
+ child.on("error", reject);
55
+ child.on("close", (code) => {
56
+ clearTimeout(timer);
57
+ resolve({ code, stdout, stderr });
58
+ });
59
+ child.stdin.end(stdin);
41
60
  });
42
- let stdout = "";
43
- let stderr = "";
44
- const timer = setTimeout(() => {
45
- child.kill("SIGKILL");
46
- reject(new Error(`machine run timed out\nstdout=${stdout}\nstderr=${stderr}`));
47
- }, process.env.CI ? 90000 : 45000);
48
- child.stdout.on("data", (chunk) => {
49
- stdout += String(chunk);
50
- });
51
- child.stderr.on("data", (chunk) => {
52
- stderr += String(chunk);
53
- });
54
- child.on("error", reject);
55
- child.on("close", (code) => {
56
- clearTimeout(timer);
57
- resolve({ code, stdout, stderr });
58
- });
59
- child.stdin.end("Reply briefly that the machine transport smoke completed.");
60
- });
61
61
 
62
- const lines = output.stdout.trim().split(/\r?\n/).filter(Boolean);
63
- if (output.code !== 0) throw new Error(`machine run exited ${output.code}\n${output.stdout}\n${output.stderr}`);
64
- if (lines.length !== 1) throw new Error(`machine run emitted ${lines.length} stdout lines\n${output.stdout}`);
65
- const payload = JSON.parse(lines[0]);
66
- if (payload.ok !== true || !String(payload.result || "").trim()) {
67
- throw new Error(`machine run returned an unusable payload\n${output.stdout}`);
62
+ const lines = output.stdout.trim().split(/\r?\n/).filter(Boolean);
63
+ if (output.code !== 0) throw new Error(`${label} exited ${output.code}\n${output.stdout}\n${output.stderr}`);
64
+ if (lines.length !== 1) throw new Error(`${label} emitted ${lines.length} stdout lines\n${output.stdout}`);
65
+ const payload = JSON.parse(lines[0]);
66
+ if (payload.ok !== true || !String(payload.result || "").trim()) {
67
+ throw new Error(`${label} returned an unusable payload\n${output.stdout}`);
68
+ }
69
+ if (/(?:^|\n)(?:Session|Provider|Model|Routing|Workspace|Plan):/i.test(output.stdout)) {
70
+ throw new Error(`${label} leaked interactive metadata\n${output.stdout}`);
71
+ }
72
+ if (output.stderr.trim()) throw new Error(`${label} leaked stderr\n${output.stderr}`);
68
73
  }
69
- if (/(?:^|\n)(?:Session|Provider|Model|Routing|Workspace|Plan):/i.test(output.stdout)) {
70
- throw new Error(`machine run leaked interactive metadata\n${output.stdout}`);
74
+
75
+ try {
76
+ await runMachine(
77
+ "explicit stdin machine run",
78
+ ["--stdin", ...machineOptions],
79
+ "Reply briefly that the explicit stdin transport smoke completed."
80
+ );
81
+ await runMachine(
82
+ "positional prompt machine run",
83
+ [...machineOptions, "Reply briefly that the positional prompt smoke completed."]
84
+ );
85
+ await runMachine(
86
+ "implicit piped stdin machine run",
87
+ machineOptions,
88
+ "Reply briefly that the implicit piped stdin smoke completed."
89
+ );
90
+ } finally {
91
+ await fs.rm(home, { recursive: true, force: true });
71
92
  }
72
- if (output.stderr.trim()) throw new Error(`machine run leaked stderr\n${output.stderr}`);
73
93
 
74
- console.log("run stdin smoke passed");
94
+ console.log("run input precedence smoke passed");
@@ -0,0 +1,98 @@
1
+ #!/usr/bin/env node
2
+ import assert from "node:assert/strict";
3
+ import fs from "node:fs/promises";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+
7
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "aginti-runtime-core-"));
8
+ const originalHome = process.env.AGINTIFLOW_HOME;
9
+ const originalHousekeeping = process.env.AGINTIFLOW_HOUSEKEEPING;
10
+ process.env.AGINTIFLOW_HOUSEKEEPING = "0";
11
+
12
+ const {
13
+ closeSessionIndexConnections,
14
+ listSessionIndex,
15
+ sessionIndexConnectionCount,
16
+ upsertSessionIndex,
17
+ } = await import("../src/session-index.js");
18
+ const { SessionStore } = await import("../src/session-store.js");
19
+
20
+ try {
21
+ const firstHome = path.join(tempRoot, "home-one");
22
+ const firstProject = path.join(tempRoot, "project-one");
23
+ process.env.AGINTIFLOW_HOME = firstHome;
24
+
25
+ const indexStartedAt = performance.now();
26
+ for (let index = 0; index < 200; index += 1) {
27
+ assert.equal(
28
+ upsertSessionIndex({
29
+ sessionId: `runtime-${index}`,
30
+ projectRoot: firstProject,
31
+ commandCwd: firstProject,
32
+ status: "running",
33
+ goal: `runtime smoke ${index}`,
34
+ }),
35
+ true
36
+ );
37
+ }
38
+ const indexElapsedMs = performance.now() - indexStartedAt;
39
+ assert.equal(sessionIndexConnectionCount(), 1, "one runtime home should reuse one SQLite connection");
40
+ const firstRows = listSessionIndex({ projectRoot: firstProject, commandCwd: firstProject, limit: 500 });
41
+ assert.equal(firstRows.length, 200, "cached prepared queries lost indexed sessions");
42
+
43
+ const secondHome = path.join(tempRoot, "home-two");
44
+ const secondProject = path.join(tempRoot, "project-two");
45
+ process.env.AGINTIFLOW_HOME = secondHome;
46
+ assert.equal(
47
+ upsertSessionIndex({
48
+ sessionId: "other-home",
49
+ projectRoot: secondProject,
50
+ commandCwd: secondProject,
51
+ status: "saved",
52
+ }),
53
+ true
54
+ );
55
+ assert.equal(sessionIndexConnectionCount(), 2, "separate runtime homes must not share a SQLite connection");
56
+ assert.equal(listSessionIndex({ projectRoot: secondProject }).length, 1);
57
+ assert.equal(listSessionIndex({ projectRoot: firstProject }).length, 0, "runtime homes leaked index records");
58
+
59
+ process.env.AGINTIFLOW_HOME = firstHome;
60
+ const store = new SessionStore(path.join(firstHome, "sessions"), "event-order");
61
+ const eventStartedAt = performance.now();
62
+ await Promise.all(
63
+ Array.from({ length: 100 }, (_, index) => store.appendEvent("runtime.smoke", { index }))
64
+ );
65
+ const eventElapsedMs = performance.now() - eventStartedAt;
66
+ const events = await store.loadEvents();
67
+ assert.equal(events.length, 100, "concurrent appends lost or duplicated session events");
68
+ assert.deepEqual(
69
+ events.map((event) => event.data.index),
70
+ Array.from({ length: 100 }, (_, index) => index),
71
+ "concurrent session events were persisted out of order"
72
+ );
73
+
74
+ const missingStore = new SessionStore(path.join(firstHome, "sessions"), "missing-state");
75
+ assert.equal(await missingStore.loadState(), null, "an absent session state should remain resumably absent");
76
+
77
+ const corruptStore = new SessionStore(path.join(firstHome, "sessions"), "corrupt-state");
78
+ await fs.mkdir(corruptStore.sessionDir, { recursive: true });
79
+ await fs.writeFile(corruptStore.statePath, "{not-json\n", "utf8");
80
+ await assert.rejects(
81
+ corruptStore.loadState(),
82
+ (error) => error?.code === "SESSION_STATE_CORRUPT" && error?.name === "SessionStateCorruptionError",
83
+ "malformed session state must fail visibly instead of appearing missing"
84
+ );
85
+
86
+ closeSessionIndexConnections();
87
+ assert.equal(sessionIndexConnectionCount(), 0, "session index connections did not close cleanly");
88
+ console.log(
89
+ `runtime core smoke passed (200 index writes ${indexElapsedMs.toFixed(1)}ms; 100 ordered events ${eventElapsedMs.toFixed(1)}ms)`
90
+ );
91
+ } finally {
92
+ closeSessionIndexConnections();
93
+ if (originalHome === undefined) delete process.env.AGINTIFLOW_HOME;
94
+ else process.env.AGINTIFLOW_HOME = originalHome;
95
+ if (originalHousekeeping === undefined) delete process.env.AGINTIFLOW_HOUSEKEEPING;
96
+ else process.env.AGINTIFLOW_HOUSEKEEPING = originalHousekeeping;
97
+ await fs.rm(tempRoot, { recursive: true, force: true });
98
+ }
@@ -326,7 +326,11 @@ function compactJson(value, limit = 1200) {
326
326
  function compactMultiline(value = "", limit = 3600) {
327
327
  const text = redactSensitiveText(String(value || ""));
328
328
  if (!limit || text.length <= limit) return text;
329
- return `${text.slice(0, Math.max(0, limit - 80)).trimEnd()}\n... [truncated ${text.length - limit + 80} chars]`;
329
+ const marker = `\n... [${text.length - limit} chars omitted] ...\n`;
330
+ const available = Math.max(0, limit - marker.length);
331
+ const head = Math.floor(available * 0.35);
332
+ const tail = Math.max(0, available - head);
333
+ return `${text.slice(0, head).trimEnd()}${marker}${text.slice(-tail).trimStart()}`;
330
334
  }
331
335
 
332
336
  function safeParseToolContent(content) {
@@ -379,7 +383,9 @@ function summarizeOriginalRequests(messages = [], limit = 6) {
379
383
  if (/^Previous assistant response retained as compacted history/i.test(content)) continue;
380
384
  requests.push(compactSingleLine(content, 1200));
381
385
  }
382
- return [...new Set(requests)].slice(0, limit);
386
+ const unique = [...new Set(requests)];
387
+ if (unique.length <= limit) return unique;
388
+ return [unique[0], ...unique.slice(-(limit - 1))];
383
389
  }
384
390
 
385
391
  function countMessageChars(messages = []) {
@@ -393,7 +399,13 @@ function modelTimeoutMsForConfig(config = {}) {
393
399
 
394
400
  function buildCompactedRuntimeMessages(state, config, snapshot, step, options = {}) {
395
401
  const messages = Array.isArray(state?.messages) ? state.messages : [];
396
- const systemMessages = messages.filter((message) => message?.role === "system").slice(0, 4);
402
+ const systemMessages = messages
403
+ .filter((message) => message?.role === "system")
404
+ .slice(0, 3)
405
+ .map((message) => ({
406
+ ...message,
407
+ content: compactMultiline(message.content, 12000),
408
+ }));
397
409
  const requests = summarizeOriginalRequests(messages);
398
410
  const toolHistory = summarizeToolHistory(messages);
399
411
  const snapshotSummary = {
@@ -472,6 +484,10 @@ function isModelTimeoutError(error) {
472
484
  return error?.name === "ModelTimeoutError" || /timed out after \d+ms/i.test(String(error?.message || ""));
473
485
  }
474
486
 
487
+ function isLocalContextBudgetError(error) {
488
+ return error?.name === "LocalContextBudgetError" || error?.code === "LOCALLLM_CONTEXT_BUDGET_EXCEEDED";
489
+ }
490
+
475
491
  export function repairModelMessageHistory(state, config = {}) {
476
492
  if (!Array.isArray(state?.messages)) {
477
493
  return {
@@ -3438,39 +3454,83 @@ export async function runAgent(config) {
3438
3454
  response = await requestNextStep(client, config, state.messages);
3439
3455
  } catch (error) {
3440
3456
  const retryKey = `step-${step}`;
3441
- const retriedSteps = state.meta.modelTimeoutRetries || {};
3442
- if (!isModelTimeoutError(error) || retriedSteps[retryKey]) throw error;
3457
+ const contextRetriedSteps = state.meta.localContextBudgetRetries || {};
3458
+ if (isLocalContextBudgetError(error) && !contextRetriedSteps[retryKey]) {
3459
+ const compactMessages = buildContextBudgetCompactionMessages(
3460
+ state,
3461
+ config,
3462
+ snapshot,
3463
+ step,
3464
+ {
3465
+ reason: redactSensitiveText(
3466
+ error instanceof Error ? error.message : String(error)
3467
+ ),
3468
+ }
3469
+ );
3470
+ const detail = {
3471
+ step,
3472
+ provider: config.provider,
3473
+ model: config.model,
3474
+ messageCharsBefore: countMessageChars(state.messages),
3475
+ messageCharsAfter: countMessageChars(compactMessages),
3476
+ messageTokensBefore: estimateMessageTokens(state.messages),
3477
+ messageTokensAfter: estimateMessageTokens(compactMessages),
3478
+ error: redactSensitiveText(
3479
+ error instanceof Error ? error.message : String(error)
3480
+ ),
3481
+ };
3482
+ state.messages = compactMessages;
3483
+ state.meta.localContextBudgetRetries = {
3484
+ ...contextRetriedSteps,
3485
+ [retryKey]: true,
3486
+ };
3487
+ state.meta.lastLocalContextBudgetRecovery = detail;
3488
+ await store.appendEvent("model.local_context_budget_exceeded", detail);
3489
+ await store.appendEvent("history.compacted_for_local_context_retry", detail);
3490
+ observers.event("model.local_context_budget_exceeded", detail);
3491
+ observers.event("history.compacted_for_local_context_retry", detail);
3492
+ emitConsole(
3493
+ config,
3494
+ `Local provider context exceeded its configured window at step ${step}; compacted authoritative context and retrying once.`,
3495
+ { kind: "meta" }
3496
+ );
3497
+ await store.saveState(state);
3498
+ response = await requestNextStep(client, config, state.messages);
3499
+ } else {
3500
+ const retriedSteps = state.meta.modelTimeoutRetries || {};
3501
+ if (!isModelTimeoutError(error) || retriedSteps[retryKey]) throw error;
3443
3502
 
3444
- const timeoutMs = modelTimeoutMsForConfig(config);
3445
- const retryTimeoutMs = Math.max(timeoutMs * 2, 180000);
3446
- const compactMessages = buildModelTimeoutRetryMessages(state, config, snapshot, step, error);
3447
- const detail = {
3448
- step,
3449
- provider: config.provider,
3450
- model: config.model,
3451
- timeoutMs,
3452
- retryTimeoutMs,
3453
- messageCharsBefore: countMessageChars(state.messages),
3454
- messageCharsAfter: countMessageChars(compactMessages),
3455
- error: redactSensitiveText(error instanceof Error ? error.message : String(error)),
3456
- };
3457
- state.messages = compactMessages;
3458
- state.meta.modelTimeoutRetries = {
3459
- ...retriedSteps,
3460
- [retryKey]: true,
3461
- };
3462
- state.meta.lastModelTimeout = detail;
3463
- await store.appendEvent("model.timeout", detail);
3464
- await store.appendEvent("history.compacted_for_model_retry", detail);
3465
- observers.event("model.timeout", detail);
3466
- observers.event("history.compacted_for_model_retry", detail);
3467
- emitConsole(
3468
- config,
3469
- `Model request timed out after ${timeoutMs}ms; compacted history and retrying once with ${retryTimeoutMs}ms.`,
3470
- { kind: "meta" }
3471
- );
3472
- await store.saveState(state);
3473
- response = await requestNextStep(client, { ...config, modelTimeoutMs: retryTimeoutMs }, state.messages);
3503
+ const timeoutMs = modelTimeoutMsForConfig(config);
3504
+ const retryTimeoutMs = Math.max(timeoutMs * 2, 180000);
3505
+ const compactMessages = buildModelTimeoutRetryMessages(state, config, snapshot, step, error);
3506
+ const detail = {
3507
+ step,
3508
+ provider: config.provider,
3509
+ model: config.model,
3510
+ timeoutMs,
3511
+ retryTimeoutMs,
3512
+ messageCharsBefore: countMessageChars(state.messages),
3513
+ messageCharsAfter: countMessageChars(compactMessages),
3514
+ error: redactSensitiveText(error instanceof Error ? error.message : String(error)),
3515
+ };
3516
+ state.messages = compactMessages;
3517
+ state.meta.modelTimeoutRetries = {
3518
+ ...retriedSteps,
3519
+ [retryKey]: true,
3520
+ };
3521
+ state.meta.lastModelTimeout = detail;
3522
+ await store.appendEvent("model.timeout", detail);
3523
+ await store.appendEvent("history.compacted_for_model_retry", detail);
3524
+ observers.event("model.timeout", detail);
3525
+ observers.event("history.compacted_for_model_retry", detail);
3526
+ emitConsole(
3527
+ config,
3528
+ `Model request timed out after ${timeoutMs}ms; compacted history and retrying once with ${retryTimeoutMs}ms.`,
3529
+ { kind: "meta" }
3530
+ );
3531
+ await store.saveState(state);
3532
+ response = await requestNextStep(client, { ...config, modelTimeoutMs: retryTimeoutMs }, state.messages);
3533
+ }
3474
3534
  }
3475
3535
  const assistantMessage = response.choices[0]?.message;
3476
3536
  if (!assistantMessage) {
package/src/cli.js CHANGED
@@ -2398,7 +2398,12 @@ export async function main(argv = process.argv.slice(2)) {
2398
2398
  const optionArgv = runArgv.filter((arg) => !["--stdin", "--json"].includes(arg));
2399
2399
  const parsedRunArgs = parseArgs(optionArgv);
2400
2400
  exitOnUnknownOptions(parsedRunArgs);
2401
- const prompt = stdinFlag || !process.stdin.isTTY ? await readStdin() : parsedRunArgs.goal;
2401
+ let prompt = parsedRunArgs.goal;
2402
+ if (stdinFlag) {
2403
+ prompt = await readStdin();
2404
+ } else if (!prompt && !process.stdin.isTTY) {
2405
+ prompt = await readStdin();
2406
+ }
2402
2407
  if (!prompt) {
2403
2408
  if (jsonFlag) {
2404
2409
  console.log(JSON.stringify({ ok: false, result: "", reason: "empty_prompt" }));
@@ -49,6 +49,37 @@ export function estimateTextTokens(value = "") {
49
49
  return Math.ceil(ascii / 3) + Math.ceil(nonAscii * 1.5);
50
50
  }
51
51
 
52
+ export function compactTextForTokenBudget(value = "", maxTokens = 0, options = {}) {
53
+ const text = String(value ?? "");
54
+ const budget = positiveInteger(maxTokens, 0);
55
+ if (!budget || estimateTextTokens(text) <= budget) return text;
56
+
57
+ const headFraction = Math.min(0.8, Math.max(0.2, Number(options.headFraction ?? 0.35)));
58
+ const buildCandidate = (keptChars) => {
59
+ const kept = Math.max(0, Math.min(text.length, Math.floor(keptChars)));
60
+ const headChars = Math.floor(kept * headFraction);
61
+ const tailChars = Math.max(0, kept - headChars);
62
+ const omitted = Math.max(0, text.length - headChars - tailChars);
63
+ const marker = `\n\n[... ${omitted} chars omitted to fit the provider context ...]\n\n`;
64
+ return `${text.slice(0, headChars).trimEnd()}${marker}${text.slice(text.length - tailChars).trimStart()}`;
65
+ };
66
+
67
+ let low = 0;
68
+ let high = text.length;
69
+ let best = "[... content omitted to fit the provider context ...]";
70
+ while (low <= high) {
71
+ const middle = Math.floor((low + high) / 2);
72
+ const candidate = buildCandidate(middle);
73
+ if (estimateTextTokens(candidate) <= budget) {
74
+ best = candidate;
75
+ low = middle + 1;
76
+ } else {
77
+ high = middle - 1;
78
+ }
79
+ }
80
+ return best;
81
+ }
82
+
52
83
  export function estimateMessageTokens(messages = []) {
53
84
  return (Array.isArray(messages) ? messages : []).reduce((total, message) => {
54
85
  let count = 6 + estimateTextTokens(message?.role || "") + estimateTextTokens(message?.content || "");
@@ -19,7 +19,11 @@ import {
19
19
  providerSupportsReasoningEffort,
20
20
  } from "./provider-contract.js";
21
21
  import { selectProgressiveTools } from "./progressive-tool-selection.js";
22
- import { estimateMessageTokens, estimateToolSchemaTokens } from "./context-budget-controller.js";
22
+ import {
23
+ compactTextForTokenBudget,
24
+ estimateMessageTokens,
25
+ estimateToolSchemaTokens,
26
+ } from "./context-budget-controller.js";
23
27
  import { attachToolContract } from "./tool-contract.js";
24
28
 
25
29
  export function createClient(config) {
@@ -834,6 +838,14 @@ export async function createPlan(client, config, state) {
834
838
  const planMaxTokens = normalizeProviderId(config.provider, "") === "localllm"
835
839
  ? Math.min(2048, Number(config.maxOutputTokens || 2048))
836
840
  : 0;
841
+ const localContextWindow = Number(config.contextWindowTokens || 32768);
842
+ const planGoal = planMaxTokens > 0
843
+ ? compactTextForTokenBudget(
844
+ state.goal,
845
+ Math.max(2048, Math.min(8192, Math.floor(localContextWindow * 0.3))),
846
+ { headFraction: 0.3 }
847
+ )
848
+ : state.goal;
837
849
  const planPayload = {
838
850
  model: config.model,
839
851
  temperature: 0,
@@ -846,7 +858,7 @@ export async function createPlan(client, config, state) {
846
858
  {
847
859
  role: "user",
848
860
  content: [
849
- `Goal: ${state.goal}`,
861
+ `Goal: ${planGoal}`,
850
862
  state.startUrl ? `Suggested start URL: ${state.startUrl}` : "",
851
863
  config.allowedDomains.length > 0 ? `Allowed domains: ${config.allowedDomains.join(", ")}` : "",
852
864
  config.allowShellTool
@@ -912,9 +924,33 @@ export async function createPlan(client, config, state) {
912
924
  },
913
925
  ],
914
926
  ...(planMaxTokens > 0 ? { max_tokens: planMaxTokens } : {}),
915
- };
927
+ };
916
928
  const planConfig = planMaxTokens > 0 ? { ...config, maxOutputTokens: planMaxTokens } : config;
917
- assertLocalRequestWithinContext(planPayload, planConfig, "plan request");
929
+ try {
930
+ assertLocalRequestWithinContext(planPayload, planConfig, "plan request");
931
+ } catch (error) {
932
+ if (error?.name !== "LocalContextBudgetError") throw error;
933
+ const minimalGoal = compactTextForTokenBudget(state.goal, 4096, { headFraction: 0.3 });
934
+ planPayload.messages = [
935
+ {
936
+ role: "system",
937
+ content:
938
+ "Plan one exact agent task in 3 to 6 concise steps. Use the established project routines and tools; do not redesign a mature workflow. Preserve current intent, later interruptions, safety gates, and requested artifacts.",
939
+ },
940
+ {
941
+ role: "user",
942
+ content: [
943
+ `Goal: ${minimalGoal}`,
944
+ `Task profile: ${taskProfile.label}. ${taskProfile.prompt}`,
945
+ projectInstructions?.exists
946
+ ? `Project instructions are available at ${projectInstructions.path}; read them when executing.`
947
+ : "Read AGENTS.md/README and the exact routine contract when present.",
948
+ "Return a numbered plan only.",
949
+ ].join("\n"),
950
+ },
951
+ ];
952
+ assertLocalRequestWithinContext(planPayload, planConfig, "compacted plan request");
953
+ }
918
954
  const response = await createChatCompletion(client, planPayload, planConfig, "plan request");
919
955
 
920
956
  return redactSensitiveText(response.choices[0]?.message?.content?.trim() || "1. Inspect the page.\n2. Use the smallest safe action.\n3. Finish with a concise answer.");
@@ -495,29 +495,48 @@ function stripForbiddenLanguage(goal = "") {
495
495
  .replace(/禁止([^。\n;]+)/g, "");
496
496
  }
497
497
 
498
+ function scopedChatopsEvidenceGoal(goal = "", taskProfile = "") {
499
+ if (String(taskProfile || "").trim().toLowerCase() !== "chatops") return String(goal || "");
500
+ const match = String(goal || "").match(/^AGINTI_EVIDENCE_SCOPE_JSON:\s*(\{[^\n]+\})\s*$/m);
501
+ if (!match) return String(goal || "");
502
+ try {
503
+ const payload = JSON.parse(match[1]);
504
+ if (!payload || typeof payload !== "object") return String(goal || "");
505
+ const mode = String(payload.mode || "").trim().toLowerCase();
506
+ if (mode === "chat-response") {
507
+ return "Answer the current chat turn directly without external execution.";
508
+ }
509
+ const request = String(payload.request || "").trim();
510
+ return request || String(goal || "");
511
+ } catch {
512
+ return String(goal || "");
513
+ }
514
+ }
515
+
498
516
  export function deriveScsTaskContract({ goal = "", taskProfile = "", acceptanceCriteria = [] } = {}) {
499
- const requirementCategories = inferRequirementCategories(goal, taskProfile, acceptanceCriteria);
500
- const requiredToolCalls = inferRequiredToolCalls(goal);
501
- const requiresExternalEvidence = requirementCategories.length > 0 || requiredToolCalls.length > 0 || goalRequiresEvidence(goal, taskProfile);
517
+ const evidenceGoal = scopedChatopsEvidenceGoal(goal, taskProfile);
518
+ const requirementCategories = inferRequirementCategories(evidenceGoal, taskProfile, acceptanceCriteria);
519
+ const requiredToolCalls = inferRequiredToolCalls(evidenceGoal);
520
+ const requiresExternalEvidence = requirementCategories.length > 0 || requiredToolCalls.length > 0 || goalRequiresEvidence(evidenceGoal, taskProfile);
502
521
  const requiredEvidence = requirementCategories.map((category) => ({
503
522
  id: category,
504
523
  category,
505
524
  description: CATEGORY_LABELS[category] || category,
506
525
  }));
507
- const exactOutputPaths = inferExactOutputPaths(goal);
508
- const exactInputPaths = inferExactInputPaths(goal).filter((item) => !exactOutputPaths.includes(item));
526
+ const exactOutputPaths = inferExactOutputPaths(evidenceGoal);
527
+ const exactInputPaths = inferExactInputPaths(evidenceGoal).filter((item) => !exactOutputPaths.includes(item));
509
528
  return {
510
529
  version: 1,
511
- outcome: compact(goal || "Complete the requested task.", 500),
530
+ outcome: compact(evidenceGoal || "Complete the requested task.", 500),
512
531
  taskProfile: String(taskProfile || "auto"),
513
532
  requiresExternalEvidence,
514
533
  requiredEvidence,
515
- forbiddenActions: inferForbiddenActions(goal),
534
+ forbiddenActions: inferForbiddenActions(evidenceGoal),
516
535
  exactOutputPaths,
517
536
  exactInputPaths,
518
537
  requiredToolCalls,
519
- requiredTextTerms: inferRequiredTextTerms(goal),
520
- forbiddenTextTerms: inferForbiddenTextTerms(goal),
538
+ requiredTextTerms: inferRequiredTextTerms(evidenceGoal),
539
+ forbiddenTextTerms: inferForbiddenTextTerms(evidenceGoal),
521
540
  successCriteria: unique(acceptanceCriteria).slice(0, 10),
522
541
  };
523
542
  }
@@ -6,6 +6,11 @@ import { loadDatabaseSync } from "./sqlite.js";
6
6
  export const PROJECT_SESSIONS_DIR_NAME = ".aginti-sessions";
7
7
  export const LEGACY_PROJECT_SESSIONS_DIR_NAME = ".sessions";
8
8
 
9
+ const SESSION_COLUMNS = `session_id AS sessionId, project_root AS projectRoot, command_cwd AS commandCwd, project_sessions_dir AS projectSessionsDir,
10
+ session_dir AS sessionDir, provider, model, goal, title, status,
11
+ created_at AS createdAt, updated_at AS updatedAt, ended_at AS endedAt, result, error`;
12
+ const indexDbConnections = new Map();
13
+
9
14
  export function agintiflowHome() {
10
15
  return path.resolve(process.env.AGINTIFLOW_HOME || path.join(os.homedir(), ".agintiflow"));
11
16
  }
@@ -29,34 +34,106 @@ export function isSafeSessionId(sessionId) {
29
34
 
30
35
  function ensureIndexDb() {
31
36
  const paths = globalSessionPaths();
37
+ if (indexDbConnections.has(paths.indexDbPath)) return indexDbConnections.get(paths.indexDbPath);
32
38
  fs.mkdirSync(paths.sessionsDir, { recursive: true });
33
39
  const DatabaseSync = loadDatabaseSync({ optional: true });
34
- if (!DatabaseSync) return null;
40
+ if (!DatabaseSync) {
41
+ indexDbConnections.set(paths.indexDbPath, null);
42
+ return null;
43
+ }
35
44
  const db = new DatabaseSync(paths.indexDbPath);
36
- db.exec(`
37
- CREATE TABLE IF NOT EXISTS sessions (
38
- session_id TEXT PRIMARY KEY,
39
- project_root TEXT NOT NULL DEFAULT '',
40
- command_cwd TEXT NOT NULL DEFAULT '',
41
- project_sessions_dir TEXT NOT NULL DEFAULT '',
42
- session_dir TEXT NOT NULL DEFAULT '',
43
- provider TEXT NOT NULL DEFAULT '',
44
- model TEXT NOT NULL DEFAULT '',
45
- goal TEXT NOT NULL DEFAULT '',
46
- title TEXT NOT NULL DEFAULT '',
47
- status TEXT NOT NULL DEFAULT '',
48
- created_at TEXT NOT NULL DEFAULT '',
49
- updated_at TEXT NOT NULL DEFAULT '',
50
- ended_at TEXT,
51
- result TEXT NOT NULL DEFAULT '',
52
- error TEXT NOT NULL DEFAULT ''
53
- );
54
- `);
55
- const existingColumns = db.prepare("PRAGMA table_info(sessions)").all();
56
- if (!existingColumns.some((column) => column.name === "command_cwd")) {
57
- db.exec("ALTER TABLE sessions ADD COLUMN command_cwd TEXT NOT NULL DEFAULT ''");
45
+ try {
46
+ db.exec("PRAGMA busy_timeout = 5000");
47
+ db.exec("PRAGMA journal_mode = WAL");
48
+ db.exec(`
49
+ CREATE TABLE IF NOT EXISTS sessions (
50
+ session_id TEXT PRIMARY KEY,
51
+ project_root TEXT NOT NULL DEFAULT '',
52
+ command_cwd TEXT NOT NULL DEFAULT '',
53
+ project_sessions_dir TEXT NOT NULL DEFAULT '',
54
+ session_dir TEXT NOT NULL DEFAULT '',
55
+ provider TEXT NOT NULL DEFAULT '',
56
+ model TEXT NOT NULL DEFAULT '',
57
+ goal TEXT NOT NULL DEFAULT '',
58
+ title TEXT NOT NULL DEFAULT '',
59
+ status TEXT NOT NULL DEFAULT '',
60
+ created_at TEXT NOT NULL DEFAULT '',
61
+ updated_at TEXT NOT NULL DEFAULT '',
62
+ ended_at TEXT,
63
+ result TEXT NOT NULL DEFAULT '',
64
+ error TEXT NOT NULL DEFAULT ''
65
+ );
66
+ `);
67
+ const existingColumns = db.prepare("PRAGMA table_info(sessions)").all();
68
+ if (!existingColumns.some((column) => column.name === "command_cwd")) {
69
+ try {
70
+ db.exec("ALTER TABLE sessions ADD COLUMN command_cwd TEXT NOT NULL DEFAULT ''");
71
+ } catch (error) {
72
+ const migratedColumns = db.prepare("PRAGMA table_info(sessions)").all();
73
+ if (!migratedColumns.some((column) => column.name === "command_cwd")) throw error;
74
+ }
75
+ }
76
+
77
+ const connection = {
78
+ db,
79
+ upsert: db.prepare(
80
+ `INSERT INTO sessions (
81
+ session_id, project_root, command_cwd, project_sessions_dir, session_dir,
82
+ provider, model, goal, title, status, created_at, updated_at, ended_at, result, error
83
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
84
+ ON CONFLICT(session_id) DO UPDATE SET
85
+ project_root = CASE WHEN excluded.project_root != '' THEN excluded.project_root ELSE sessions.project_root END,
86
+ command_cwd = CASE WHEN excluded.command_cwd != '' THEN excluded.command_cwd ELSE sessions.command_cwd END,
87
+ project_sessions_dir = CASE WHEN excluded.project_sessions_dir != '' THEN excluded.project_sessions_dir ELSE sessions.project_sessions_dir END,
88
+ session_dir = CASE WHEN excluded.session_dir != '' THEN excluded.session_dir ELSE sessions.session_dir END,
89
+ provider = CASE WHEN excluded.provider != '' THEN excluded.provider ELSE sessions.provider END,
90
+ model = CASE WHEN excluded.model != '' THEN excluded.model ELSE sessions.model END,
91
+ goal = CASE WHEN excluded.goal != '' THEN excluded.goal ELSE sessions.goal END,
92
+ title = CASE WHEN excluded.title != '' THEN excluded.title ELSE sessions.title END,
93
+ status = CASE WHEN excluded.status != '' THEN excluded.status ELSE sessions.status END,
94
+ updated_at = excluded.updated_at,
95
+ ended_at = excluded.ended_at,
96
+ result = CASE WHEN excluded.result != '' THEN excluded.result ELSE sessions.result END,
97
+ error = CASE WHEN excluded.error != '' THEN excluded.error ELSE sessions.error END`
98
+ ),
99
+ rename: db.prepare("UPDATE sessions SET title = ?, updated_at = ? WHERE session_id = ?"),
100
+ remove: db.prepare("DELETE FROM sessions WHERE session_id = ?"),
101
+ listAll: db.prepare(`SELECT ${SESSION_COLUMNS} FROM sessions ORDER BY updated_at DESC LIMIT ?`),
102
+ listByProjectRoot: db.prepare(
103
+ `SELECT ${SESSION_COLUMNS} FROM sessions WHERE project_root = ? ORDER BY updated_at DESC LIMIT ?`
104
+ ),
105
+ listByCommandCwd: db.prepare(
106
+ `SELECT ${SESSION_COLUMNS} FROM sessions WHERE command_cwd = ? ORDER BY updated_at DESC LIMIT ?`
107
+ ),
108
+ listByProjectAndCwd: db.prepare(
109
+ `SELECT ${SESSION_COLUMNS} FROM sessions WHERE project_root = ? AND command_cwd = ? ORDER BY updated_at DESC LIMIT ?`
110
+ ),
111
+ };
112
+ indexDbConnections.set(paths.indexDbPath, connection);
113
+ return connection;
114
+ } catch (error) {
115
+ try {
116
+ db.close();
117
+ } catch {
118
+ // Preserve the initialization error.
119
+ }
120
+ throw error;
121
+ }
122
+ }
123
+
124
+ export function sessionIndexConnectionCount() {
125
+ return [...indexDbConnections.values()].filter(Boolean).length;
126
+ }
127
+
128
+ export function closeSessionIndexConnections() {
129
+ for (const connection of indexDbConnections.values()) {
130
+ try {
131
+ connection?.db.close();
132
+ } catch {
133
+ // Closing is best effort during tests and process shutdown.
134
+ }
58
135
  }
59
- return db;
136
+ indexDbConnections.clear();
60
137
  }
61
138
 
62
139
  function emptyIndexState() {
@@ -115,9 +192,9 @@ function normalizeIndexRecord(record = {}, sessionId = "") {
115
192
  export function upsertSessionIndex(record = {}) {
116
193
  const sessionId = String(record.sessionId || record.session_id || "").trim();
117
194
  if (!isSafeSessionId(sessionId)) return false;
118
- const db = ensureIndexDb();
195
+ const index = ensureIndexDb();
119
196
  const normalized = normalizeIndexRecord(record, sessionId);
120
- if (!db) {
197
+ if (!index) {
121
198
  const state = readIndexJson();
122
199
  const previous = state.sessions[sessionId] || {};
123
200
  state.sessions[sessionId] = {
@@ -139,26 +216,7 @@ export function upsertSessionIndex(record = {}) {
139
216
  return true;
140
217
  }
141
218
  const paths = globalSessionPaths(sessionId);
142
- db.prepare(
143
- `INSERT INTO sessions (
144
- session_id, project_root, command_cwd, project_sessions_dir, session_dir,
145
- provider, model, goal, title, status, created_at, updated_at, ended_at, result, error
146
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
147
- ON CONFLICT(session_id) DO UPDATE SET
148
- project_root = CASE WHEN excluded.project_root != '' THEN excluded.project_root ELSE sessions.project_root END,
149
- command_cwd = CASE WHEN excluded.command_cwd != '' THEN excluded.command_cwd ELSE sessions.command_cwd END,
150
- project_sessions_dir = CASE WHEN excluded.project_sessions_dir != '' THEN excluded.project_sessions_dir ELSE sessions.project_sessions_dir END,
151
- session_dir = CASE WHEN excluded.session_dir != '' THEN excluded.session_dir ELSE sessions.session_dir END,
152
- provider = CASE WHEN excluded.provider != '' THEN excluded.provider ELSE sessions.provider END,
153
- model = CASE WHEN excluded.model != '' THEN excluded.model ELSE sessions.model END,
154
- goal = CASE WHEN excluded.goal != '' THEN excluded.goal ELSE sessions.goal END,
155
- title = CASE WHEN excluded.title != '' THEN excluded.title ELSE sessions.title END,
156
- status = CASE WHEN excluded.status != '' THEN excluded.status ELSE sessions.status END,
157
- updated_at = excluded.updated_at,
158
- ended_at = excluded.ended_at,
159
- result = CASE WHEN excluded.result != '' THEN excluded.result ELSE sessions.result END,
160
- error = CASE WHEN excluded.error != '' THEN excluded.error ELSE sessions.error END`
161
- ).run(
219
+ index.upsert.run(
162
220
  sessionId,
163
221
  normalized.projectRoot,
164
222
  normalized.commandCwd,
@@ -180,8 +238,8 @@ export function upsertSessionIndex(record = {}) {
180
238
 
181
239
  export function renameSessionIndex(sessionId, title) {
182
240
  if (!isSafeSessionId(sessionId)) return false;
183
- const db = ensureIndexDb();
184
- if (!db) {
241
+ const index = ensureIndexDb();
242
+ if (!index) {
185
243
  const state = readIndexJson();
186
244
  if (!state.sessions[sessionId]) return false;
187
245
  state.sessions[sessionId].title = String(title || "").trim();
@@ -189,30 +247,28 @@ export function renameSessionIndex(sessionId, title) {
189
247
  writeIndexJson(state);
190
248
  return true;
191
249
  }
192
- const result = db
193
- .prepare("UPDATE sessions SET title = ?, updated_at = ? WHERE session_id = ?")
194
- .run(String(title || "").trim(), new Date().toISOString(), sessionId);
250
+ const result = index.rename.run(String(title || "").trim(), new Date().toISOString(), sessionId);
195
251
  return result.changes > 0;
196
252
  }
197
253
 
198
254
  export function deleteSessionIndex(sessionId) {
199
255
  if (!isSafeSessionId(sessionId)) return false;
200
- const db = ensureIndexDb();
201
- if (!db) {
256
+ const index = ensureIndexDb();
257
+ if (!index) {
202
258
  const state = readIndexJson();
203
259
  if (!state.sessions[sessionId]) return false;
204
260
  delete state.sessions[sessionId];
205
261
  writeIndexJson(state);
206
262
  return true;
207
263
  }
208
- const result = db.prepare("DELETE FROM sessions WHERE session_id = ?").run(sessionId);
264
+ const result = index.remove.run(sessionId);
209
265
  return result.changes > 0;
210
266
  }
211
267
 
212
268
  export function listSessionIndex({ projectRoot = "", commandCwd = "", limit = 100 } = {}) {
213
- const db = ensureIndexDb();
269
+ const index = ensureIndexDb();
214
270
  const maxRows = Math.min(Math.max(Number(limit) || 100, 1), 1000);
215
- if (!db) {
271
+ if (!index) {
216
272
  const resolvedProjectRoot = projectRoot ? path.resolve(projectRoot) : "";
217
273
  const resolvedCommandCwd = commandCwd ? path.resolve(commandCwd) : "";
218
274
  return Object.values(readIndexJson().sessions)
@@ -224,23 +280,12 @@ export function listSessionIndex({ projectRoot = "", commandCwd = "", limit = 10
224
280
  .sort((left, right) => String(right.updatedAt || "").localeCompare(String(left.updatedAt || "")))
225
281
  .slice(0, maxRows);
226
282
  }
227
- const columns = `session_id AS sessionId, project_root AS projectRoot, command_cwd AS commandCwd, project_sessions_dir AS projectSessionsDir,
228
- session_dir AS sessionDir, provider, model, goal, title, status,
229
- created_at AS createdAt, updated_at AS updatedAt, ended_at AS endedAt, result, error`;
230
- const clauses = [];
231
- const params = [];
232
- if (projectRoot) {
233
- clauses.push("project_root = ?");
234
- params.push(path.resolve(projectRoot));
235
- }
236
- if (commandCwd) {
237
- clauses.push("command_cwd = ?");
238
- params.push(path.resolve(commandCwd));
239
- }
240
- if (clauses.length > 0) {
241
- return db
242
- .prepare(`SELECT ${columns} FROM sessions WHERE ${clauses.join(" AND ")} ORDER BY updated_at DESC LIMIT ?`)
243
- .all(...params, maxRows);
283
+ const resolvedProjectRoot = projectRoot ? path.resolve(projectRoot) : "";
284
+ const resolvedCommandCwd = commandCwd ? path.resolve(commandCwd) : "";
285
+ if (resolvedProjectRoot && resolvedCommandCwd) {
286
+ return index.listByProjectAndCwd.all(resolvedProjectRoot, resolvedCommandCwd, maxRows);
244
287
  }
245
- return db.prepare(`SELECT ${columns} FROM sessions ORDER BY updated_at DESC LIMIT ?`).all(maxRows);
288
+ if (resolvedProjectRoot) return index.listByProjectRoot.all(resolvedProjectRoot, maxRows);
289
+ if (resolvedCommandCwd) return index.listByCommandCwd.all(resolvedCommandCwd, maxRows);
290
+ return index.listAll.all(maxRows);
246
291
  }
@@ -75,6 +75,26 @@ async function appendDurably(filePath, content) {
75
75
  if (!existed) await syncDirectory(directoryPath);
76
76
  }
77
77
 
78
+ async function loadStateFile(filePath) {
79
+ let raw;
80
+ try {
81
+ raw = await fs.readFile(filePath, "utf8");
82
+ } catch (error) {
83
+ if (error?.code === "ENOENT") return null;
84
+ throw error;
85
+ }
86
+
87
+ try {
88
+ return JSON.parse(raw);
89
+ } catch (error) {
90
+ const corruptionError = new Error(`Session state is not valid JSON: ${filePath}`);
91
+ corruptionError.name = "SessionStateCorruptionError";
92
+ corruptionError.code = "SESSION_STATE_CORRUPT";
93
+ corruptionError.cause = error;
94
+ throw corruptionError;
95
+ }
96
+ }
97
+
78
98
  function inboxItemKey(item = {}) {
79
99
  const id = String(item.id || "").trim();
80
100
  if (id) return `id:${id}`;
@@ -167,11 +187,23 @@ export class SessionStore {
167
187
  DEFAULT_INBOX_COMPACTION_BYTE_THRESHOLD
168
188
  );
169
189
  this.inboxLockTimeoutMs = positiveInteger(options.inboxLockTimeoutMs, DEFAULT_INBOX_LOCK_TIMEOUT_MS);
190
+ this.ensurePromise = null;
191
+ this.eventAppendTail = Promise.resolve();
170
192
  }
171
193
 
172
194
  async ensure() {
173
- await fs.mkdir(this.artifactsDir, { recursive: true });
174
- await this.writePointer().catch(() => {});
195
+ if (!this.ensurePromise) {
196
+ this.ensurePromise = (async () => {
197
+ await fs.mkdir(this.artifactsDir, { recursive: true });
198
+ await this.writePointer().catch(() => {});
199
+ })();
200
+ }
201
+ try {
202
+ await this.ensurePromise;
203
+ } catch (error) {
204
+ this.ensurePromise = null;
205
+ throw error;
206
+ }
175
207
  }
176
208
 
177
209
  async writePointer(state = {}) {
@@ -196,20 +228,10 @@ export class SessionStore {
196
228
  }
197
229
 
198
230
  async loadState() {
199
- try {
200
- const raw = await fs.readFile(this.statePath, "utf8");
201
- return JSON.parse(raw);
202
- } catch {
203
- if (this.legacySessionDir) {
204
- try {
205
- const raw = await fs.readFile(path.join(this.legacySessionDir, "state.json"), "utf8");
206
- return JSON.parse(raw);
207
- } catch {
208
- return null;
209
- }
210
- }
211
- return null;
212
- }
231
+ const currentState = await loadStateFile(this.statePath);
232
+ if (currentState !== null) return currentState;
233
+ if (!this.legacySessionDir) return null;
234
+ return loadStateFile(path.join(this.legacySessionDir, "state.json"));
213
235
  }
214
236
 
215
237
  async saveState(state) {
@@ -248,20 +270,24 @@ export class SessionStore {
248
270
  }
249
271
 
250
272
  async appendEvent(type, data = {}) {
251
- await this.ensure();
252
- const event = {
253
- timestamp: new Date().toISOString(),
254
- type,
255
- data,
256
- };
257
- const line = JSON.stringify(event);
258
- await fs.appendFile(this.eventsPath, `${line}\n`, "utf8");
259
- enqueueHousekeepingEvent({
260
- sessionId: this.sessionId,
261
- projectRoot: this.projectRoot,
262
- commandCwd: this.commandCwd,
263
- event,
273
+ const operation = this.eventAppendTail.then(async () => {
274
+ await this.ensure();
275
+ const event = {
276
+ timestamp: new Date().toISOString(),
277
+ type,
278
+ data,
279
+ };
280
+ const line = JSON.stringify(event);
281
+ await fs.appendFile(this.eventsPath, `${line}\n`, "utf8");
282
+ enqueueHousekeepingEvent({
283
+ sessionId: this.sessionId,
284
+ projectRoot: this.projectRoot,
285
+ commandCwd: this.commandCwd,
286
+ event,
287
+ });
264
288
  });
289
+ this.eventAppendTail = operation.catch(() => {});
290
+ return operation;
265
291
  }
266
292
 
267
293
  async loadEvents() {
@@ -681,8 +707,11 @@ export class SessionStore {
681
707
  }
682
708
 
683
709
  async remove() {
710
+ await this.eventAppendTail.catch(() => {});
684
711
  await fs.rm(this.sessionDir, { recursive: true, force: true });
685
712
  if (this.pointerDir) await fs.rm(this.pointerDir, { recursive: true, force: true }).catch(() => {});
686
713
  deleteSessionIndex(this.sessionId);
714
+ this.ensurePromise = null;
715
+ this.eventAppendTail = Promise.resolve();
687
716
  }
688
717
  }