# congressional-trading

- Repo: `~/projects/congressional-trading` · https://github.com/jamesjlopez/congressional-trading
- Goal: Score congressional trade disclosures (0–13 scale) and push high-conviction buy signals via ntfy when score ≥ 9 + quality gates pass. In shadow/measurement mode — scoring and logging signals but not pushing live notifications yet.
- Stage: test
- Guard: autonomous-ok
- Metric: Signal precision (% of signals that are wins at 30/60d) over shadow period — threshold TBD by James
- Blocked on James: define exit criteria — what win-rate and sample size graduates it to live?
- Next action: Await James's threshold decision (11.5/LOW/edge≥2 config); weekly precision monitor running (Mondays 9am CDT)

## Notes
- 2026-07-06 — Seeded from git activity: "Keep shadow measurement active during..." (same-day commit). Runs on always-on machine.
- 2026-07-06 — Explored codebase: scoring algorithm is 0–13 scale with 8 components (committee, cluster, size, small-cap bonus, options bonus, sell pressure, strategic LLM score, disruption LLM score). Notification threshold = 9.0 after timeliness multiplier. Max 1 notification/run. 14k+ historical trades stored. `signal_outcomes` table tracks 30/60d returns. Key open issue: win-rate uses current price snapshot, not fixed-horizon (backlog item). Forward-looking predictor module also exists.
- 2026-07-06 — James approved agentic calibration approach: backtest first → data-derived thresholds → ongoing automated monitoring. Scoped as 3 phases below.

### Calibration task — 3 phases

**Phase 1: Fix win-rate calculation** ✅ DONE 2026-07-12
- Added `compute_fixed_horizon()` to `tools/win_rate.py`: uses `price_history` table (price at tx_date+30d vs tx_date) — stable classification, not today's rolling price
- `compute()` dispatches to fixed-horizon when `price_history` is populated; rolling fallback for fresh installs
- 8 TDD tests added to `tests/test_win_rate.py`, all GREEN
- Committed: `feat/agentic-alert-loop` — "fix(win_rate): use fixed-horizon returns instead of rolling price snapshot"

**Phase 2: Backtest sweep script** ✅ DONE 2026-07-12
- `scripts/backtest_sweep.py` added. Sweeps score threshold (7–13), conviction (LOW/MEDIUM/HIGH), edge-factor min (2/3/4).
- Quick mode: reads signal_outcomes (n=36, no price_history needed). Full mode: replays 7k+ trades from price_history.
- Quick mode ran: signals scored 10.8–14.5. Threshold 12.5+ improves precision 58%→67% and cuts max drawdown from -40.7% to -7.7%.
- **Price_history backfill needed for full sweep** (python scripts/backtest_sweep.py --backfill --csv results.csv) — yfinance not installed in container.
- Committed: `feat/agentic-alert-loop` — "feat(backtest): add threshold/conviction/edge sweep script"

**Phase 3: Backfill + full sweep** ✅ DONE 2026-07-12
- Batched yfinance backfill: 960k price rows across 1,378 tickers in 39s
- Ran full replay sweep: 6,493 trades, 15 months, 117 config combinations
- CSV saved to `data/backtest_sweep_2026-07-12.csv`
- **Key finding**: Current config (HIGH conviction, thresh=9.0, edge≥3) = 44% precision, -0.96% median excess vs SPY
- **Best config**: threshold=11.5, LOW conviction, edge≥2 → 54% precision, +0.67% median excess, 398 signals/year
- **Surprising**: HIGH conviction = WORSE performance. Unresearched signals at high base scores outperform LLM-tagged HIGH conviction ones — suggesting base score is more predictive than LLM overlay.
- Results sent to James via Slack 2026-07-12. Waiting for threshold decision to implement config changes.

**Phase 4: Ongoing automated outcome monitoring** ✅ DONE 2026-07-19
- `/workspace/agent/congressional-monitor.py` — stdlib-only script querying `signal_outcomes`, computing 30d excess precision (last-10 / last-20 / all-time), outputting `{"wakeAgent": bool, "data": {...}}` for Nano task trigger
- Alert threshold: 40% floor (`BREAKER_MIN_WIN_RATE`); sends ntfy push to `congressWatchSignals` topic on breach
- Current precision: 58.3% all-time / 65.0% last-20 / 60.0% last-10 (all well above floor)
- Scheduled weekly: task-1784500441752-hqopzy, Mondays 9am CDT (`0 9 * * 1`)
- 2026-07-25 — Dual-poller decision (James): Mac Studio is authoritative for `com.congresswatch.daily`. Stop Mini launchd job when SSH available. Until then, possible doubled LLM spend + notification race — acceptable.
- 2026-07-21 — Autopilot cycle (high tier): closed stale observation — `outcome_tracker.py` horizon-return drift bug was already fixed in `0288530` (2026-07-06); observation had been logged Open on 2026-07-15 after the fix. Added regression test `test_horizon_fetcher_anchors_at_notified_date_not_today` to prevent regression. 28/28 tests pass. Committed f2846af on feat/agentic-alert-loop.


<system>Your response was not delivered — it was not wrapped in <message to="name">...</message> blocks. All output must be wrapped: use <message to="name"> for content to send, or <internal> for scratchpad. Your destinations: cli-local, cw-coder, ingest-into-wiki, slack-agenticlever-website, slack-infrastructure, slack-mg-17835, slack-mg-17835-2, slack-mg-17835-3, slack-mg-17835-4, slack-mg-17835-5, slack-mg-17835-6, slack-qip-qien, slack-roadsense, slack-stagehand, slack-tradingbot25, telegram-mg-17796, telegram-mg-17800. Please re-send your response with the correct wrapping.</system>


Reset autopilot after quota window reset


Let me continue the SqliteError investigation. The last state was:
- Running lock is written at `2026-07-26T07:00:00Z`
- Investigating `SqliteError: attempt to write a readonly database` in `getDueOutboundMessages`
- Theory: WAL mode requires all readers to write to `-shm` file
- Next step: Check `openOutboundDbRaw` implementation in the DB connection module

Let me read the connection file to understand how the DB is opened.


1	import Database from 'better-sqlite3';
2	import fs from 'fs';
3	import path from 'path';
4
5	import { log } from '../log.js';
6
7	let _db: Database.Database | null = null;
8
9	export function getDb(): Database.Database {
10	  if (!_db) throw new Error('Database not initialized. Call initDb() first.');
11	  return _db;
12	}
13
14	export function initDb(dbPath: string): Database.Database {
15	  fs.mkdirSync(path.dirname(dbPath), { recursive: true });
16	  _db = new Database(dbPath);
17	  _db.pragma('journal_mode = WAL');
18	  _db.pragma('foreign_keys = ON');
19	  log.info('Central DB initialized', { path: dbPath });
20	  return _db;
21	}
22
23	/** For tests only — creates an in-memory DB and runs migrations. */
24	export function initTestDb(): Database.Database {
25	  _db = new Database(':memory:');
26	  _db.pragma('foreign_keys = ON');
27	  return _db;
28	}
29
30	export function closeDb(): void {
31	  _db?.close();
32	  _db = null;
33	}
34
35	/**
36	 * Check whether a table exists. Used by core code that touches
37	 * module-owned tables so that an uninstalled module degrades silently
38	 * instead of raising SQLite errors. Cheap: a single indexed lookup on
39	 * sqlite_master. Results are not cached — a module install adds the
40	 * table at runtime (next service start), and callers may run before
41	 * or after that boundary.
42	 */
43	export function hasTable(db: Database.Database, name: string): boolean {
44	  const row = db.prepare(`SELECT 1 FROM sqlite_master WHERE type='table' AND name = ? LIMIT 1`).get(name) as
45	    | { '1': number }
46	    | undefined;
47	  return row !== undefined;
48	}
49

import Database from 'better-sqlite3';
import fs from 'fs';
import path from 'path';

import { log } from '../log.js';

let _db: Database.Database | null = null;

export function getDb(): Database.Database {
  if (!_db) throw new Error('Database not initialized. Call initDb() first.');
  return _db;
}

export function initDb(dbPath: string): Database.Database {
  fs.mkdirSync(path.dirname(dbPath), { recursive: true });
  _db = new Database(dbPath);
  _db.pragma('journal_mode = WAL');
  _db.pragma('foreign_keys = ON');
  log.info('Central DB initialized', { path: dbPath });
  return _db;
}

/** For tests only — creates an in-memory DB and runs migrations. */
export function initTestDb(): Database.Database {
  _db = new Database(':memory:');
  _db.pragma('foreign_keys = ON');
  return _db;
}

export function closeDb(): void {
  _db?.close();
  _db = null;
}

/**
 * Check whether a table exists. Used by core code that touches
 * module-owned tables so that an uninstalled module degrades silently
 * instead of raising SQLite errors. Cheap: a single indexed lookup on
 * sqlite_master. Results are not cached — a module install adds the
 * table at runtime (next service start), and callers may run before
 * or after that boundary.
 */
export function hasTable(db: Database.Database, name: string): boolean {
  const row = db.prepare(`SELECT 1 FROM sqlite_master WHERE type='table' AND name = ? LIMIT 1`).get(name) as
    | { '1': number }
    | undefined;
  return row !== undefined;
}


Prompt: <system>Your response was not delivered — it was not wrapped in <message to="name">...</message> blocks. All output must be wrapped: use <message to="name"> for content to send, or <internal> for scratchpad. Your destinations: cli-local, cw-coder, ingest-into-wiki, slack-agenticlever-website, slack-infrastructure, slack-mg-17835, slack-mg-17835-2, slack-mg-17835-3, slack-mg-17835-4, slack-mg-17835-5, slack-mg-17835-6, slack-qip-qien, slack-roadsense, slack-stagehand, slack-tradingbot25, telegram-mg-17796, telegram-mg-17800. Please re-send your response with the correct wrapping.</system>


The `connection.ts` file shows that `initDb()` sets `journal_mode = WAL`. But this is for the main inbound DB. I need to find where `openOutboundDbRaw` and `openInboundDb` are defined, which might be in `session-manager.ts` or another file.

Let me look at the session-manager.ts for the outbound DB functions.


Found 13 files
/workspace/extra/projects/nanoclaw/src/host-sweep.ts
/workspace/extra/projects/nanoclaw/src/host-core.test.ts
/workspace/extra/projects/nanoclaw/src/channels/channel-registry.test.ts
/workspace/extra/projects/nanoclaw/src/cli/resources/destinations.test.ts
/workspace/extra/projects/nanoclaw/src/db/session-db.ts
/workspace/extra/projects/nanoclaw/src/delivery.test.ts
/workspace/extra/projects/nanoclaw/src/delivery.ts
/workspace/extra/projects/nanoclaw/src/modules/agent-to-agent/agent-route.test.ts
/workspace/extra/projects/nanoclaw/src/modules/agent-to-agent/agent-route.ts
/workspace/extra/projects/nanoclaw/src/modules/agent-to-agent/write-destinations.ts
/workspace/extra/projects/nanoclaw/src/modules/scheduling/db.test.ts
/workspace/extra/projects/nanoclaw/src/modules/scheduling/recurrence.test.ts
/workspace/extra/projects/nanoclaw/src/session-manager.ts


21:export function openInboundDb(dbPath: string): Database.Database {
29:export function openOutboundDb(dbPath: string): Database.Database {
36:export function openOutboundDbRw(dbPath: string): Database.Database {


1	/**
2	 * SQL operations on per-session inbound/outbound DBs.
3	 *
4	 * These are NOT the central app DB — they're the cross-mount SQLite files
5	 * shared between host and container. Callers own the connection lifecycle
6	 * (open-write-close per op). See session-manager.ts header for invariants.
7	 */
8	import Database from 'better-sqlite3';
9
10	import { INBOUND_SCHEMA, OUTBOUND_SCHEMA } from './schema.js';
11
12	/** Apply the inbound or outbound schema to a DB file. Idempotent. */
13	export function ensureSchema(dbPath: string, schema: 'inbound' | 'outbound'): void {
14	  const db = new Database(dbPath);
15	  db.pragma('journal_mode = DELETE');
16	  db.exec(schema === 'inbound' ? INBOUND_SCHEMA : OUTBOUND_SCHEMA);
17	  db.close();
18	}
19
20	/** Open the inbound DB for a session (host reads/writes). */
21	export function openInboundDb(dbPath: string): Database.Database {
22	  const db = new Database(dbPath);
23	  db.pragma('journal_mode = DELETE');
24	  db.pragma('busy_timeout = 5000');
25	  return db;
26	}
27
28	/** Open the outbound DB for a session (host reads only). */
29	export function openOutboundDb(dbPath: string): Database.Database {
30	  const db = new Database(dbPath, { readonly: true });
31	  db.pragma('busy_timeout = 5000');
32	  return db;
33	}
34
35	/** Open the outbound DB for a session with write access. Only safe to call when no container is running. */
36	export function openOutboundDbRw(dbPath: string): Database.Database {
37	  const db = new Database(dbPath);
38	  db.pragma('journal_mode = DELETE');
39	  db.pragma('busy_timeout = 5000');
40	  return db;
41	}
42
43	export function upsertSessionRouting(
44	  db: Database.Database,
45	  routing: { channel_type: string | null; platform_id: string | null; thread_id: string | null },
46	): void {
47	  db.prepare(
48	    `INSERT INTO session_routing (id, channel_type, platform_id, thread_id)
49	     VALUES (1, @channel_type, @platform_id, @thread_id)
50	     ON CONFLICT(id) DO UPDATE SET
51	       channel_type = excluded.channel_type,
52	       platform_id  = excluded.platform_id,
53	       thread_id    = excluded.thread_id`,
54	  ).run(routing);
55	}
56
57	export interface DestinationRow {
58	  name: string;
59	  display_name: string | null;
60	  type: 'channel' | 'agent';
61	  channel_type: string | null;
62	  platform_id: string | null;
63	  agent_group_id: string | null;
64	}
65
66	export function replaceDestinations(db: Database.Database, entries: DestinationRow[]): void {
67	  const tx = db.transaction((rows: DestinationRow[]) => {
68	    db.prepare('DELETE FROM destinations').run();
69	    const stmt = db.prepare(
70	      `INSERT INTO destinations (name, display_name, type, channel_type, platform_id, agent_group_id)
71	       VALUES (@name, @display_name, @type, @channel_type, @platform_id, @agent_group_id)`,
72	    );
73	    for (const row of rows) stmt.run(row);
74	  });
75	  tx(entries);
76	}
77
78	// ---------------------------------------------------------------------------
79	// messages_in
80	// ---------------------------------------------------------------------------

/**
 * SQL operations on per-session inbound/outbound DBs.
 *
 * These are NOT the central app DB — they're the cross-mount SQLite files
 * shared between host and container. Callers own the connection lifecycle
 * (open-write-close per op). See session-manager.ts header for invariants.
 */
import Database from 'better-sqlite3';

import { INBOUND_SCHEMA, OUTBOUND_SCHEMA } from './schema.js';

/** Apply the inbound or outbound schema to a DB file. Idempotent. */
export function ensureSchema(dbPath: string, schema: 'inbound' | 'outbound'): void {
  const db = new Database(dbPath);
  db.pragma('journal_mode = DELETE');
  db.exec(schema === 'inbound' ? INBOUND_SCHEMA : OUTBOUND_SCHEMA);
  db.close();
}

/** Open the inbound DB for a session (host reads/writes). */
export function openInboundDb(dbPath: string): Database.Database {
  const db = new Database(dbPath);
  db.pragma('journal_mode = DELETE');
  db.pragma('busy_timeout = 5000');
  return db;
}

/** Open the outbound DB for a session (host reads only). */
export function openOutboundDb(dbPath: string): Database.Database {
  const db = new Database(dbPath, { readonly: true });
  db.pragma('busy_timeout = 5000');
  return db;
}

/** Open the outbound DB for a session with write access. Only safe to call when no container is running. */
export function openOutboundDbRw(dbPath: string): Database.Database {
  const db = new Database(dbPath);
  db.pragma('journal_mode = DELETE');
  db.pragma('busy_timeout = 5000');
  return db;
}

export function upsertSessionRouting(
  db: Database.Database,
  routing: { channel_type: string | null; platform_id: string | null; thread_id: string | null },
): void {
  db.prepare(
    `INSERT INTO session_routing (id, channel_type, platform_id, thread_id)
     VALUES (1, @channel_type, @platform_id, @thread_id)
     ON CONFLICT(id) DO UPDATE SET
       channel_type = excluded.channel_type,
       platform_id  = excluded.platform_id,
       thread_id    = excluded.thread_id`,
  ).run(routing);
}

export interface DestinationRow {
  name: string;
  display_name: string | null;
  type: 'channel' | 'agent';
  channel_type: string | null;
  platform_id: string | null;
  agent_group_id: string | null;
}

export function replaceDestinations(db: Database.Database, entries: DestinationRow[]): void {
  const tx = db.transaction((rows: DestinationRow[]) => {
    db.prepare('DELETE FROM destinations').run();
    const stmt = db.prepare(
      `INSERT INTO destinations (name, display_name, type, channel_type, platform_id, agent_group_id)
       VALUES (@name, @display_name, @type, @channel_type, @platform_id, @agent_group_id)`,
    );
    for (const row of rows) stmt.run(row);
  });
  tx(entries);
}

// ---------------------------------------------------------------------------
// messages_in
// ---------------------------------------------------------------------------


Now I can see the issue clearly:

1. `openOutboundDb` opens with `{ readonly: true }` — so better-sqlite3 will not attempt to write to the DB file itself.

2. But `ensureSchema` for outbound uses `journal_mode = DELETE` — so the outbound DB should be in DELETE mode, not WAL mode.

3. The container's agent writes to `outbound.db`. The container may be setting the journal mode to WAL when it writes. Let me check what the container side does.
The real problem is that when better-sqlite3 opens a database in readonly mode with WAL journal mode, it still needs to write the WAL-index file (`-shm`) for readers, which fails on a readonly database. If the outbound DB was created with DELETE journal mode instead, there's no WAL overhead. So I need to check whether the container agent is opening the outbound DB with WAL mode.


80	// ---------------------------------------------------------------------------
81
82	/**
83	 * Next even seq number for host-owned inbound.db.
84	 *
85	 * Exported so the scheduling module's task helpers can maintain the
86	 * host-writes-even-seq invariant without duplicating the logic. Not part of
87	 * the general public API — imported by `src/modules/scheduling/db.ts` only.
88	 */
89	export function nextEvenSeq(db: Database.Database): number {
90	  const maxSeq = (db.prepare('SELECT COALESCE(MAX(seq), 0) AS m FROM messages_in').get() as { m: number }).m;
91	  return maxSeq < 2 ? 2 : maxSeq + 2 - (maxSeq % 2);
92	}
93
94	export function insertMessage(
95	  db: Database.Database,
96	  message: {
97	    id: string;
98	    kind: string;
99	    timestamp: string;
100	    platformId: string | null;
101	    channelType: string | null;
102	    threadId: string | null;
103	    content: string;
104	    processAfter: string | null;
105	    recurrence: string | null;
106	    /**
107	     * 1 = wake the agent (default); 0 = accumulate as context only.
108	     * Host countDueMessages gates on this; container reads everything.
109	     */
110	    trigger?: 0 | 1;
111	    /**
112	     * For agent-to-agent inbound: the source session id that emitted the
113	     * outbound message which became this inbound row. Used as the return
114	     * path for the target's reply. NULL on channel-side inbound.
115	     */
116	    sourceSessionId?: string | null;
117	    /**
118	     * 1 = only deliver on the container's first poll (fresh start).
119	     * Dying containers (past first poll) skip these rows.
120	     */
121	    onWake?: 0 | 1;
122	  },
123	): void {
124	  db.prepare(
125	    `INSERT INTO messages_in (id, seq, kind, timestamp, status, platform_id, channel_type, thread_id, content, process_after, recurrence, series_id, trigger, source_session_id, on_wake)
126	     VALUES (@id, @seq, @kind, @timestamp, 'pending', @platformId, @channelType, @threadId, @content, @processAfter, @recurrence, @id, @trigger, @sourceSessionId, @onWake)`,
127	  ).run({
128	    ...message,
129	    trigger: message.trigger ?? 1,
130	    onWake: message.onWake ?? 0,
131	    sourceSessionId: message.sourceSessionId ?? null,
132	    seq: nextEvenSeq(db),
133	  });
134	}
135
136	export function countDueMessages(db: Database.Database): number {
137	  return (
138	    db
139	      .prepare(
140	        `SELECT COUNT(*) as count FROM messages_in
141	       WHERE status = 'pending'
142	         AND trigger = 1
143	         AND (process_after IS NULL OR datetime(process_after) <= datetime('now'))`,
144	      )
145	      .get() as { count: number }
146	  ).count;
147	}
148
149	export function markMessageFailed(db: Database.Database, messageId: string): void {
150	  db.prepare("UPDATE messages_in SET status = 'failed' WHERE id = ?").run(messageId);
151	}
152
153	export function retryWithBackoff(db: Database.Database, messageId: string, backoffSec: number): void {
154	  db.prepare(
155	    `UPDATE messages_in SET tries = tries + 1, process_after = datetime('now', '+${backoffSec} seconds') WHERE id = ?`,
156	  ).run(messageId);
157	}
158
159	export function getMessageForRetry(
160	  db: Database.Database,
161	  messageId: string,
162	  status: string,
163	): { id: string; tries: number; processAfter: string | null } | undefined {
164	  return db
165	    .prepare('SELECT id, tries, process_after as processAfter FROM messages_in WHERE id = ? AND status = ?')
166	    .get(messageId, status) as { id: string; tries: number; processAfter: string | null } | undefined;
167	}
168
169	export function syncProcessingAcks(inDb: Database.Database, outDb: Database.Database): void {
170	  const completed = outDb
171	    .prepare("SELECT message_id FROM processing_ack WHERE status IN ('completed', 'failed')")
172	    .all() as Array<{ message_id: string }>;
173
174	  if (completed.length === 0) return;
175
176	  const updateStmt = inDb.prepare("UPDATE messages_in SET status = 'completed' WHERE id = ? AND status != 'completed'");
177	  inDb.transaction(() => {
178	    for (const { message_id } of completed) {
179	      updateStmt.run(message_id);
180	    }
181	  })();
182	}
183
184	export function getStuckProcessingIds(outDb: Database.Database): string[] {
185	  return (
186	    outDb.prepare("SELECT message_id FROM processing_ack WHERE status = 'processing'").all() as Array<{
187	      message_id: string;
188	    }>
189	  ).map((r) => r.message_id);
190	}
191
192	export interface ProcessingClaim {
193	  message_id: string;
194	  status_changed: string;
195	}
196
197	/** Return processing_ack rows still in 'processing' with their claim timestamps. */
198	export function getProcessingClaims(outDb: Database.Database): ProcessingClaim[] {
199	  return outDb
200	    .prepare("SELECT message_id, status_changed FROM processing_ack WHERE status = 'processing'")
201	    .all() as ProcessingClaim[];
202	}
203
204	/**
205	 * Delete orphan 'processing' rows. Called by the host after killing a
206	 * container so the leftover claim doesn't trip claim-stuck on the next sweep
207	 * tick (which would kill the freshly respawned container before its
208	 * agent-runner can run its own startup cleanup).
209	 *
210	 * Safe because the host only writes to outbound.db when no container is
211	 * running (we just killed it). Returns the number of rows deleted.
212	 */
213	export function deleteOrphanProcessingClaims(outDb: Database.Database): number {
214	  return outDb.prepare("DELETE FROM processing_ack WHERE status = 'processing'").run().changes;
215	}
216
217	export interface ContainerState {
218	  current_tool: string | null;
219	  tool_declared_timeout_ms: number | null;
220	  tool_started_at: string | null;
221	}
222
223	/**
224	 * Read the container's current tool-in-flight state, if any. Returns null
225	 * when either the table doesn't exist yet (older session DB) or no tool is
226	 * active. Host sweep reads this to widen stuck-detection tolerance while
227	 * Bash is running with a long declared timeout.
228	 */
229	export function getContainerState(outDb: Database.Database): ContainerState | null {
230	  try {
231	    const row = outDb
232	      .prepare(
233	        `SELECT current_tool, tool_declared_timeout_ms, tool_started_at
234	           FROM container_state WHERE id = 1`,
235	      )
236	      .get() as ContainerState | undefined;
237	    return row ?? null;
238	  } catch {
239	    // Table not present on older session DBs — treat as "no tool in flight".
240	    return null;
241	  }
242	}
243
244	// ---------------------------------------------------------------------------
245	// messages_out (read-only from host)
246	// ---------------------------------------------------------------------------
247
248	export interface OutboundMessage {
249	  id: string;
250	  kind: string;
251	  platform_id: string | null;
252	  channel_type: string | null;
253	  thread_id: string | null;
254	  content: string;
255	  in_reply_to: string | null;
256	}
257
258	export function getDueOutboundMessages(db: Database.Database): OutboundMessage[] {
259	  return db

// ---------------------------------------------------------------------------

/**
 * Next even seq number for host-owned inbound.db.
 *
 * Exported so the scheduling module's task helpers can maintain the
 * host-writes-even-seq invariant without duplicating the logic. Not part of
 * the general public API — imported by `src/modules/scheduling/db.ts` only.
 */
export function nextEvenSeq(db: Database.Database): number {
  const maxSeq = (db.prepare('SELECT COALESCE(MAX(seq), 0) AS m FROM messages_in').get() as { m: number }).m;
  return maxSeq < 2 ? 2 : maxSeq + 2 - (maxSeq % 2);
}

export function insertMessage(
  db: Database.Database,
  message: {
    id: string;
    kind: string;
    timestamp: string;
    platformId: string | null;
    channelType: string | null;
    threadId: string | null;
    content: string;
    processAfter: string | null;
    recurrence: string | null;
    /**
     * 1 = wake the agent (default); 0 = accumulate as context only.
     * Host countDueMessages gates on this; container reads everything.
     */
    trigger?: 0 | 1;
    /**
     * For agent-to-agent inbound: the source session id that emitted the
     * outbound message which became this inbound row. Used as the return
     * path for the target's reply. NULL on channel-side inbound.
     */
    sourceSessionId?: string | null;
    /**
     * 1 = only deliver on the container's first poll (fresh start).
     * Dying containers (past first poll) skip these rows.
     */
    onWake?: 0 | 1;
  },
): void {
  db.prepare(
    `INSERT INTO messages_in (id, seq, kind, timestamp, status, platform_id, channel_type, thread_id, content, process_after, recurrence, series_id, trigger, source_session_id, on_wake)
     VALUES (@id, @seq, @kind, @timestamp, 'pending', @platformId, @channelType, @threadId, @content, @processAfter, @recurrence, @id, @trigger, @sourceSessionId, @onWake)`,
  ).run({
    ...message,
    trigger: message.trigger ?? 1,
    onWake: message.onWake ?? 0,
    sourceSessionId: message.sourceSessionId ?? null,
    seq: nextEvenSeq(db),
  });
}

export function countDueMessages(db: Database.Database): number {
  return (
    db
      .prepare(
        `SELECT COUNT(*) as count FROM messages_in
       WHERE status = 'pending'
         AND trigger = 1
         AND (process_after IS NULL OR datetime(process_after) <= datetime('now'))`,
      )
      .get() as { count: number }
  ).count;
}

export function markMessageFailed(db: Database.Database, messageId: string): void {
  db.prepare("UPDATE messages_in SET status = 'failed' WHERE id = ?").run(messageId);
}

export function retryWithBackoff(db: Database.Database, messageId: string, backoffSec: number): void {
  db.prepare(
    `UPDATE messages_in SET tries = tries + 1, process_after = datetime('now', '+${backoffSec} seconds') WHERE id = ?`,
  ).run(messageId);
}

export function getMessageForRetry(
  db: Database.Database,
  messageId: string,
  status: string,
): { id: string; tries: number; processAfter: string | null } | undefined {
  return db
    .prepare('SELECT id, tries, process_after as processAfter FROM messages_in WHERE id = ? AND status = ?')
    .get(messageId, status) as { id: string; tries: number; processAfter: string | null } | undefined;
}

export function syncProcessingAcks(inDb: Database.Database, outDb: Database.Database): void {
  const completed = outDb
    .prepare("SELECT message_id FROM processing_ack WHERE status IN ('completed', 'failed')")
    .all() as Array<{ message_id: string }>;

  if (completed.length === 0) return;

  const updateStmt = inDb.prepare("UPDATE messages_in SET status = 'completed' WHERE id = ? AND status != 'completed'");
  inDb.transaction(() => {
    for (const { message_id } of completed) {
      updateStmt.run(message_id);
    }
  })();
}

export function getStuckProcessingIds(outDb: Database.Database): string[] {
  return (
    outDb.prepare("SELECT message_id FROM processing_ack WHERE status = 'processing'").all() as Array<{
      message_id: string;
    }>
  ).map((r) => r.message_id);
}

export interface ProcessingClaim {
  message_id: string;
  status_changed: string;
}

/** Return processing_ack rows still in 'processing' with their claim timestamps. */
export function getProcessingClaims(outDb: Database.Database): ProcessingClaim[] {
  return outDb
    .prepare("SELECT message_id, status_changed FROM processing_ack WHERE status = 'processing'")
    .all() as ProcessingClaim[];
}

/**
 * Delete orphan 'processing' rows. Called by the host after killing a
 * container so the leftover claim doesn't trip claim-stuck on the next sweep
 * tick (which would kill the freshly respawned container before its
 * agent-runner can run its own startup cleanup).
 *
 * Safe because the host only writes to outbound.db when no container is
 * running (we just killed it). Returns the number of rows deleted.
 */
export function deleteOrphanProcessingClaims(outDb: Database.Database): number {
  return outDb.prepare("DELETE FROM processing_ack WHERE status = 'processing'").run().changes;
}

export interface ContainerState {
  current_tool: string | null;
  tool_declared_timeout_ms: number | null;
  tool_started_at: string | null;
}

/**
 * Read the container's current tool-in-flight state, if any. Returns null
 * when either the table doesn't exist yet (older session DB) or no tool is
 * active. Host sweep reads this to widen stuck-detection tolerance while
 * Bash is running with a long declared timeout.
 */
export function getContainerState(outDb: Database.Database): ContainerState | null {
  try {
    const row = outDb
      .prepare(
        `SELECT current_tool, tool_declared_timeout_ms, tool_started_at
           FROM container_state WHERE id = 1`,
      )
      .get() as ContainerState | undefined;
    return row ?? null;
  } catch {
    // Table not present on older session DBs — treat as "no tool in flight".
    return null;
  }
}

// ---------------------------------------------------------------------------
// messages_out (read-only from host)
// ---------------------------------------------------------------------------

export interface OutboundMessage {
  id: string;
  kind: string;
  platform_id: string | null;
  channel_type: string | null;
  thread_id: string | null;
  content: string;
  in_reply_to: string | null;
}

export function getDueOutboundMessages(db: Database.Database): OutboundMessage[] {
  return db


Now I see something important. Looking at line 213-215:
```typescript
export function deleteOrphanProcessingClaims(outDb: Database.Database): number {
  return outDb.prepare("DELETE FROM processing_ack WHERE status = 'processing'").run().changes;
}
```

This is a WRITE operation on the outbound DB (it deletes rows from `processing_ack`), and it uses the `outDb` passed from the caller. The comment says "Safe because the host only writes to outbound.db when no container is running (we just killed it)."
But wait—if this function gets called with a readonly connection instead of the read-write one, it would fail with exactly the error we're seeing. And `getDueOutboundMessages` is purely a SELECT query, so the write error must be happening somewhere else in the call chain, or there's confusion about which connection is being passed where.

The real issue might be deeper: SQLite in WAL mode tries to write to the `-shm` shared memory file even during read operations, which would trigger this error on a readonly connection. But the schema setup explicitly sets `journal_mode = DELETE`, so unless the container side is changing the journal mode when it accesses the outbound database, that shouldn't be the culprit.

I need to check how the container actually initializes and opens the outbound database—there's likely an agent-side module handling that, and it might be using different settings than what we expect.


Found 26 files
/workspace/extra/projects/nanoclaw/src/host-sweep.ts
/workspace/extra/projects/nanoclaw/src/container-runner.ts
/workspace/extra/projects/nanoclaw/src/router.ts
/workspace/extra/projects/nanoclaw/src/host-core.test.ts
/workspace/extra/projects/nanoclaw/src/channels/telegram-markdown-sanitize.ts
/workspace/extra/projects/nanoclaw/src/channels/adapter.ts
/workspace/extra/projects/nanoclaw/src/channels/channel-registry.test.ts
/workspace/extra/projects/nanoclaw/src/channels/chat-sdk-bridge.test.ts
/workspace/extra/projects/nanoclaw/src/channels/chat-sdk-bridge.ts
/workspace/extra/projects/nanoclaw/src/channels/cli.ts
/workspace/extra/projects/nanoclaw/src/cli/delivery-action.ts
/workspace/extra/projects/nanoclaw/src/cli/resources/sessions.ts
/workspace/extra/projects/nanoclaw/src/cli/transport.ts
/workspace/extra/projects/nanoclaw/src/command-gate.ts
/workspace/extra/projects/nanoclaw/src/db/messaging-groups.ts
/workspace/extra/projects/nanoclaw/src/db/schema.ts
/workspace/extra/projects/nanoclaw/src/db/session-db.ts
/workspace/extra/projects/nanoclaw/src/delivery.test.ts
/workspace/extra/projects/nanoclaw/src/delivery.ts
/workspace/extra/projects/nanoclaw/src/modules/agent-to-agent/agent-route.test.ts
/workspace/extra/projects/nanoclaw/src/modules/agent-to-agent/agent-route.ts
/workspace/extra/projects/nanoclaw/src/modules/interactive/index.ts
/workspace/extra/projects/nanoclaw/src/modules/scheduling/actions.ts
/workspace/extra/projects/nanoclaw/src/modules/self-mod/index.ts
/workspace/extra/projects/nanoclaw/src/modules/self-mod/request.ts
/workspace/extra/projects/nanoclaw/src/session-manager.ts


No matches found


267:  // Session folder at /workspace (contains inbound.db, outbound.db, outbox/, .claude/)


/workspace/extra/projects/nanoclaw/src/host-sweep.ts:5: *   - Reads processing_ack + container_state from outbound.db
/workspace/extra/projects/nanoclaw/src/host-sweep.ts:8: *   - Never writes to outbound.db — preserves single-writer-per-file invariant
/workspace/extra/projects/nanoclaw/src/host-sweep.ts:181:    // outbound.db might not exist yet (container hasn't started)
/workspace/extra/projects/nanoclaw/src/delivery.test.ts:65:  const db = new Database(outboundDbPath(agentGroupId, sessionId));
/workspace/extra/projects/nanoclaw/src/delivery.test.ts:242:    const outDb = new Database(outboundDbPath('ag-1', session.id));
/workspace/extra/projects/nanoclaw/src/session-manager.ts:4: * Two-DB split — inbound.db (host writes) + outbound.db (container writes).
/workspace/extra/projects/nanoclaw/src/session-manager.ts:6: *   1. journal_mode=DELETE — WAL's mmapped -shm doesn't refresh host→guest;
/workspace/extra/projects/nanoclaw/src/session-manager.ts:63:  return path.join(sessionDir(agentGroupId, sessionId), 'outbound.db');
/workspace/extra/projects/nanoclaw/src/container-runner.ts:267:  // Session folder at /workspace (contains inbound.db, outbound.db, outbox/, .claude/)
/workspace/extra/projects/nanoclaw/src/cli/delivery-action.ts:4: * When an agent writes a `cli_request` system message to outbound.db,
/workspace/extra/projects/nanoclaw/src/host-core.test.ts:103:    // Verify outbound.db
/workspace/extra/projects/nanoclaw/src/modules/self-mod/project.md:12:  container's self-mod MCP tools write into outbound.db. On the host, each
/workspace/extra/projects/nanoclaw/src/delivery.ts:6: *   - Reads messages_out from outbound.db (container-owned, opened read-only)
/workspace/extra/projects/nanoclaw/src/delivery.ts:8: *   - Never writes to outbound.db — preserves single-writer-per-file invariant
/workspace/extra/projects/nanoclaw/src/delivery.ts:178:    // Read all due messages from outbound.db (read-only)
/workspace/extra/projects/nanoclaw/src/channels/cli.ts:33: * is already in outbound.db, so the message isn't lost — it just doesn't
/workspace/extra/projects/nanoclaw/src/cli/transport.ts:4: * Unix socket (host) or through outbound.db / inbound.db rows (container).
/workspace/extra/projects/nanoclaw/src/db/schema.ts:153: *   outbound.db — container writes, host reads (read-only open)
/workspace/extra/projects/nanoclaw/src/db/schema.ts:187:-- Avoids writing to outbound.db (container-owned).
/workspace/extra/projects/nanoclaw/src/db/schema.ts:222:export const OUTBOUND_SCHEMA = `
/workspace/extra/projects/nanoclaw/src/db/session-db.ts:10:import { INBOUND_SCHEMA, OUTBOUND_SCHEMA } from './schema.js';
/workspace/extra/projects/nanoclaw/src/db/session-db.ts:15:  db.pragma('journal_mode = DELETE');
/workspace/extra/projects/nanoclaw/src/db/session-db.ts:16:  db.exec(schema === 'inbound' ? INBOUND_SCHEMA : OUTBOUND_SCHEMA);
/workspace/extra/projects/nanoclaw/src/db/session-db.ts:23:  db.pragma('journal_mode = DELETE');
/workspace/extra/projects/nanoclaw/src/db/session-db.ts:38:  db.pragma('journal_mode = DELETE');
/workspace/extra/projects/nanoclaw/src/db/session-db.ts:210: * Safe because the host only writes to outbound.db when no container is
/workspace/extra/projects/nanoclaw/src/db/connection.ts:17:  _db.pragma('journal_mode = WAL');
/workspace/extra/projects/nanoclaw/src/cli/resources/sessions.ts:8:    'Session — the runtime unit. Maps one (agent_group, messaging_group, thread) combination to a container with its own inbound.db and outbound.db. Created automatically by the router when a message arrives.',
/workspace/extra/projects/nanoclaw/src/modules/interactive/index.ts:4: * Container-side `ask_user_question` writes a chat-sdk card to outbound.db +
/workspace/extra/projects/nanoclaw/src/modules/interactive/project.md:5:The container-side MCP tool `ask_user_question` writes a chat-sdk card to outbound.db and polls inbound.db for a `question_response` system message. The host side of this is split:


This is very interesting. Looking at `session-manager.ts` line 6, there's a comment that explicitly mentions:
```
1. journal_mode=DELETE — WAL's mmapped -shm doesn't refresh host→guest;
```

This suggests the journal_mode=DELETE is intentional to avoid WAL issues with host-to-guest cross-mount communication. But what does the CONTAINER do when it opens the outbound DB?

The container writes to outbound.db. The container is the claude agent inside Docker. But the code for that container is separate from the host code. Let me look for the container-side code that opens outbound.db.
Now I need to check the schema definition to understand what OUTBOUND_SCHEMA contains and what that comment at line 153 is referring to.


140	  sender_name        TEXT,
141	  original_message   TEXT NOT NULL,    -- JSON of the original InboundEvent
142	  approver_user_id   TEXT NOT NULL,
143	  created_at         TEXT NOT NULL,
144	  UNIQUE(messaging_group_id, sender_identity)
145	);
146	`;
147
148	/**
149	 * Session DB schemas — split into two files so each has exactly one writer.
150	 * This eliminates SQLite write contention across the host-container mount boundary.
151	 *
152	 *   inbound.db  — host writes, container reads (read-only mount or open read-only)
153	 *   outbound.db — container writes, host reads (read-only open)
154	 */
155
156	/** Host-owned: inbound messages + delivery tracking + destination map. */
157	export const INBOUND_SCHEMA = `
158	CREATE TABLE IF NOT EXISTS messages_in (
159	  id             TEXT PRIMARY KEY,
160	  seq            INTEGER UNIQUE,
161	  kind           TEXT NOT NULL,
162	  timestamp      TEXT NOT NULL,
163	  status         TEXT DEFAULT 'pending',
164	  process_after  TEXT,
165	  recurrence     TEXT,
166	  series_id      TEXT,
167	  tries          INTEGER DEFAULT 0,
168	  trigger        INTEGER NOT NULL DEFAULT 1,
169	                 -- 0 = accumulated context (don't wake), 1 = wake agent
170	  platform_id    TEXT,
171	  channel_type   TEXT,
172	  thread_id      TEXT,
173	  content        TEXT NOT NULL,
174	  -- For agent-to-agent inbound rows: the source session that emitted the
175	  -- triggering outbound. Used as a return path when the target replies —
176	  -- the reply routes back to this exact session, not to the source agent
177	  -- group's "newest" session. NULL on channel-side inbound and on a2a rows
178	  -- written before this column existed.
179	  source_session_id TEXT,
180	  on_wake        INTEGER NOT NULL DEFAULT 0
181	               -- 1 = only deliver on the container's first poll (fresh start).
182	               -- Dying containers (past first poll) skip these rows.
183	);
184	CREATE INDEX IF NOT EXISTS idx_messages_in_series ON messages_in(series_id);
185
186	-- Host tracks delivery outcomes for messages_out IDs.
187	-- Avoids writing to outbound.db (container-owned).
188	CREATE TABLE IF NOT EXISTS delivered (
189	  message_out_id      TEXT PRIMARY KEY,
190	  platform_message_id TEXT,
191	  status              TEXT NOT NULL DEFAULT 'delivered',
192	  delivered_at        TEXT NOT NULL
193	);
194
195	-- Destination map for this session's agent.
196	-- Host overwrites on every container wake AND on demand (rewires, new child
197	-- agents, etc.). Container queries this live on every lookup, so changes
198	-- take effect mid-session without requiring a container restart.
199	CREATE TABLE IF NOT EXISTS destinations (
200	  name            TEXT PRIMARY KEY,
201	  display_name    TEXT,
202	  type            TEXT NOT NULL,   -- 'channel' | 'agent'
203	  channel_type    TEXT,            -- for type='channel'
204	  platform_id     TEXT,            -- for type='channel'
205	  agent_group_id  TEXT             -- for type='agent'
206	);
207
208	-- Default reply routing for this session. Single-row table (id=1).
209	-- Host overwrites on every container wake from the session's messaging_group
210	-- and thread_id. Container reads it in send_message / ask_user_question to
211	-- default the channel/thread of outbound messages when the agent doesn't
212	-- specify an explicit destination.
213	CREATE TABLE IF NOT EXISTS session_routing (
214	  id           INTEGER PRIMARY KEY CHECK (id = 1),
215	  channel_type TEXT,
216	  platform_id  TEXT,
217	  thread_id    TEXT
218	);
219	`;
220
221	/** Container-owned: outbound messages + processing acknowledgments. */
222	export const OUTBOUND_SCHEMA = `
223	CREATE TABLE IF NOT EXISTS messages_out (
224	  id             TEXT PRIMARY KEY,
225	  seq            INTEGER UNIQUE,
226	  in_reply_to    TEXT,
227	  timestamp      TEXT NOT NULL,
228	  deliver_after  TEXT,
229	  recurrence     TEXT,
230	  kind           TEXT NOT NULL,
231	  platform_id    TEXT,
232	  channel_type   TEXT,
233	  thread_id      TEXT,
234	  content        TEXT NOT NULL
235	);
236
237	-- Container tracks processing status here instead of updating messages_in.
238	-- Host reads this to know which messages have been processed.
239	-- On container startup, stale 'processing' entries are cleared (crash recovery).

  sender_name        TEXT,
  original_message   TEXT NOT NULL,    -- JSON of the original InboundEvent
  approver_user_id   TEXT NOT NULL,
  created_at         TEXT NOT NULL,
  UNIQUE(messaging_group_id, sender_identity)
);
`;

/**
 * Session DB schemas — split into two files so each has exactly one writer.
 * This eliminates SQLite write contention across the host-container mount boundary.
 *
 *   inbound.db  — host writes, container reads (read-only mount or open read-only)
 *   outbound.db — container writes, host reads (read-only open)
 */

/** Host-owned: inbound messages + delivery tracking + destination map. */
export const INBOUND_SCHEMA = `
CREATE TABLE IF NOT EXISTS messages_in (
  id             TEXT PRIMARY KEY,
  seq            INTEGER UNIQUE,
  kind           TEXT NOT NULL,
  timestamp      TEXT NOT NULL,
  status         TEXT DEFAULT 'pending',
  process_after  TEXT,
  recurrence     TEXT,
  series_id      TEXT,
  tries          INTEGER DEFAULT 0,
  trigger        INTEGER NOT NULL DEFAULT 1,
                 -- 0 = accumulated context (don't wake), 1 = wake agent
  platform_id    TEXT,
  channel_type   TEXT,
  thread_id      TEXT,
  content        TEXT NOT NULL,
  -- For agent-to-agent inbound rows: the source session that emitted the
  -- triggering outbound. Used as a return path when the target replies —
  -- the reply routes back to this exact session, not to the source agent
  -- group's "newest" session. NULL on channel-side inbound and on a2a rows
  -- written before this column existed.
  source_session_id TEXT,
  on_wake        INTEGER NOT NULL DEFAULT 0
               -- 1 = only deliver on the container's first poll (fresh start).
               -- Dying containers (past first poll) skip these rows.
);
CREATE INDEX IF NOT EXISTS idx_messages_in_series ON messages_in(series_id);

-- Host tracks delivery outcomes for messages_out IDs.
-- Avoids writing to outbound.db (container-owned).
CREATE TABLE IF NOT EXISTS delivered (
  message_out_id      TEXT PRIMARY KEY,
  platform_message_id TEXT,
  status              TEXT NOT NULL DEFAULT 'delivered',
  delivered_at        TEXT NOT NULL
);

-- Destination map for this session's agent.
-- Host overwrites on every container wake AND on demand (rewires, new child
-- agents, etc.). Container queries this live on every lookup, so changes
-- take effect mid-session without requiring a container restart.
CREATE TABLE IF NOT EXISTS destinations (
  name            TEXT PRIMARY KEY,
  display_name    TEXT,
  type            TEXT NOT NULL,   -- 'channel' | 'agent'
  channel_type    TEXT,            -- for type='channel'
  platform_id     TEXT,            -- for type='channel'
  agent_group_id  TEXT             -- for type='agent'
);

-- Default reply routing for this session. Single-row table (id=1).
-- Host overwrites on every container wake from the session's messaging_group
-- and thread_id. Container reads it in send_message / ask_user_question to
-- default the channel/thread of outbound messages when the agent doesn't
-- specify an explicit destination.
CREATE TABLE IF NOT EXISTS session_routing (
  id           INTEGER PRIMARY KEY CHECK (id = 1),
  channel_type TEXT,
  platform_id  TEXT,
  thread_id    TEXT
);
`;

/** Container-owned: outbound messages + processing acknowledgments. */
export const OUTBOUND_SCHEMA = `
CREATE TABLE IF NOT EXISTS messages_out (
  id             TEXT PRIMARY KEY,
  seq            INTEGER UNIQUE,
  in_reply_to    TEXT,
  timestamp      TEXT NOT NULL,
  deliver_after  TEXT,
  recurrence     TEXT,
  kind           TEXT NOT NULL,
  platform_id    TEXT,
  channel_type   TEXT,
  thread_id      TEXT,
  content        TEXT NOT NULL
);

-- Container tracks processing status here instead of updating messages_in.
-- Host reads this to know which messages have been processed.
-- On container startup, stale 'processing' entries are cleared (crash recovery).


Add NON_BASH_TOOL_CEILING_MS and skip kill when non-Bash tool is in flight

Add RED tests for Agent-tool-in-flight ceiling behavior

Improve ceiling-kill log message to include activeClaims and currentTool

Update board and card, log run, schedule reset-wake


237	-- Container tracks processing status here instead of updating messages_in.
238	-- Host reads this to know which messages have been processed.
239	-- On container startup, stale 'processing' entries are cleared (crash recovery).
240	CREATE TABLE IF NOT EXISTS processing_ack (
241	  message_id     TEXT PRIMARY KEY,
242	  status         TEXT NOT NULL,
243	  status_changed TEXT NOT NULL
244	);
245
246	-- Persistent key/value state owned by the container. Used (among other things)
247	-- to store the SDK session ID so the agent's conversation resumes across
248	-- container restarts. Cleared by /clear.
249	CREATE TABLE IF NOT EXISTS session_state (
250	  key        TEXT PRIMARY KEY,
251	  value      TEXT NOT NULL,
252	  updated_at TEXT NOT NULL
253	);
254
255	-- Current tool-in-flight state. Single-row table (id=1). Container writes on
256	-- PreToolUse and clears on PostToolUse / PostToolUseFailure. Host reads in the
257	-- sweep to extend the stuck-tolerance window when Bash is running with a
258	-- declared timeout > 60s (long-running scripts shouldn't be flagged as stuck).
259	CREATE TABLE IF NOT EXISTS container_state (
260	  id                       INTEGER PRIMARY KEY CHECK (id = 1),
261	  current_tool             TEXT,
262	  tool_declared_timeout_ms INTEGER,
263	  tool_started_at          TEXT,
264	  updated_at               TEXT NOT NULL
265	);
266	`;
267

-- Container tracks processing status here instead of updating messages_in.
-- Host reads this to know which messages have been processed.
-- On container startup, stale 'processing' entries are cleared (crash recovery).
CREATE TABLE IF NOT EXISTS processing_ack (
  message_id     TEXT PRIMARY KEY,
  status         TEXT NOT NULL,
  status_changed TEXT NOT NULL
);

-- Persistent key/value state owned by the container. Used (among other things)
-- to store the SDK session ID so the agent's conversation resumes across
-- container restarts. Cleared by /clear.
CREATE TABLE IF NOT EXISTS session_state (
  key        TEXT PRIMARY KEY,
  value      TEXT NOT NULL,
  updated_at TEXT NOT NULL
);

-- Current tool-in-flight state. Single-row table (id=1). Container writes on
-- PreToolUse and clears on PostToolUse / PostToolUseFailure. Host reads in the
-- sweep to extend the stuck-tolerance window when Bash is running with a
-- declared timeout > 60s (long-running scripts shouldn't be flagged as stuck).
CREATE TABLE IF NOT EXISTS container_state (
  id                       INTEGER PRIMARY KEY CHECK (id = 1),
  current_tool             TEXT,
  tool_declared_timeout_ms INTEGER,
  tool_started_at          TEXT,
  updated_at               TEXT NOT NULL
);
`;


Now let me look at `session-manager.ts` to understand more about the comment about journal_mode=DELETE and WAL, and why:


1	/**
2	 * Session lifecycle: folders, DBs, messages, container status.
3	 *
4	 * Two-DB split — inbound.db (host writes) + outbound.db (container writes).
5	 * Three cross-mount invariants are load-bearing:
6	 *   1. journal_mode=DELETE — WAL's mmapped -shm doesn't refresh host→guest;
7	 *      the container would silently miss every new message.
8	 *   2. Host opens-writes-CLOSES per op — close invalidates the container's
9	 *      page cache; a long-lived connection freezes its view at first read.
10	 *   3. One writer per file — DELETE-mode journal-unlink isn't atomic across
11	 *      the mount; concurrent writers corrupt the DB.
12	 */
13	import type Database from 'better-sqlite3';
14	import fs from 'fs';
15	import path from 'path';
16
17	import { deriveAttachmentName } from './attachment-naming.js';
18	import { isSafeAttachmentName } from './attachment-safety.js';
19	import type { OutboundFile } from './channels/adapter.js';
20	import { DATA_DIR } from './config.js';
21	import { getMessagingGroup } from './db/messaging-groups.js';
22	import {
23	  createSession,
24	  findSessionByAgentGroup,
25	  findSessionForAgent,
26	  getSession,
27	  updateSession,
28	} from './db/sessions.js';
29	import {
30	  ensureSchema,
31	  openInboundDb as openInboundDbRaw,
32	  openOutboundDb as openOutboundDbRaw,
33	  openOutboundDbRw as openOutboundDbRwRaw,
34	  upsertSessionRouting,
35	  insertMessage,
36	  migrateMessagesInTable,
37	} from './db/session-db.js';
38	import { log } from './log.js';
39	import type { Session } from './types.js';
40
41	function isPathInside(parent: string, child: string): boolean {
42	  const relative = path.relative(parent, child);
43	  return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
44	}
45
46	/** Root directory for all session data. */
47	export function sessionsBaseDir(): string {
48	  return path.join(DATA_DIR, 'v2-sessions');
49	}
50

/**
 * Session lifecycle: folders, DBs, messages, container status.
 *
 * Two-DB split — inbound.db (host writes) + outbound.db (container writes).
 * Three cross-mount invariants are load-bearing:
 *   1. journal_mode=DELETE — WAL's mmapped -shm doesn't refresh host→guest;
 *      the container would silently miss every new message.
 *   2. Host opens-writes-CLOSES per op — close invalidates the container's
 *      page cache; a long-lived connection freezes its view at first read.
 *   3. One writer per file — DELETE-mode journal-unlink isn't atomic across
 *      the mount; concurrent writers corrupt the DB.
 */
import type Database from 'better-sqlite3';
import fs from 'fs';
import path from 'path';

import { deriveAttachmentName } from './attachment-naming.js';
import { isSafeAttachmentName } from './attachment-safety.js';
import type { OutboundFile } from './channels/adapter.js';
import { DATA_DIR } from './config.js';
import { getMessagingGroup } from './db/messaging-groups.js';
import {
  createSession,
  findSessionByAgentGroup,
  findSessionForAgent,
  getSession,
  updateSession,
} from './db/sessions.js';
import {
  ensureSchema,
  openInboundDb as openInboundDbRaw,
  openOutboundDb as openOutboundDbRaw,
  openOutboundDbRw as openOutboundDbRwRaw,
  upsertSessionRouting,
  insertMessage,
  migrateMessagesInTable,
} from './db/session-db.js';
import { log } from './log.js';
import type { Session } from './types.js';

function isPathInside(parent: string, child: string): boolean {
  const relative = path.relative(parent, child);
  return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
}

/** Root directory for all session data. */
export function sessionsBaseDir(): string {
  return path.join(DATA_DIR, 'v2-sessions');
}


Now I understand the architecture very well. The key comment at lines 6-7 says:
```
1. journal_mode=DELETE — WAL's mmapped -shm doesn't refresh host→guest;
   the container would silently miss every new message.
```

This confirms that both inbound.db and outbound.db are intentionally in `journal_mode=DELETE` to avoid WAL issues across the host-container mount boundary.

The host side opens the outbound DB with `readonly: true` (no WAL, no `-shm` issue). The SQLite error "attempt to write a readonly database" shouldn't be triggered by a SELECT in DELETE journal mode.
Let me trace through the actual error more carefully. The error occurs in `getDueOutboundMessages`, which is a pure SELECT query, so the write attempt must be happening internally within SQLite itself—not from the query logic. I need to look at how `openOutboundDb` is configured to understand why SQLite thinks it needs to write.

The database is opened in readonly mode with `{ readonly: true }`, which passes `SQLITE_OPEN_READONLY` to SQLite. But then immediately after, it runs `PRAGMA busy_timeout = 5000`. I'm wondering if that pragma operation itself requires write access, or if there's something else happening with lock files or WAL mechanics that's causing the readonly constraint to fail.

Actually, I should check whether the `outbound.db` file might have a leftover WAL file from a previous session when it was opened in write mode. If the container previously set WAL mode on the database and then the host tries to open it readonly, the WAL lock mechanism might require write access to function properly. I need to look at the container-side code — specifically the nanoclaw MCP server running inside the container that writes to `outbound.db` — to see how it's opening and configuring the database.


Bash

find /workspace/extra/projects/nanoclaw/src -name "*.ts" | xargs grep -l "messages_out" 2>/dev/null | head -20

Find container-side code that writes to messages_out


99:  content: unknown; // parsed JSON from messages_out


I need to look at the container-side code. In nanoclaw's architecture, the container is a Docker container that runs Claude Code along with some MCP tools that write to the outbound.db. Let me search for those container-side MCP tools.

Actually, looking at the file list more carefully, I see `state-sqlite.ts` which might be related to the container's SQLite operations. Let me also look for any "container" folder or similar.


1	/**
2	 * Chat SDK StateAdapter backed by SQLite.
3	 * Persists subscriptions, locks, KV, and lists across restarts.
4	 *
5	 * Ported from feat/chat-sdk-integration branch.
6	 */
7	import crypto from 'crypto';
8
9	import type Database from 'better-sqlite3';
10	import type { StateAdapter, QueueEntry } from 'chat';
11
12	import { getDb } from './db/connection.js';
13
14	interface Lock {
15	  threadId: string;
16	  token: string;
17	  expiresAt: number;
18	}
19
20	export class SqliteStateAdapter implements StateAdapter {
21	  private db!: Database.Database;
22
23	  async connect(): Promise<void> {
24	    this.db = getDb();
25	    this.cleanup();
26	  }
27
28	  async disconnect(): Promise<void> {}
29
30	  // --- Key-value ---
31
32	  async get<T = unknown>(key: string): Promise<T | null> {
33	    this.cleanup();
34	    const row = this.db.prepare('SELECT value, expires_at FROM chat_sdk_kv WHERE key = ?').get(key) as
35	      | { value: string; expires_at: number | null }
36	      | undefined;
37	    if (!row) return null;
38	    if (row.expires_at && row.expires_at < Date.now()) {
39	      this.db.prepare('DELETE FROM chat_sdk_kv WHERE key = ?').run(key);
40	      return null;
41	    }
42	    return JSON.parse(row.value) as T;
43	  }
44
45	  async set<T = unknown>(key: string, value: T, ttlMs?: number): Promise<void> {
46	    const expiresAt = ttlMs ? Date.now() + ttlMs : null;
47	    this.db
48	      .prepare('INSERT OR REPLACE INTO chat_sdk_kv (key, value, expires_at) VALUES (?, ?, ?)')
49	      .run(key, JSON.stringify(value), expiresAt);
50	  }
51
52	  async setIfNotExists(key: string, value: unknown, ttlMs?: number): Promise<boolean> {
53	    const existing = this.db.prepare('SELECT expires_at FROM chat_sdk_kv WHERE key = ?').get(key) as
54	      | { expires_at: number | null }
55	      | undefined;
56	    if (existing?.expires_at && existing.expires_at < Date.now()) {
57	      this.db.prepare('DELETE FROM chat_sdk_kv WHERE key = ?').run(key);
58	    }
59	    const expiresAt = ttlMs ? Date.now() + ttlMs : null;
60	    const result = this.db
61	      .prepare('INSERT OR IGNORE INTO chat_sdk_kv (key, value, expires_at) VALUES (?, ?, ?)')
62	      .run(key, JSON.stringify(value), expiresAt);
63	    return result.changes > 0;
64	  }
65
66	  async delete(key: string): Promise<void> {
67	    this.db.prepare('DELETE FROM chat_sdk_kv WHERE key = ?').run(key);
68	  }
69
70	  // --- Subscriptions ---
71
72	  async subscribe(threadId: string): Promise<void> {
73	    this.db.prepare('INSERT OR REPLACE INTO chat_sdk_subscriptions (thread_id) VALUES (?)').run(threadId);
74	  }
75
76	  async unsubscribe(threadId: string): Promise<void> {
77	    this.db.prepare('DELETE FROM chat_sdk_subscriptions WHERE thread_id = ?').run(threadId);
78	  }
79
80	  async isSubscribed(threadId: string): Promise<boolean> {
81	    const row = this.db.prepare('SELECT 1 FROM chat_sdk_subscriptions WHERE thread_id = ? LIMIT 1').get(threadId);
82	    return !!row;
83	  }
84
85	  // --- Locks ---
86
87	  async acquireLock(threadId: string, ttlMs: number): Promise<Lock | null> {
88	    const now = Date.now();
89	    const token = crypto.randomUUID();
90	    const expiresAt = now + ttlMs;
91	    this.db.prepare('DELETE FROM chat_sdk_locks WHERE thread_id = ? AND expires_at < ?').run(threadId, now);
92	    const result = this.db
93	      .prepare('INSERT OR IGNORE INTO chat_sdk_locks (thread_id, token, expires_at) VALUES (?, ?, ?)')
94	      .run(threadId, token, expiresAt);
95	    if (result.changes === 0) return null;
96	    return { threadId, token, expiresAt };
97	  }
98
99	  async releaseLock(lock: Lock): Promise<void> {
100	    this.db.prepare('DELETE FROM chat_sdk_locks WHERE thread_id = ? AND token = ?').run(lock.threadId, lock.token);
101	  }
102
103	  async extendLock(lock: Lock, ttlMs: number): Promise<boolean> {
104	    const newExpiry = Date.now() + ttlMs;
105	    const result = this.db
106	      .prepare('UPDATE chat_sdk_locks SET expires_at = ? WHERE thread_id = ? AND token = ?')
107	      .run(newExpiry, lock.threadId, lock.token);
108	    if (result.changes > 0) {
109	      lock.expiresAt = newExpiry;
110	      return true;
111	    }
112	    return false;
113	  }
114
115	  async forceReleaseLock(threadId: string): Promise<void> {
116	    this.db.prepare('DELETE FROM chat_sdk_locks WHERE thread_id = ?').run(threadId);
117	  }
118
119	  // --- Lists ---
120
121	  async appendToList(key: string, value: unknown, options?: { maxLength?: number; ttlMs?: number }): Promise<void> {
122	    const expiresAt = options?.ttlMs ? Date.now() + options.ttlMs : null;
123	    const maxRow = this.db.prepare('SELECT MAX(idx) as maxIdx FROM chat_sdk_lists WHERE key = ?').get(key) as
124	      | { maxIdx: number | null }
125	      | undefined;
126	    const nextIdx = (maxRow?.maxIdx ?? -1) + 1;
127	    this.db
128	      .prepare('INSERT INTO chat_sdk_lists (key, idx, value, expires_at) VALUES (?, ?, ?, ?)')
129	      .run(key, nextIdx, JSON.stringify(value), expiresAt);
130	    if (options?.maxLength) {
131	      const cutoff = nextIdx - options.maxLength;
132	      if (cutoff >= 0) {
133	        this.db.prepare('DELETE FROM chat_sdk_lists WHERE key = ? AND idx <= ?').run(key, cutoff);
134	      }
135	    }
136	  }
137
138	  async getList<T = unknown>(key: string): Promise<T[]> {
139	    const now = Date.now();
140	    const rows = this.db
141	      .prepare(
142	        'SELECT value FROM chat_sdk_lists WHERE key = ? AND (expires_at IS NULL OR expires_at > ?) ORDER BY idx ASC',
143	      )
144	      .all(key, now) as { value: string }[];
145	    return rows.map((r) => JSON.parse(r.value) as T);
146	  }
147
148	  // --- Queue ---
149
150	  async enqueue(threadId: string, entry: QueueEntry, maxSize: number): Promise<number> {
151	    const key = `queue:${threadId}`;
152	    await this.appendToList(key, entry, { maxLength: maxSize });
153	    return await this.queueDepth(threadId);
154	  }
155
156	  async dequeue(threadId: string): Promise<QueueEntry | null> {
157	    const key = `queue:${threadId}`;
158	    const row = this.db
159	      .prepare('SELECT idx, value FROM chat_sdk_lists WHERE key = ? ORDER BY idx ASC LIMIT 1')
160	      .get(key) as { idx: number; value: string } | undefined;
161	    if (!row) return null;
162	    this.db.prepare('DELETE FROM chat_sdk_lists WHERE key = ? AND idx = ?').run(key, row.idx);
163	    return JSON.parse(row.value) as QueueEntry;
164	  }
165
166	  async queueDepth(threadId: string): Promise<number> {
167	    const key = `queue:${threadId}`;
168	    const row = this.db.prepare('SELECT COUNT(*) as count FROM chat_sdk_lists WHERE key = ?').get(key) as {
169	      count: number;
170	    };
171	    return row.count;
172	  }
173
174	  // --- Cleanup ---
175
176	  private cleanup(): void {
177	    const now = Date.now();
178	    this.db.prepare('DELETE FROM chat_sdk_kv WHERE expires_at IS NOT NULL AND expires_at < ?').run(now);
179	    this.db.prepare('DELETE FROM chat_sdk_locks WHERE expires_at < ?').run(now);
180	    this.db.prepare('DELETE FROM chat_sdk_lists WHERE expires_at IS NOT NULL AND expires_at < ?').run(now);
181	  }
182	}
183

/**
 * Chat SDK StateAdapter backed by SQLite.
 * Persists subscriptions, locks, KV, and lists across restarts.
 *
 * Ported from feat/chat-sdk-integration branch.
 */
import crypto from 'crypto';

import type Database from 'better-sqlite3';
import type { StateAdapter, QueueEntry } from 'chat';

import { getDb } from './db/connection.js';

interface Lock {
  threadId: string;
  token: string;
  expiresAt: number;
}

export class SqliteStateAdapter implements StateAdapter {
  private db!: Database.Database;

  async connect(): Promise<void> {
    this.db = getDb();
    this.cleanup();
  }

  async disconnect(): Promise<void> {}

  // --- Key-value ---

  async get<T = unknown>(key: string): Promise<T | null> {
    this.cleanup();
    const row = this.db.prepare('SELECT value, expires_at FROM chat_sdk_kv WHERE key = ?').get(key) as
      | { value: string; expires_at: number | null }
      | undefined;
    if (!row) return null;
    if (row.expires_at && row.expires_at < Date.now()) {
      this.db.prepare('DELETE FROM chat_sdk_kv WHERE key = ?').run(key);
      return null;
    }
    return JSON.parse(row.value) as T;
  }

  async set<T = unknown>(key: string, value: T, ttlMs?: number): Promise<void> {
    const expiresAt = ttlMs ? Date.now() + ttlMs : null;
    this.db
      .prepare('INSERT OR REPLACE INTO chat_sdk_kv (key, value, expires_at) VALUES (?, ?, ?)')
      .run(key, JSON.stringify(value), expiresAt);
  }

  async setIfNotExists(key: string, value: unknown, ttlMs?: number): Promise<boolean> {
    const existing = this.db.prepare('SELECT expires_at FROM chat_sdk_kv WHERE key = ?').get(key) as
      | { expires_at: number | null }
      | undefined;
    if (existing?.expires_at && existing.expires_at < Date.now()) {
      this.db.prepare('DELETE FROM chat_sdk_kv WHERE key = ?').run(key);
    }
    const expiresAt = ttlMs ? Date.now() + ttlMs : null;
    const result = this.db
      .prepare('INSERT OR IGNORE INTO chat_sdk_kv (key, value, expires_at) VALUES (?, ?, ?)')
      .run(key, JSON.stringify(value), expiresAt);
    return result.changes > 0;
  }

  async delete(key: string): Promise<void> {
    this.db.prepare('DELETE FROM chat_sdk_kv WHERE key = ?').run(key);
  }

  // --- Subscriptions ---

  async subscribe(threadId: string): Promise<void> {
    this.db.prepare('INSERT OR REPLACE INTO chat_sdk_subscriptions (thread_id) VALUES (?)').run(threadId);
  }

  async unsubscribe(threadId: string): Promise<void> {
    this.db.prepare('DELETE FROM chat_sdk_subscriptions WHERE thread_id = ?').run(threadId);
  }

  async isSubscribed(threadId: string): Promise<boolean> {
    const row = this.db.prepare('SELECT 1 FROM chat_sdk_subscriptions WHERE thread_id = ? LIMIT 1').get(threadId);
    return !!row;
  }

  // --- Locks ---

  async acquireLock(threadId: string, ttlMs: number): Promise<Lock | null> {
    const now = Date.now();
    const token = crypto.randomUUID();
    const expiresAt = now + ttlMs;
    this.db.prepare('DELETE FROM chat_sdk_locks WHERE thread_id = ? AND expires_at < ?').run(threadId, now);
    const result = this.db
      .prepare('INSERT OR IGNORE INTO chat_sdk_locks (thread_id, token, expires_at) VALUES (?, ?, ?)')
      .run(threadId, token, expiresAt);
    if (result.changes === 0) return null;
    return { threadId, token, expiresAt };
  }

  async releaseLock(lock: Lock): Promise<void> {
    this.db.prepare('DELETE FROM chat_sdk_locks WHERE thread_id = ? AND token = ?').run(lock.threadId, lock.token);
  }

  async extendLock(lock: Lock, ttlMs: number): Promise<boolean> {
    const newExpiry = Date.now() + ttlMs;
    const result = this.db
      .prepare('UPDATE chat_sdk_locks SET expires_at = ? WHERE thread_id = ? AND token = ?')
      .run(newExpiry, lock.threadId, lock.token);
    if (result.changes > 0) {
      lock.expiresAt = newExpiry;
      return true;
    }
    return false;
  }

  async forceReleaseLock(threadId: string): Promise<void> {
    this.db.prepare('DELETE FROM chat_sdk_locks WHERE thread_id = ?').run(threadId);
  }

  // --- Lists ---

  async appendToList(key: string, value: unknown, options?: { maxLength?: number; ttlMs?: number }): Promise<void> {
    const expiresAt = options?.ttlMs ? Date.now() + options.ttlMs : null;
    const maxRow = this.db.prepare('SELECT MAX(idx) as maxIdx FROM chat_sdk_lists WHERE key = ?').get(key) as
      | { maxIdx: number | null }
      | undefined;
    const nextIdx = (maxRow?.maxIdx ?? -1) + 1;
    this.db
      .prepare('INSERT INTO chat_sdk_lists (key, idx, value, expires_at) VALUES (?, ?, ?, ?)')
      .run(key, nextIdx, JSON.stringify(value), expiresAt);
    if (options?.maxLength) {
      const cutoff = nextIdx - options.maxLength;
      if (cutoff >= 0) {
        this.db.prepare('DELETE FROM chat_sdk_lists WHERE key = ? AND idx <= ?').run(key, cutoff);
      }
    }
  }

  async getList<T = unknown>(key: string): Promise<T[]> {
    const now = Date.now();
    const rows = this.db
      .prepare(
        'SELECT value FROM chat_sdk_lists WHERE key = ? AND (expires_at IS NULL OR expires_at > ?) ORDER BY idx ASC',
      )
      .all(key, now) as { value: string }[];
    return rows.map((r) => JSON.parse(r.value) as T);
  }

  // --- Queue ---

  async enqueue(threadId: string, entry: QueueEntry, maxSize: number): Promise<number> {
    const key = `queue:${threadId}`;
    await this.appendToList(key, entry, { maxLength: maxSize });
    return await this.queueDepth(threadId);
  }

  async dequeue(threadId: string): Promise<QueueEntry | null> {
    const key = `queue:${threadId}`;
    const row = this.db
      .prepare('SELECT idx, value FROM chat_sdk_lists WHERE key = ? ORDER BY idx ASC LIMIT 1')
      .get(key) as { idx: number; value: string } | undefined;
    if (!row) return null;
    this.db.prepare('DELETE FROM chat_sdk_lists WHERE key = ? AND idx = ?').run(key, row.idx);
    return JSON.parse(row.value) as QueueEntry;
  }

  async queueDepth(threadId: string): Promise<number> {
    const key = `queue:${threadId}`;
    const row = this.db.prepare('SELECT COUNT(*) as count FROM chat_sdk_lists WHERE key = ?').get(key) as {
      count: number;
    };
    return row.count;
  }

  // --- Cleanup ---

  private cleanup(): void {
    const now = Date.now();
    this.db.prepare('DELETE FROM chat_sdk_kv WHERE expires_at IS NOT NULL AND expires_at < ?').run(now);
    this.db.prepare('DELETE FROM chat_sdk_locks WHERE expires_at < ?').run(now);
    this.db.prepare('DELETE FROM chat_sdk_lists WHERE expires_at IS NOT NULL AND expires_at < ?').run(now);
  }
}


OK, this is the main DB (WAL mode), not outbound.db. Let me look at the container-side nanoclaw code. Since NanoClaw runs agents in Docker containers, there must be a container-side nanoclaw package that the container uses. Let me check if there's a separate directory for container code.


find /workspace/extra/projects/nanoclaw -name "*.ts" | xargs grep -l "messages_out.*INSERT\|INSERT.*messages_out" 2>/dev/null | head -10

Find files that insert into messages_out


1	/**
2	 * Outbound message operations (container side).
3	 *
4	 * Writes to outbound.db (container-owned).
5	 * The host polls this DB (read-only) for undelivered messages.
6	 */
7	import { getInboundDb, getOutboundDb } from './connection.js';
8
9	export interface MessageOutRow {
10	  id: string;
11	  seq: number | null;
12	  in_reply_to: string | null;
13	  timestamp: string;
14	  deliver_after: string | null;
15	  recurrence: string | null;
16	  kind: string;
17	  platform_id: string | null;
18	  channel_type: string | null;
19	  thread_id: string | null;
20	  content: string;
21	}
22
23	export interface WriteMessageOut {
24	  id: string;
25	  in_reply_to?: string | null;
26	  deliver_after?: string | null;
27	  recurrence?: string | null;
28	  kind: string;
29	  platform_id?: string | null;
30	  channel_type?: string | null;
31	  thread_id?: string | null;
32	  content: string;
33	}
34
35	/**
36	 * Write a new outbound message, auto-assigning an odd seq number.
37	 * Container uses odd seq (1, 3, 5...), host uses even (2, 4, 6...).
38	 *
39	 * The disjoint namespace is load-bearing, not just collision avoidance:
40	 * seq is the agent-facing message ID returned by send_message and accepted
41	 * by edit_message / add_reaction, and getMessageIdBySeq() below looks up
42	 * by seq across BOTH tables. If inbound and outbound could share a seq,
43	 * the agent's "edit message #5" could resolve to the wrong row.
44	 */
45	export function writeMessageOut(msg: WriteMessageOut): number {
46	  const outbound = getOutboundDb();
47	  const inbound = getInboundDb();
48
49	  // Read max seq from both DBs to maintain global ordering.
50	  // Safe: each side only reads the other DB, never writes to it.
51	  const maxOut = (outbound.prepare('SELECT COALESCE(MAX(seq), 0) AS m FROM messages_out').get() as { m: number }).m;
52	  const maxIn = (inbound.prepare('SELECT COALESCE(MAX(seq), 0) AS m FROM messages_in').get() as { m: number }).m;
53	  const max = Math.max(maxOut, maxIn);
54	  const nextSeq = max % 2 === 0 ? max + 1 : max + 2; // next odd
55
56	  // bun:sqlite requires named parameters to be passed with the prefix character
57	  // in the JS object keys (better-sqlite3 auto-stripped it, bun:sqlite does not).
58	  outbound
59	    .prepare(
60	      `INSERT INTO messages_out (id, seq, in_reply_to, timestamp, deliver_after, recurrence, kind, platform_id, channel_type, thread_id, content)
61	     VALUES ($id, $seq, $in_reply_to, datetime('now'), $deliver_after, $recurrence, $kind, $platform_id, $channel_type, $thread_id, $content)`,
62	    )
63	    .run({
64	      $id: msg.id,
65	      $seq: nextSeq,
66	      $in_reply_to: msg.in_reply_to ?? null,
67	      $deliver_after: msg.deliver_after ?? null,
68	      $recurrence: msg.recurrence ?? null,
69	      $kind: msg.kind,
70	      $platform_id: msg.platform_id ?? null,
71	      $channel_type: msg.channel_type ?? null,
72	      $thread_id: msg.thread_id ?? null,
73	      $content: msg.content,
74	    });
75
76	  return nextSeq;
77	}
78
79	/**
80	 * Look up a message's platform ID by seq number.
81	 * Searches both inbound and outbound DBs since seq spans both.
82	 *
83	 * For inbound messages, the Chat SDK message ID is already the platform message ID
84	 * (e.g., "6037840640:42" for Telegram).
85	 *
86	 * For outbound messages, the internal ID (msg-xxx) won't work for edits/reactions.
87	 * Instead, look up the platform_message_id from the delivered table (host writes this
88	 * after successful delivery).
89	 */
90	export function getMessageIdBySeq(seq: number): string | null {
91	  const inbound = getInboundDb();
92
93	  // Inbound messages: ID is already the platform message ID
94	  const inRow = inbound.prepare('SELECT id FROM messages_in WHERE seq = ?').get(seq) as
95	    | { id: string }
96	    | undefined;
97	  if (inRow) return inRow.id;
98
99	  // Outbound messages: look up platform message ID from delivered table
100	  const outRow = getOutboundDb().prepare('SELECT id FROM messages_out WHERE seq = ?').get(seq) as
101	    | { id: string }
102	    | undefined;
103	  if (!outRow) return null;
104
105	  // Check if host has stored the platform message ID after delivery
106	  const deliveredRow = inbound
107	    .prepare('SELECT platform_message_id FROM delivered WHERE message_out_id = ?')
108	    .get(outRow.id) as { platform_message_id: string | null } | undefined;
109	  if (deliveredRow?.platform_message_id) return deliveredRow.platform_message_id;
110
111	  // Fallback to internal ID (edits/reactions on undelivered messages won't work)
112	  return outRow.id;
113	}
114
115	/**
116	 * Look up the routing fields for a message by seq (for edit/reaction targeting).
117	 * Returns the channel_type, platform_id, thread_id of the referenced message.
118	 */
119	export function getRoutingBySeq(
120	  seq: number,
121	): { channel_type: string | null; platform_id: string | null; thread_id: string | null } | null {
122	  const inbound = getInboundDb();
123	  const inRow = inbound
124	    .prepare('SELECT channel_type, platform_id, thread_id FROM messages_in WHERE seq = ?')
125	    .get(seq) as { channel_type: string | null; platform_id: string | null; thread_id: string | null } | undefined;
126	  if (inRow) return inRow;
127
128	  const outRow = getOutboundDb()
129	    .prepare('SELECT channel_type, platform_id, thread_id FROM messages_out WHERE seq = ?')
130	    .get(seq) as { channel_type: string | null; platform_id: string | null; thread_id: string | null } | undefined;
131	  return outRow ?? null;
132	}
133
134	/** Get undelivered messages (for host polling — reads from outbound.db). */
135	export function getUndeliveredMessages(): MessageOutRow[] {
136	  return getOutboundDb()
137	    .prepare(
138	      `SELECT * FROM messages_out
139	       WHERE (deliver_after IS NULL OR deliver_after <= datetime('now'))
140	       ORDER BY timestamp ASC`,
141	    )
142	    .all() as MessageOutRow[];
143	}
144

/**
 * Outbound message operations (container side).
 *
 * Writes to outbound.db (container-owned).
 * The host polls this DB (read-only) for undelivered messages.
 */
import { getInboundDb, getOutboundDb } from './connection.js';

export interface MessageOutRow {
  id: string;
  seq: number | null;
  in_reply_to: string | null;
  timestamp: string;
  deliver_after: string | null;
  recurrence: string | null;
  kind: string;
  platform_id: string | null;
  channel_type: string | null;
  thread_id: string | null;
  content: string;
}

export interface WriteMessageOut {
  id: string;
  in_reply_to?: string | null;
  deliver_after?: string | null;
  recurrence?: string | null;
  kind: string;
  platform_id?: string | null;
  channel_type?: string | null;
  thread_id?: string | null;
  content: string;
}

/**
 * Write a new outbound message, auto-assigning an odd seq number.
 * Container uses odd seq (1, 3, 5...), host uses even (2, 4, 6...).
 *
 * The disjoint namespace is load-bearing, not just collision avoidance:
 * seq is the agent-facing message ID returned by send_message and accepted
 * by edit_message / add_reaction, and getMessageIdBySeq() below looks up
 * by seq across BOTH tables. If inbound and outbound could share a seq,
 * the agent's "edit message #5" could resolve to the wrong row.
 */
export function writeMessageOut(msg: WriteMessageOut): number {
  const outbound = getOutboundDb();
  const inbound = getInboundDb();

  // Read max seq from both DBs to maintain global ordering.
  // Safe: each side only reads the other DB, never writes to it.
  const maxOut = (outbound.prepare('SELECT COALESCE(MAX(seq), 0) AS m FROM messages_out').get() as { m: number }).m;
  const maxIn = (inbound.prepare('SELECT COALESCE(MAX(seq), 0) AS m FROM messages_in').get() as { m: number }).m;
  const max = Math.max(maxOut, maxIn);
  const nextSeq = max % 2 === 0 ? max + 1 : max + 2; // next odd

  // bun:sqlite requires named parameters to be passed with the prefix character
  // in the JS object keys (better-sqlite3 auto-stripped it, bun:sqlite does not).
  outbound
    .prepare(
      `INSERT INTO messages_out (id, seq, in_reply_to, timestamp, deliver_after, recurrence, kind, platform_id, channel_type, thread_id, content)
     VALUES ($id, $seq, $in_reply_to, datetime('now'), $deliver_after, $recurrence, $kind, $platform_id, $channel_type, $thread_id, $content)`,
    )
    .run({
      $id: msg.id,
      $seq: nextSeq,
      $in_reply_to: msg.in_reply_to ?? null,
      $deliver_after: msg.deliver_after ?? null,
      $recurrence: msg.recurrence ?? null,
      $kind: msg.kind,
      $platform_id: msg.platform_id ?? null,
      $channel_type: msg.channel_type ?? null,
      $thread_id: msg.thread_id ?? null,
      $content: msg.content,
    });

  return nextSeq;
}

/**
 * Look up a message's platform ID by seq number.
 * Searches both inbound and outbound DBs since seq spans both.
 *
 * For inbound messages, the Chat SDK message ID is already the platform message ID
 * (e.g., "6037840640:42" for Telegram).
 *
 * For outbound messages, the internal ID (msg-xxx) won't work for edits/reactions.
 * Instead, look up the platform_message_id from the delivered table (host writes this
 * after successful delivery).
 */
export function getMessageIdBySeq(seq: number): string | null {
  const inbound = getInboundDb();

  // Inbound messages: ID is already the platform message ID
  const inRow = inbound.prepare('SELECT id FROM messages_in WHERE seq = ?').get(seq) as
    | { id: string }
    | undefined;
  if (inRow) return inRow.id;

  // Outbound messages: look up platform message ID from delivered table
  const outRow = getOutboundDb().prepare('SELECT id FROM messages_out WHERE seq = ?').get(seq) as
    | { id: string }
    | undefined;
  if (!outRow) return null;

  // Check if host has stored the platform message ID after delivery
  const deliveredRow = inbound
    .prepare('SELECT platform_message_id FROM delivered WHERE message_out_id = ?')
    .get(outRow.id) as { platform_message_id: string | null } | undefined;
  if (deliveredRow?.platform_message_id) return deliveredRow.platform_message_id;

  // Fallback to internal ID (edits/reactions on undelivered messages won't work)
  return outRow.id;
}

/**
 * Look up the routing fields for a message by seq (for edit/reaction targeting).
 * Returns the channel_type, platform_id, thread_id of the referenced message.
 */
export function getRoutingBySeq(
  seq: number,
): { channel_type: string | null; platform_id: string | null; thread_id: string | null } | null {
  const inbound = getInboundDb();
  const inRow = inbound
    .prepare('SELECT channel_type, platform_id, thread_id FROM messages_in WHERE seq = ?')
    .get(seq) as { channel_type: string | null; platform_id: string | null; thread_id: string | null } | undefined;
  if (inRow) return inRow;

  const outRow = getOutboundDb()
    .prepare('SELECT channel_type, platform_id, thread_id FROM messages_out WHERE seq = ?')
    .get(seq) as { channel_type: string | null; platform_id: string | null; thread_id: string | null } | undefined;
  return outRow ?? null;
}

/** Get undelivered messages (for host polling — reads from outbound.db). */
export function getUndeliveredMessages(): MessageOutRow[] {
  return getOutboundDb()
    .prepare(
      `SELECT * FROM messages_out
       WHERE (deliver_after IS NULL OR deliver_after <= datetime('now'))
       ORDER BY timestamp ASC`,
    )
    .all() as MessageOutRow[];
}


Now I can see it! The container side uses `bun:sqlite` (not better-sqlite3). The comment at line 56 says: "bun:sqlite requires named parameters to be passed with the prefix character in the JS object keys (better-sqlite3 auto-stripped it, bun:sqlite does not)."

Let me check the container-side `connection.ts` to understand how the outbound DB is opened on the container side - specifically what journal mode it uses.