@saadnvd1/agent-os 0.3.637 → 0.3.639

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.
Files changed (154) hide show
  1. package/app/api/sessions/[id]/pr/route.ts +3 -3
  2. package/app/api/sessions/[id]/setup/route.ts +3 -3
  3. package/app/api/tasks/[id]/approve-plan/route.ts +3 -4
  4. package/app/api/tasks/[id]/arm/route.ts +6 -5
  5. package/app/api/tasks/[id]/chat-facts/route.ts +2 -2
  6. package/app/api/tasks/[id]/chat-tail/route.ts +2 -2
  7. package/app/api/tasks/[id]/checks/route.ts +1 -1
  8. package/app/api/tasks/[id]/diff/route.ts +1 -1
  9. package/app/api/tasks/[id]/done/route.ts +2 -2
  10. package/app/api/tasks/[id]/drop/route.ts +6 -2
  11. package/app/api/tasks/[id]/export/route.ts +3 -5
  12. package/app/api/tasks/[id]/merge/route.ts +3 -3
  13. package/app/api/tasks/[id]/message/route.ts +2 -2
  14. package/app/api/tasks/[id]/model/route.ts +5 -1
  15. package/app/api/tasks/[id]/move/route.ts +5 -1
  16. package/app/api/tasks/[id]/moved/route.ts +6 -8
  17. package/app/api/tasks/[id]/plan/route.ts +4 -6
  18. package/app/api/tasks/[id]/plan-edits/route.ts +6 -5
  19. package/app/api/tasks/[id]/priority/route.ts +4 -1
  20. package/app/api/tasks/[id]/resume/route.ts +3 -4
  21. package/app/api/tasks/[id]/stop/route.ts +2 -2
  22. package/app/api/tasks/route.ts +1 -1
  23. package/lib/approvals/enable.ts +1 -1
  24. package/lib/away/report.ts +4 -4
  25. package/lib/away/run.ts +2 -2
  26. package/lib/bookmarks/store.ts +3 -3
  27. package/lib/bus/held-reply.ts +2 -2
  28. package/lib/bus/index.ts +3 -3
  29. package/lib/chat/compact-context.ts +1 -1
  30. package/lib/chat/hold.ts +3 -3
  31. package/lib/chat/refresh.ts +1 -1
  32. package/lib/chat/registry.ts +3 -3
  33. package/lib/chat/runner.ts +1 -1
  34. package/lib/chat/worker/main.ts +1 -1
  35. package/lib/db/index.ts +5 -1
  36. package/lib/db/lumifyhub.ts +8 -9
  37. package/lib/db/machine-scope.ts +12 -0
  38. package/lib/db/migrations/20261015T1800_split_sessions_tables.ts +172 -0
  39. package/lib/db/migrations/README.md +7 -0
  40. package/lib/db/queries.ts +16 -31
  41. package/lib/db/session-parts/columns.ts +186 -0
  42. package/lib/db/session-parts/fill.ts +33 -0
  43. package/lib/db/session-parts/index.ts +9 -0
  44. package/lib/db/session-parts/pauses.ts +11 -0
  45. package/lib/db/session-parts/placement.ts +11 -0
  46. package/lib/db/session-parts/plans.ts +31 -0
  47. package/lib/db/session-parts/prs.ts +31 -0
  48. package/lib/db/session-parts/tasks.ts +27 -0
  49. package/lib/db/session-parts/view.ts +43 -0
  50. package/lib/db/session-parts/write.ts +69 -0
  51. package/lib/done/archive.ts +2 -2
  52. package/lib/done/auto-archive.ts +1 -1
  53. package/lib/done/bulk.ts +1 -1
  54. package/lib/done/index.ts +8 -14
  55. package/lib/health/evidence.ts +1 -1
  56. package/lib/health/facts.ts +1 -1
  57. package/lib/health/pieces.ts +1 -1
  58. package/lib/hosts/peer-actions.ts +2 -2
  59. package/lib/hosts/peer-mirror.ts +2 -2
  60. package/lib/hosts/peer-sync.ts +69 -32
  61. package/lib/learning-cards/input.ts +1 -1
  62. package/lib/learning-cards/store.ts +1 -1
  63. package/lib/models/plan-step.ts +10 -24
  64. package/lib/models/resume.ts +5 -7
  65. package/lib/notify/index.ts +1 -1
  66. package/lib/notify/push/events.ts +1 -1
  67. package/lib/notify/push/index.ts +1 -1
  68. package/lib/orchestrator/act.ts +1 -1
  69. package/lib/orchestrator/armed.ts +17 -15
  70. package/lib/orchestrator/ask-approvals.ts +1 -1
  71. package/lib/orchestrator/ask-heads.ts +1 -1
  72. package/lib/orchestrator/ask-settle.ts +1 -1
  73. package/lib/orchestrator/brakes.ts +1 -1
  74. package/lib/orchestrator/digest.ts +2 -2
  75. package/lib/orchestrator/external-pr.ts +2 -2
  76. package/lib/orchestrator/facts.ts +4 -28
  77. package/lib/orchestrator/held-asks.ts +3 -3
  78. package/lib/orchestrator/home.ts +2 -2
  79. package/lib/orchestrator/overview.ts +1 -1
  80. package/lib/orchestrator/pr-wake.ts +3 -5
  81. package/lib/orchestrator/questions.ts +1 -1
  82. package/lib/orchestrator/quick-store.ts +1 -1
  83. package/lib/orchestrator/quick.ts +10 -7
  84. package/lib/orchestrator/read.ts +1 -1
  85. package/lib/orchestrator/rebase.ts +8 -11
  86. package/lib/orchestrator/reconcile.ts +2 -2
  87. package/lib/orchestrator/signoff.ts +3 -1
  88. package/lib/orchestrator/targets.ts +1 -1
  89. package/lib/orchestrator/task-state.ts +4 -2
  90. package/lib/plans/features.ts +1 -1
  91. package/lib/plans/release.ts +1 -1
  92. package/lib/ports.ts +1 -1
  93. package/lib/resurrect/find.ts +1 -1
  94. package/lib/resurrect/index.ts +24 -18
  95. package/lib/schedules/store.ts +1 -1
  96. package/lib/session-audit/sources.ts +3 -3
  97. package/lib/session-logs/index.ts +1 -1
  98. package/lib/session-titles.ts +1 -1
  99. package/lib/sessions/setup-progress.ts +1 -1
  100. package/lib/sessions/stop.ts +1 -1
  101. package/lib/sessions/worktree-setup.ts +2 -1
  102. package/lib/simulator/index.ts +1 -1
  103. package/lib/simulator/task-devices.ts +1 -1
  104. package/lib/stacks/board.ts +1 -1
  105. package/lib/stacks/land.ts +1 -1
  106. package/lib/stacks/restack.ts +1 -1
  107. package/lib/stacks/start.ts +2 -2
  108. package/lib/stacks/tick.ts +1 -1
  109. package/lib/stacks/view.ts +1 -1
  110. package/lib/status/incremental.ts +1 -1
  111. package/lib/tasks/contract/actions.ts +35 -0
  112. package/lib/tasks/contract/authorize.ts +82 -0
  113. package/lib/tasks/contract/client.ts +296 -0
  114. package/lib/tasks/{held-remote.ts → contract/held-calls.ts} +35 -66
  115. package/lib/tasks/contract/index.ts +28 -0
  116. package/lib/tasks/{remote-plan-path.ts → contract/plan-path.ts} +2 -2
  117. package/lib/tasks/{remote-plan.ts → contract/plan.ts} +76 -110
  118. package/lib/tasks/contract/routes.ts +26 -0
  119. package/lib/tasks/contract/start.ts +122 -0
  120. package/lib/tasks/contract/sync.ts +126 -0
  121. package/lib/tasks/contract/types.ts +41 -0
  122. package/lib/tasks/{remote-views.ts → contract/views.ts} +11 -18
  123. package/lib/tasks/finish.ts +10 -9
  124. package/lib/tasks/held-replies.ts +7 -13
  125. package/lib/tasks/held.ts +14 -18
  126. package/lib/tasks/import.ts +19 -15
  127. package/lib/tasks/index.ts +34 -22
  128. package/lib/tasks/launch-gate.ts +1 -1
  129. package/lib/tasks/move-carry.ts +15 -7
  130. package/lib/tasks/move-flow.ts +29 -36
  131. package/lib/tasks/move-guard.ts +1 -1
  132. package/lib/tasks/move-recover.ts +6 -25
  133. package/lib/tasks/move.ts +23 -23
  134. package/lib/tasks/moved-away.ts +1 -1
  135. package/lib/tasks/pause-hold.ts +13 -5
  136. package/lib/tasks/pause.ts +19 -23
  137. package/lib/tasks/plan-view.ts +1 -1
  138. package/lib/tasks/queue-placement.ts +1 -1
  139. package/lib/tasks/queue-rows.ts +4 -4
  140. package/lib/tasks/queue-start.ts +5 -3
  141. package/lib/tasks/queue-tick.ts +2 -2
  142. package/lib/tasks/queue.ts +14 -6
  143. package/lib/tasks/session.ts +7 -4
  144. package/lib/tasks/start.ts +4 -4
  145. package/lib/tasks/starter.ts +8 -11
  146. package/lib/usage/calls.ts +1 -1
  147. package/lib/usage/compare.ts +1 -1
  148. package/lib/usage/quality.ts +1 -1
  149. package/lib/usage/turns.ts +1 -1
  150. package/lib/voice/vocab.ts +1 -1
  151. package/package.json +1 -1
  152. package/scripts/backfill-session-logs.ts +1 -1
  153. package/lib/tasks/held-routes.ts +0 -33
  154. package/lib/tasks/remote.ts +0 -255
@@ -112,9 +112,9 @@ export function emit(sessionId: string, m: ChatServerMessage): void {
112
112
  }
113
113
 
114
114
  export function getSession(sessionId: string): Session {
115
- const s = db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(sessionId) as
116
- | Session
117
- | undefined;
115
+ const s = db
116
+ .prepare(`SELECT * FROM session_rows WHERE id = ?`)
117
+ .get(sessionId) as Session | undefined;
118
118
  if (!s) throw new Error("Session not found");
119
119
  return s;
120
120
  }
@@ -549,7 +549,7 @@ async function resumeCutOff(sessionId: string): Promise<void> {
549
549
  .prepare(
550
550
  `SELECT s.name, s.view, s.archived_at, s.task_status, s.role,
551
551
  COALESCE(s.workspace_id, p.workspace_id) AS workspace_id
552
- FROM sessions s LEFT JOIN projects p ON p.id = s.project_id
552
+ FROM session_rows s LEFT JOIN projects p ON p.id = s.project_id
553
553
  WHERE s.id = ?`
554
554
  )
555
555
  .get(sessionId) as
@@ -30,7 +30,7 @@ async function main(sessionId: string) {
30
30
  const { ChatHost } = await import("./host");
31
31
 
32
32
  const row = db
33
- .prepare(`SELECT * FROM sessions WHERE id = ?`)
33
+ .prepare(`SELECT * FROM session_rows WHERE id = ?`)
34
34
  .get(sessionId) as Session | undefined;
35
35
  if (!row) {
36
36
  console.error(`No session ${sessionId}`);
package/lib/db/index.ts CHANGED
@@ -1,16 +1,18 @@
1
1
  import Database from "better-sqlite3";
2
- import path from "path";
3
2
  import fs from "fs";
4
3
  import { createSchema } from "./schema";
5
4
  import { runMigrations } from "./migrations";
6
5
  import { dbPath } from "./path";
7
6
 
7
+ import { machineDb } from "./machine-scope";
8
+
8
9
  // Re-export types and queries
9
10
  export * from "./types";
10
11
  export { queries, sessionListColumns } from "./queries";
11
12
  export * from "./lumifyhub";
12
13
  export * from "./lumifyhub-docs";
13
14
  export * from "./stacks";
15
+ export * from "./session-parts";
14
16
 
15
17
  const DB_PATH = dbPath();
16
18
  const LOCK_PATH = DB_PATH + ".init-lock";
@@ -89,6 +91,8 @@ export function initDb(): Database.Database {
89
91
  let _db: Database.Database | null = null;
90
92
 
91
93
  export function getDb(): Database.Database {
94
+ const scoped = machineDb.getStore();
95
+ if (scoped) return scoped;
92
96
  if (!_db) {
93
97
  _db = initDb();
94
98
  }
@@ -1,4 +1,5 @@
1
1
  import type Database from "better-sqlite3";
2
+ import { setTask } from "./session-parts";
2
3
 
3
4
  // The one LumifyHub account this AgentOS is connected to. The token never
4
5
  // leaves the server: callers that talk to the UI map this to a status.
@@ -118,23 +119,21 @@ export const lumifyhubQueries = {
118
119
  sessionId: string,
119
120
  card: { cardId: string; boardId: string; list: string }
120
121
  ) =>
121
- db
122
- .prepare(
123
- `UPDATE sessions SET lh_card_id = ?, lh_board_id = ?, lh_card_list = ? WHERE id = ?`
124
- )
125
- .run(card.cardId, card.boardId, card.list, sessionId),
122
+ setTask(db, sessionId, {
123
+ lh_card_id: card.cardId,
124
+ lh_board_id: card.boardId,
125
+ lh_card_list: card.list,
126
+ }),
126
127
 
127
128
  setTaskCardList: (db: Database.Database, sessionId: string, list: string) =>
128
- db
129
- .prepare(`UPDATE sessions SET lh_card_list = ? WHERE id = ?`)
130
- .run(list, sessionId),
129
+ setTask(db, sessionId, { lh_card_list: list }),
131
130
 
132
131
  linkedCardIds: (db: Database.Database, boardId: string) =>
133
132
  new Set(
134
133
  (
135
134
  db
136
135
  .prepare(
137
- `SELECT lh_card_id FROM sessions WHERE lh_board_id = ? AND lh_card_id IS NOT NULL`
136
+ `SELECT lh_card_id FROM session_rows WHERE lh_board_id = ? AND lh_card_id IS NOT NULL`
138
137
  )
139
138
  .all(boardId) as { lh_card_id: string }[]
140
139
  ).map((r) => r.lh_card_id)
@@ -0,0 +1,12 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import type Database from "better-sqlite3";
3
+
4
+ /**
5
+ * Tests only: runs a function against another database, so one process can
6
+ * stand in for two machines. `getDb()` (and so `db`) follows the scope; in
7
+ * production nothing is ever scoped and it returns the one database.
8
+ */
9
+ export const machineDb = new AsyncLocalStorage<Database.Database>();
10
+
11
+ export const runOnMachine = <T>(database: Database.Database, fn: () => T): T =>
12
+ machineDb.run(database, fn);
@@ -0,0 +1,172 @@
1
+ import type { Migration } from "../migration-types";
2
+ import type { PartTable } from "../session-parts/columns";
3
+ import { createSessionRowsView } from "../session-parts/view";
4
+
5
+ // The split of sessions into focused tables, as it was when this shipped:
6
+ // frozen here, so changing the live split never changes what this did.
7
+ const PARTS: Record<string, { columns: [string, string][] }> = {
8
+ session_tasks: {
9
+ columns: [
10
+ ["task_prompt", "TEXT"],
11
+ ["task_brief", "TEXT"],
12
+ ["task_status", "TEXT"],
13
+ ["task_since", "TEXT"],
14
+ ["task_ended_at", "TEXT"],
15
+ ["merged_at", "TEXT"],
16
+ ["priority", "TEXT"],
17
+ ["quick_of", "TEXT"],
18
+ ["moved_to", "TEXT"],
19
+ ["moved_from", "TEXT"],
20
+ ["model_overrides", "TEXT"],
21
+ ["lh_card_id", "TEXT"],
22
+ ["lh_board_id", "TEXT"],
23
+ ["lh_card_list", "TEXT"],
24
+ ],
25
+ },
26
+ session_prs: {
27
+ columns: [
28
+ ["pr_url", "TEXT"],
29
+ ["pr_number", "INTEGER"],
30
+ ["pr_status", "TEXT"],
31
+ ["merge_armed_at", "TEXT"],
32
+ ["merge_armed_seen", "TEXT"],
33
+ ["rebased_from", "TEXT"],
34
+ ["rebased_to", "TEXT"],
35
+ ["rebase_asked_head", "TEXT"],
36
+ ["pr_wake_since", "TEXT"],
37
+ ],
38
+ },
39
+ session_plans: {
40
+ columns: [
41
+ ["plan_phase", "TEXT"],
42
+ ["plan_only", "INTEGER"],
43
+ ["plan_decided", "INTEGER"],
44
+ ["plan_reason", "TEXT"],
45
+ ["plan_note", "TEXT"],
46
+ ["plan_edits", "TEXT"],
47
+ ["plan_waits_for_caller", "INTEGER"],
48
+ ["plan_handoff_message", "TEXT"],
49
+ ],
50
+ },
51
+ session_placement: {
52
+ columns: [
53
+ ["peer_mirror", "INTEGER NOT NULL DEFAULT 0"],
54
+ ["placed_note", "TEXT"],
55
+ ["held_by_device", "TEXT"],
56
+ ["held_from", "TEXT"],
57
+ ["held_for", "TEXT"],
58
+ ["held_for_name", "TEXT"],
59
+ ["started_by_device", "TEXT"],
60
+ ],
61
+ },
62
+ session_pauses: {
63
+ columns: [
64
+ ["pause_state", "TEXT"],
65
+ ["paused_until", "TEXT"],
66
+ ["pause_id", "INTEGER"],
67
+ ["pause_note", "TEXT"],
68
+ ],
69
+ },
70
+ };
71
+
72
+ const hasValue = (prefix: string, column: string) =>
73
+ column === "peer_mirror"
74
+ ? `COALESCE(${prefix}.${column}, 0) != 0`
75
+ : `${prefix}.${column} IS NOT NULL`;
76
+
77
+ export default {
78
+ key: "20261015T1800_split_sessions_tables",
79
+ up: (db) => {
80
+ // Additive: five tables, each keyed by the session and holding a row only
81
+ // for a session with a value in its group; the session_rows view that
82
+ // reads them back as one flat row; and triggers that keep a write to a
83
+ // legacy sessions column (an older build, a chat worker that has not
84
+ // restarted) landing in the new tables. No sessions column is touched.
85
+ // Writers in this build go through lib/db/session-parts. Dropping the old
86
+ // columns is a later step (docs/decisions/2026-10-15-split-sessions-table.md).
87
+ db.transaction(() => {
88
+ for (const [table, { columns }] of Object.entries(PARTS)) {
89
+ const names = columns.map(([name]) => name);
90
+
91
+ db.exec(
92
+ `CREATE TABLE IF NOT EXISTS ${table} (
93
+ session_id TEXT PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE ON UPDATE CASCADE,
94
+ ${columns.map(([n, type]) => `${n} ${type}`).join(",\n ")}
95
+ )`
96
+ );
97
+
98
+ const anyValue = (prefix: string) =>
99
+ names.map((n) => hasValue(prefix, n)).join(" OR ");
100
+ db.exec(
101
+ `INSERT OR IGNORE INTO ${table} (session_id, ${names.join(", ")})
102
+ SELECT s.id, ${names.map((n) => `s.${n}`).join(", ")}
103
+ FROM sessions s WHERE ${anyValue("s")}`
104
+ );
105
+
106
+ db.exec(
107
+ `CREATE TRIGGER IF NOT EXISTS ${table}_from_sessions_ins
108
+ AFTER INSERT ON sessions
109
+ WHEN ${anyValue("NEW")}
110
+ BEGIN
111
+ INSERT OR REPLACE INTO ${table} (session_id, ${names.join(", ")})
112
+ VALUES (NEW.id, ${names.map((n) => `NEW.${n}`).join(", ")});
113
+ END`
114
+ );
115
+ db.exec(
116
+ `CREATE TRIGGER IF NOT EXISTS ${table}_from_sessions_upd
117
+ AFTER UPDATE OF ${names.join(", ")} ON sessions
118
+ WHEN ${names.map((n) => `NEW.${n} IS NOT OLD.${n}`).join(" OR ")}
119
+ BEGIN
120
+ INSERT INTO ${table} (session_id, ${names.join(", ")})
121
+ VALUES (NEW.id, ${names.map((n) => `NEW.${n}`).join(", ")})
122
+ ON CONFLICT(session_id) DO UPDATE SET
123
+ ${names
124
+ .map(
125
+ (n) =>
126
+ `${n} = CASE WHEN NEW.${n} IS NOT OLD.${n} THEN excluded.${n} ELSE ${table}.${n} END`
127
+ )
128
+ .join(",\n ")};
129
+ END`
130
+ );
131
+ db.exec(
132
+ `CREATE TRIGGER IF NOT EXISTS ${table}_from_sessions_del
133
+ AFTER DELETE ON sessions
134
+ BEGIN
135
+ DELETE FROM ${table} WHERE session_id = OLD.id;
136
+ END`
137
+ );
138
+
139
+ for (const [suffix, event] of [
140
+ ["ins", "INSERT"],
141
+ ["upd", "UPDATE"],
142
+ ["del", "DELETE"],
143
+ ])
144
+ db.exec(
145
+ `CREATE TRIGGER IF NOT EXISTS ${table}_bump_sessions_${suffix}
146
+ AFTER ${event} ON ${table}
147
+ BEGIN
148
+ INSERT INTO change_versions (topic, version) VALUES ('sessions', 1)
149
+ ON CONFLICT(topic) DO UPDATE SET version = version + 1;
150
+ END`
151
+ );
152
+ }
153
+
154
+ db.exec(
155
+ `CREATE INDEX IF NOT EXISTS idx_session_tasks_status ON session_tasks(task_status)`
156
+ );
157
+ db.exec(
158
+ `CREATE INDEX IF NOT EXISTS idx_session_tasks_moved_from ON session_tasks(moved_from)`
159
+ );
160
+ db.exec(
161
+ `CREATE INDEX IF NOT EXISTS idx_session_tasks_quick_of ON session_tasks(quick_of) WHERE quick_of IS NOT NULL`
162
+ );
163
+
164
+ createSessionRowsView(
165
+ db,
166
+ Object.fromEntries(
167
+ Object.entries(PARTS).map(([t, p]) => [t, p.columns.map(([n]) => n)])
168
+ ) as Record<PartTable, string[]>
169
+ );
170
+ })();
171
+ },
172
+ } satisfies Migration;
@@ -33,3 +33,10 @@ export default {
33
33
  `lib/db/migrations.ts` loads. `lib/db/migrations.test.ts` also checks order, the
34
34
  legacy bodies, and that a database migrated before the split would run nothing
35
35
  again.
36
+ - Adding a column to `sessions`? `session_rows` (the view that reads a session
37
+ with its columns from the tables they moved to) lists the columns at the time
38
+ it was built. Add the name to `SESSION_ROW_COLUMNS` in
39
+ `lib/db/session-parts/columns.ts` and call `createSessionRowsView(db)` after
40
+ the `ALTER`; `lib/db/split-sessions-migration.test.ts` fails if the view and
41
+ the table disagree. Columns that belong to a group go in `PART_COLUMNS` and a
42
+ new migration for that table instead.
package/lib/db/queries.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type Database from "better-sqlite3";
2
+ import { SESSION_ROW_COLUMNS } from "./session-parts/columns";
2
3
 
3
4
  // Prepared statement cache
4
5
  const stmtCache = new Map<string, Database.Statement>();
@@ -13,28 +14,18 @@ function getStmt(db: Database.Database, sql: string): Database.Statement {
13
14
  return stmt;
14
15
  }
15
16
 
16
- // Every sessions column but the two large text ones, which only the single
17
+ // Every session column but the two large text ones, which only the single
17
18
  // session paths read; a list gets has_brief for whether a brief exists.
18
- const listColumns = new WeakMap<Database.Database, string>();
19
- export function sessionListColumns(db: Database.Database, alias = ""): string {
20
- let cols = listColumns.get(db);
21
- if (!cols) {
22
- cols = (
23
- db.prepare(`PRAGMA table_info(sessions)`).all() as { name: string }[]
24
- )
25
- .map((c) => c.name)
26
- .filter((n) => n !== "task_brief" && n !== "system_prompt")
27
- .join(",");
28
- listColumns.set(db, cols);
29
- }
19
+ // Read from session_rows (lib/db/session-parts).
20
+ const LIST_COLUMNS = SESSION_ROW_COLUMNS.filter(
21
+ (n) => n !== "task_brief" && n !== "system_prompt"
22
+ );
23
+ export function sessionListColumns(_db: Database.Database, alias = ""): string {
30
24
  const prefix = alias ? `${alias}.` : "";
31
- const list = cols
32
- .split(",")
33
- .map((c) => `${prefix}${c}`)
34
- .join(", ");
25
+ const list = LIST_COLUMNS.map((c) => `${prefix}${c}`).join(", ");
35
26
  // The recipe run or feature piece a session is, as JSON, for its row's chip.
36
- const recipe = `(SELECT json_object('id', r.id, 'name', r.name, 'seq', rr.seq, 'status', rr.status) FROM recipe_runs rr JOIN recipes r ON r.id = rr.recipe_id WHERE rr.session_id = ${alias || "sessions"}.id)`;
37
- const piece = `(SELECT json_object('feature', f.id, 'name', f.name, 'position', fp.position, 'total', (SELECT COUNT(*) FROM feature_pieces WHERE feature_id = f.id AND status != 'dropped')) FROM feature_pieces fp JOIN features f ON f.id = fp.feature_id WHERE fp.session_id = ${alias || "sessions"}.id)`;
27
+ const recipe = `(SELECT json_object('id', r.id, 'name', r.name, 'seq', rr.seq, 'status', rr.status) FROM recipe_runs rr JOIN recipes r ON r.id = rr.recipe_id WHERE rr.session_id = ${alias || "session_rows"}.id)`;
28
+ const piece = `(SELECT json_object('feature', f.id, 'name', f.name, 'position', fp.position, 'total', (SELECT COUNT(*) FROM feature_pieces WHERE feature_id = f.id AND status != 'dropped')) FROM feature_pieces fp JOIN features f ON f.id = fp.feature_id WHERE fp.session_id = ${alias || "session_rows"}.id)`;
38
29
  return `${list}, (${prefix}task_brief IS NOT NULL AND ${prefix}task_brief != '') AS has_brief, ${recipe} AS recipe_run, ${piece} AS feature_piece`;
39
30
  }
40
31
 
@@ -48,12 +39,12 @@ export const queries = {
48
39
  ),
49
40
 
50
41
  getSession: (db: Database.Database) =>
51
- getStmt(db, `SELECT * FROM sessions WHERE id = ?`),
42
+ getStmt(db, `SELECT * FROM session_rows WHERE id = ?`),
52
43
 
53
44
  getAllSessions: (db: Database.Database) =>
54
45
  getStmt(
55
46
  db,
56
- `SELECT ${sessionListColumns(db)} FROM sessions ORDER BY updated_at DESC`
47
+ `SELECT ${sessionListColumns(db)} FROM session_rows ORDER BY updated_at DESC`
57
48
  ),
58
49
 
59
50
  // The sidebar's sessions: not archived, and not a dropped task (a
@@ -62,7 +53,7 @@ export const queries = {
62
53
  getSidebarSessions: (db: Database.Database) =>
63
54
  getStmt(
64
55
  db,
65
- `SELECT ${sessionListColumns(db)} FROM sessions
56
+ `SELECT ${sessionListColumns(db)} FROM session_rows
66
57
  WHERE archived_at IS NULL
67
58
  AND (task_status IS NULL
68
59
  OR task_status NOT IN ('dropped', 'moved'))
@@ -103,12 +94,6 @@ export const queries = {
103
94
  `UPDATE sessions SET worktree_path = ?, branch_name = ?, base_branch = ?, dev_server_port = ?, updated_at = datetime('now') WHERE id = ?`
104
95
  ),
105
96
 
106
- updateSessionPR: (db: Database.Database) =>
107
- getStmt(
108
- db,
109
- `UPDATE sessions SET pr_url = ?, pr_number = ?, pr_status = ?, updated_at = datetime('now') WHERE id = ?`
110
- ),
111
-
112
97
  updateSessionGroup: (db: Database.Database) =>
113
98
  getStmt(
114
99
  db,
@@ -118,7 +103,7 @@ export const queries = {
118
103
  getSessionsByGroup: (db: Database.Database) =>
119
104
  getStmt(
120
105
  db,
121
- `SELECT * FROM sessions WHERE group_path = ? ORDER BY updated_at DESC`
106
+ `SELECT * FROM session_rows WHERE group_path = ? ORDER BY updated_at DESC`
122
107
  ),
123
108
 
124
109
  moveSessionsToGroup: (db: Database.Database) =>
@@ -136,14 +121,14 @@ export const queries = {
136
121
  getSessionsByProject: (db: Database.Database) =>
137
122
  getStmt(
138
123
  db,
139
- `SELECT * FROM sessions WHERE project_id = ? ORDER BY updated_at DESC`
124
+ `SELECT * FROM session_rows WHERE project_id = ? ORDER BY updated_at DESC`
140
125
  ),
141
126
 
142
127
  // Orchestration
143
128
  getWorkersByConductor: (db: Database.Database) =>
144
129
  getStmt(
145
130
  db,
146
- `SELECT * FROM sessions WHERE conductor_session_id = ? ORDER BY created_at ASC`
131
+ `SELECT * FROM session_rows WHERE conductor_session_id = ? ORDER BY created_at ASC`
147
132
  ),
148
133
 
149
134
  updateWorkerStatus: (db: Database.Database) =>
@@ -0,0 +1,186 @@
1
+ import type { Session } from "@/lib/db/types";
2
+
3
+ // The sessions columns that live in their own tables, by concern. Each table
4
+ // is keyed by session_id and holds a row only for a session that has a value
5
+ // in the group; `session_rows` joins them back into the flat shape of a
6
+ // session. See docs/decisions/2026-10-15-split-sessions-table.md.
7
+ export const PART_COLUMNS = {
8
+ session_tasks: [
9
+ "task_prompt",
10
+ "task_brief",
11
+ "task_status",
12
+ "task_since",
13
+ "task_ended_at",
14
+ "merged_at",
15
+ "priority",
16
+ "quick_of",
17
+ "moved_to",
18
+ "moved_from",
19
+ "model_overrides",
20
+ "lh_card_id",
21
+ "lh_board_id",
22
+ "lh_card_list",
23
+ ],
24
+ session_prs: [
25
+ "pr_url",
26
+ "pr_number",
27
+ "pr_status",
28
+ "merge_armed_at",
29
+ "merge_armed_seen",
30
+ "rebased_from",
31
+ "rebased_to",
32
+ "rebase_asked_head",
33
+ "pr_wake_since",
34
+ ],
35
+ session_plans: [
36
+ "plan_phase",
37
+ "plan_only",
38
+ "plan_decided",
39
+ "plan_reason",
40
+ "plan_note",
41
+ "plan_edits",
42
+ "plan_waits_for_caller",
43
+ "plan_handoff_message",
44
+ ],
45
+ session_placement: [
46
+ "peer_mirror",
47
+ "placed_note",
48
+ "held_by_device",
49
+ "held_from",
50
+ "held_for",
51
+ "held_for_name",
52
+ "started_by_device",
53
+ ],
54
+ session_pauses: ["pause_state", "paused_until", "pause_id", "pause_note"],
55
+ } as const satisfies Record<string, readonly (keyof Session)[]>;
56
+
57
+ export type PartTable = keyof typeof PART_COLUMNS;
58
+ export type PartColumn<T extends PartTable> = (typeof PART_COLUMNS)[T][number];
59
+ export type PartPatch<T extends PartTable> = {
60
+ [C in PartColumn<T>]?: Session[C & keyof Session];
61
+ };
62
+
63
+ export const PART_TABLES = Object.keys(PART_COLUMNS) as PartTable[];
64
+
65
+ // Everything else is an INTEGER or TEXT by this list.
66
+ export const INTEGER_COLUMNS: ReadonlySet<string> = new Set([
67
+ "pr_number",
68
+ "plan_only",
69
+ "plan_decided",
70
+ "plan_waits_for_caller",
71
+ "peer_mirror",
72
+ "pause_id",
73
+ ]);
74
+
75
+ const partOfColumn = new Map<string, PartTable>(
76
+ PART_TABLES.flatMap((t) =>
77
+ (PART_COLUMNS[t] as readonly string[]).map((c) => [c, t] as const)
78
+ )
79
+ );
80
+
81
+ export const isMovedColumn = (column: string): boolean =>
82
+ partOfColumn.has(column);
83
+
84
+ export const partOf = (column: string): PartTable | undefined =>
85
+ partOfColumn.get(column);
86
+
87
+ // Every column of the sessions table, in order: the shape of a session row
88
+ // and of `session_rows`. A migration that adds a sessions column adds it here
89
+ // and rebuilds the view (createSessionRowsView).
90
+ export const SESSION_ROW_COLUMNS: readonly string[] = [
91
+ "id",
92
+ "name",
93
+ "created_at",
94
+ "updated_at",
95
+ "status",
96
+ "working_directory",
97
+ "parent_session_id",
98
+ "claude_session_id",
99
+ "model",
100
+ "system_prompt",
101
+ "group_path",
102
+ "agent_type",
103
+ "worktree_path",
104
+ "branch_name",
105
+ "base_branch",
106
+ "dev_server_port",
107
+ "pr_url",
108
+ "pr_number",
109
+ "pr_status",
110
+ "conductor_session_id",
111
+ "worker_task",
112
+ "worker_status",
113
+ "auto_approve",
114
+ "project_id",
115
+ "tmux_name",
116
+ "host_id",
117
+ "task_prompt",
118
+ "task_status",
119
+ "view",
120
+ "lh_card_id",
121
+ "lh_board_id",
122
+ "lh_card_list",
123
+ "chat_access",
124
+ "chat_resume_at",
125
+ "last_seen_at",
126
+ "role",
127
+ "workspace_id",
128
+ "orch_token",
129
+ "archived_at",
130
+ "pinned",
131
+ "chat_suggestion",
132
+ "chat_plan",
133
+ "chat_context",
134
+ "chat_usage",
135
+ "name_source",
136
+ "setup_status",
137
+ "setup_ms",
138
+ "setup_error",
139
+ "task_brief",
140
+ "moved_to",
141
+ "moved_from",
142
+ "port_slot",
143
+ "ports",
144
+ "database",
145
+ "peer_mirror",
146
+ "chat_restarted",
147
+ "chat_tools_digest",
148
+ "merge_armed_at",
149
+ "merge_armed_seen",
150
+ "rebased_from",
151
+ "rebased_to",
152
+ "rebase_asked_head",
153
+ "chat_refreshed",
154
+ "pr_wake_since",
155
+ "merged_at",
156
+ "task_ended_at",
157
+ "model_provider",
158
+ "model_overrides",
159
+ "plan_phase",
160
+ "quick_of",
161
+ "started_by",
162
+ "plan_decided",
163
+ "plan_reason",
164
+ "task_since",
165
+ "held_by_device",
166
+ "held_from",
167
+ "plan_note",
168
+ "held_for",
169
+ "held_for_name",
170
+ "plan_only",
171
+ "plan_edits",
172
+ "active_at",
173
+ "settled_at",
174
+ "snoozed_until",
175
+ "snoozed_at",
176
+ "priority",
177
+ "pause_state",
178
+ "paused_until",
179
+ "pause_id",
180
+ "placed_note",
181
+ "started_by_device",
182
+ "pause_note",
183
+ "started_via",
184
+ "plan_waits_for_caller",
185
+ "plan_handoff_message",
186
+ ];
@@ -0,0 +1,33 @@
1
+ import type Database from "better-sqlite3";
2
+ import { partOf, type PartTable } from "./columns";
3
+ import { writePart } from "./write";
4
+
5
+ // Copies values onto a session's moved columns where it has none yet: a
6
+ // column that already holds a value, and a null to copy, are left alone.
7
+ // Names that aren't moved columns are the caller's to write.
8
+ export function fillMissing(
9
+ db: Database.Database,
10
+ sessionId: string,
11
+ values: Record<string, string | number | null | undefined>
12
+ ): void {
13
+ const byTable = new Map<PartTable, string[]>();
14
+ for (const [column, value] of Object.entries(values)) {
15
+ const table = partOf(column);
16
+ if (!table || value === null || value === undefined) continue;
17
+ byTable.set(table, [...(byTable.get(table) ?? []), column]);
18
+ }
19
+ db.transaction(() => {
20
+ for (const [table, columns] of byTable) {
21
+ const current = db
22
+ .prepare(`SELECT ${columns.join(", ")} FROM session_rows WHERE id = ?`)
23
+ .get(sessionId) as Record<string, unknown> | undefined;
24
+ if (!current) continue;
25
+ const patch = Object.fromEntries(
26
+ columns
27
+ .filter((c) => current[c] === null)
28
+ .map((c) => [c, values[c]] as const)
29
+ );
30
+ writePart(db, table, sessionId, patch as never);
31
+ }
32
+ })();
33
+ }
@@ -0,0 +1,9 @@
1
+ export * from "./columns";
2
+ export { createSessionRowsView } from "./view";
3
+ export { writePart, type When } from "./write";
4
+ export { setTask, startTaskClock } from "./tasks";
5
+ export { setPr, recordPr } from "./prs";
6
+ export { setPlan, advancePlan } from "./plans";
7
+ export { setPlacement } from "./placement";
8
+ export { setPause } from "./pauses";
9
+ export { fillMissing } from "./fill";
@@ -0,0 +1,11 @@
1
+ import type Database from "better-sqlite3";
2
+ import type { PartPatch } from "./columns";
3
+ import { writePart, type When } from "./write";
4
+
5
+ // A task's timed pause.
6
+ export const setPause = (
7
+ db: Database.Database,
8
+ sessionId: string,
9
+ patch: PartPatch<"session_pauses">,
10
+ when?: When<"session_pauses">
11
+ ): boolean => writePart(db, "session_pauses", sessionId, patch, when);
@@ -0,0 +1,11 @@
1
+ import type Database from "better-sqlite3";
2
+ import type { PartPatch } from "./columns";
3
+ import { writePart, type When } from "./write";
4
+
5
+ // Where a task came from and who holds it: mirror, holder, starting device.
6
+ export const setPlacement = (
7
+ db: Database.Database,
8
+ sessionId: string,
9
+ patch: PartPatch<"session_placement">,
10
+ when?: When<"session_placement">
11
+ ): boolean => writePart(db, "session_placement", sessionId, patch, when);