@saadnvd1/agent-os 0.3.631 → 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 (177) 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 +5 -1
  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 -4
  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 +35 -0
  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 +5 -2
  23. package/app/globals.css +28 -0
  24. package/components/Chat/ChatPanel.tsx +2 -11
  25. package/components/Chat/EventTurns.tsx +1 -7
  26. package/components/Chat/Markdown.tsx +1 -0
  27. package/components/Chat/Messages.tsx +1 -1
  28. package/lib/approvals/enable.ts +9 -2
  29. package/lib/away/report.ts +4 -4
  30. package/lib/away/run.ts +2 -2
  31. package/lib/bookmarks/store.ts +3 -3
  32. package/lib/bus/held-reply.ts +2 -2
  33. package/lib/bus/index.ts +3 -3
  34. package/lib/chat/compact-context.ts +1 -1
  35. package/lib/chat/events.ts +4 -0
  36. package/lib/chat/group.ts +3 -1
  37. package/lib/chat/hold.ts +3 -3
  38. package/lib/chat/reading-order.ts +50 -0
  39. package/lib/chat/refresh.ts +1 -1
  40. package/lib/chat/registry.ts +3 -3
  41. package/lib/chat/runner.ts +1 -1
  42. package/lib/chat/turns.ts +22 -3
  43. package/lib/chat/worker/host.ts +3 -0
  44. package/lib/chat/worker/main.ts +1 -1
  45. package/lib/db/index.ts +5 -1
  46. package/lib/db/lumifyhub.ts +8 -9
  47. package/lib/db/machine-scope.ts +12 -0
  48. package/lib/db/migrations/20261015T1600_plan_waits_for_caller.ts +19 -0
  49. package/lib/db/migrations/20261015T1800_split_sessions_tables.ts +172 -0
  50. package/lib/db/migrations/README.md +7 -0
  51. package/lib/db/queries.ts +16 -31
  52. package/lib/db/session-parts/columns.ts +186 -0
  53. package/lib/db/session-parts/fill.ts +33 -0
  54. package/lib/db/session-parts/index.ts +9 -0
  55. package/lib/db/session-parts/pauses.ts +11 -0
  56. package/lib/db/session-parts/placement.ts +11 -0
  57. package/lib/db/session-parts/plans.ts +31 -0
  58. package/lib/db/session-parts/prs.ts +31 -0
  59. package/lib/db/session-parts/tasks.ts +27 -0
  60. package/lib/db/session-parts/view.ts +43 -0
  61. package/lib/db/session-parts/write.ts +69 -0
  62. package/lib/db/types.ts +6 -1
  63. package/lib/done/archive.ts +2 -2
  64. package/lib/done/auto-archive.ts +1 -1
  65. package/lib/done/bulk.ts +1 -1
  66. package/lib/done/index.ts +8 -14
  67. package/lib/env-setup.ts +17 -4
  68. package/lib/health/evidence.ts +1 -1
  69. package/lib/health/facts.ts +1 -1
  70. package/lib/health/pieces.ts +1 -1
  71. package/lib/hosts/peer-actions.ts +2 -2
  72. package/lib/hosts/peer-mirror.ts +2 -2
  73. package/lib/hosts/peer-sync.ts +69 -32
  74. package/lib/learning-cards/input.ts +1 -1
  75. package/lib/learning-cards/store.ts +1 -1
  76. package/lib/models/plan-step.ts +83 -46
  77. package/lib/models/resume.ts +5 -7
  78. package/lib/notify/index.ts +1 -1
  79. package/lib/notify/push/events.ts +1 -1
  80. package/lib/notify/push/index.ts +1 -1
  81. package/lib/orchestrator/act.ts +1 -1
  82. package/lib/orchestrator/armed.ts +17 -15
  83. package/lib/orchestrator/ask-approvals.ts +1 -1
  84. package/lib/orchestrator/ask-heads.ts +1 -1
  85. package/lib/orchestrator/ask-settle.ts +1 -1
  86. package/lib/orchestrator/brakes.ts +1 -1
  87. package/lib/orchestrator/digest.ts +2 -2
  88. package/lib/orchestrator/external-pr.ts +2 -2
  89. package/lib/orchestrator/facts.ts +4 -28
  90. package/lib/orchestrator/held-asks.ts +3 -3
  91. package/lib/orchestrator/home.ts +2 -2
  92. package/lib/orchestrator/overview.ts +1 -1
  93. package/lib/orchestrator/pr-wake.ts +3 -5
  94. package/lib/orchestrator/questions.ts +1 -1
  95. package/lib/orchestrator/quick-store.ts +1 -1
  96. package/lib/orchestrator/quick.ts +10 -7
  97. package/lib/orchestrator/read.ts +1 -1
  98. package/lib/orchestrator/rebase.ts +8 -11
  99. package/lib/orchestrator/reconcile.ts +2 -2
  100. package/lib/orchestrator/signoff.ts +3 -1
  101. package/lib/orchestrator/targets.ts +1 -1
  102. package/lib/orchestrator/task-state.ts +4 -2
  103. package/lib/plans/features.ts +1 -1
  104. package/lib/plans/release.ts +1 -1
  105. package/lib/ports.ts +1 -1
  106. package/lib/resurrect/find.ts +1 -1
  107. package/lib/resurrect/index.ts +24 -18
  108. package/lib/schedules/store.ts +1 -1
  109. package/lib/session-audit/sources.ts +3 -3
  110. package/lib/session-logs/index.ts +1 -1
  111. package/lib/session-titles.ts +1 -1
  112. package/lib/sessions/setup-progress.ts +1 -1
  113. package/lib/sessions/stop.ts +1 -1
  114. package/lib/sessions/worktree-setup.ts +2 -1
  115. package/lib/simulator/index.ts +1 -1
  116. package/lib/simulator/task-devices.ts +1 -1
  117. package/lib/stacks/board.ts +1 -1
  118. package/lib/stacks/land.ts +1 -1
  119. package/lib/stacks/restack.ts +1 -1
  120. package/lib/stacks/start.ts +2 -2
  121. package/lib/stacks/tick.ts +1 -1
  122. package/lib/stacks/view.ts +1 -1
  123. package/lib/status/incremental.ts +1 -1
  124. package/lib/tasks/contract/actions.ts +35 -0
  125. package/lib/tasks/contract/authorize.ts +82 -0
  126. package/lib/tasks/contract/client.ts +296 -0
  127. package/lib/tasks/{held-remote.ts → contract/held-calls.ts} +35 -66
  128. package/lib/tasks/contract/index.ts +28 -0
  129. package/lib/tasks/contract/plan-path.ts +7 -0
  130. package/lib/tasks/contract/plan.ts +274 -0
  131. package/lib/tasks/contract/routes.ts +26 -0
  132. package/lib/tasks/contract/start.ts +122 -0
  133. package/lib/tasks/contract/sync.ts +126 -0
  134. package/lib/tasks/contract/types.ts +41 -0
  135. package/lib/tasks/{remote-views.ts → contract/views.ts} +11 -18
  136. package/lib/tasks/finish.ts +10 -9
  137. package/lib/tasks/held-replies.ts +7 -13
  138. package/lib/tasks/held.ts +36 -17
  139. package/lib/tasks/hold-checks.ts +134 -0
  140. package/lib/tasks/holds.ts +281 -0
  141. package/lib/tasks/import.ts +26 -14
  142. package/lib/tasks/index.ts +56 -22
  143. package/lib/tasks/launch-gate.ts +1 -1
  144. package/lib/tasks/move-bundle.ts +17 -2
  145. package/lib/tasks/move-carry.ts +15 -7
  146. package/lib/tasks/move-flow.ts +29 -36
  147. package/lib/tasks/move-guard.ts +10 -2
  148. package/lib/tasks/move-recover.ts +6 -25
  149. package/lib/tasks/move.ts +28 -27
  150. package/lib/tasks/moved-away.ts +1 -1
  151. package/lib/tasks/pause-hold.ts +13 -5
  152. package/lib/tasks/pause.ts +21 -54
  153. package/lib/tasks/placement.ts +16 -16
  154. package/lib/tasks/plan-view.ts +11 -3
  155. package/lib/tasks/queue-candidates.ts +151 -0
  156. package/lib/tasks/queue-placement.ts +155 -0
  157. package/lib/tasks/queue-rows.ts +236 -0
  158. package/lib/tasks/queue-start.ts +213 -0
  159. package/lib/tasks/queue-tick.ts +148 -0
  160. package/lib/tasks/queue-types.ts +141 -0
  161. package/lib/tasks/queue-view.ts +112 -0
  162. package/lib/tasks/queue.ts +67 -1102
  163. package/lib/tasks/schedule.ts +44 -147
  164. package/lib/tasks/session.ts +7 -4
  165. package/lib/tasks/start.ts +4 -4
  166. package/lib/tasks/starter.ts +8 -11
  167. package/lib/usage/calls.ts +1 -1
  168. package/lib/usage/compare.ts +1 -1
  169. package/lib/usage/quality.ts +1 -1
  170. package/lib/usage/turns.ts +1 -1
  171. package/lib/voice/vocab.ts +1 -1
  172. package/package.json +1 -1
  173. package/scripts/backfill-session-logs.ts +1 -1
  174. package/scripts/check +55 -7
  175. package/lib/tasks/auto-hold.ts +0 -17
  176. package/lib/tasks/held-routes.ts +0 -33
  177. package/lib/tasks/remote.ts +0 -238
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);
@@ -0,0 +1,31 @@
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 plan step: phase, note, edits and the hand-off.
6
+ export const setPlan = (
7
+ db: Database.Database,
8
+ sessionId: string,
9
+ patch: PartPatch<"session_plans">,
10
+ when?: When<"session_plans">
11
+ ): boolean => writePart(db, "session_plans", sessionId, patch, when);
12
+
13
+ // Moves a plan from one phase to the next, only while the task is running
14
+ // and still in `from` (a step another caller took first returns false).
15
+ export function advancePlan(
16
+ db: Database.Database,
17
+ sessionId: string,
18
+ from: string,
19
+ to: NonNullable<PartPatch<"session_plans">["plan_phase"]>
20
+ ): boolean {
21
+ return db.transaction(() => {
22
+ const running = db
23
+ .prepare(
24
+ `SELECT 1 FROM session_rows WHERE id = ? AND task_status = 'running'`
25
+ )
26
+ .get(sessionId);
27
+ return running
28
+ ? setPlan(db, sessionId, { plan_phase: to }, { plan_phase: from })
29
+ : false;
30
+ })();
31
+ }
@@ -0,0 +1,31 @@
1
+ import type Database from "better-sqlite3";
2
+ import type { Session } from "@/lib/db/types";
3
+ import type { PartPatch } from "./columns";
4
+ import { writePart, type When } from "./write";
5
+
6
+ // A session's PR and the merge and rebase state kept for it.
7
+ export const setPr = (
8
+ db: Database.Database,
9
+ sessionId: string,
10
+ patch: PartPatch<"session_prs">,
11
+ when?: When<"session_prs">
12
+ ): boolean => writePart(db, "session_prs", sessionId, patch, when);
13
+
14
+ // A PR found for the session: its link, number and state, and the session
15
+ // counts as touched.
16
+ export function recordPr(
17
+ db: Database.Database,
18
+ sessionId: string,
19
+ pr: { url: string; number: number; state: string }
20
+ ): void {
21
+ db.transaction(() => {
22
+ setPr(db, sessionId, {
23
+ pr_url: pr.url,
24
+ pr_number: pr.number,
25
+ pr_status: pr.state as Session["pr_status"],
26
+ });
27
+ db.prepare(
28
+ `UPDATE sessions SET updated_at = datetime('now') WHERE id = ?`
29
+ ).run(sessionId);
30
+ })();
31
+ }
@@ -0,0 +1,27 @@
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 brief, status, priority, models and card: lib/db/session-parts/columns.ts.
6
+ export const setTask = (
7
+ db: Database.Database,
8
+ sessionId: string,
9
+ patch: PartPatch<"session_tasks">,
10
+ when?: When<"session_tasks">
11
+ ): boolean => writePart(db, "session_tasks", sessionId, patch, when);
12
+
13
+ // The task's first start, kept across moves: set once, from created_at when
14
+ // the caller has no better time.
15
+ export function startTaskClock(
16
+ db: Database.Database,
17
+ sessionId: string,
18
+ at?: string | null
19
+ ): void {
20
+ const row = db
21
+ .prepare(`SELECT created_at, task_since FROM session_rows WHERE id = ?`)
22
+ .get(sessionId) as
23
+ | { created_at: string; task_since: string | null }
24
+ | undefined;
25
+ if (!row || row.task_since) return;
26
+ setTask(db, sessionId, { task_since: at ?? row.created_at });
27
+ }
@@ -0,0 +1,43 @@
1
+ import type Database from "better-sqlite3";
2
+ import { PART_COLUMNS, type PartTable } from "./columns";
3
+
4
+ const ALIAS: Record<PartTable, string> = {
5
+ session_tasks: "t",
6
+ session_prs: "pr",
7
+ session_plans: "pl",
8
+ session_placement: "pp",
9
+ session_pauses: "pa",
10
+ };
11
+
12
+ // `session_rows`: the sessions table with each moved column read from its own
13
+ // table, under the same names and in the same order, so a row is the flat
14
+ // session it always was. A column with a default on sessions
15
+ // (peer_mirror) keeps it. Rebuild it in any migration that adds a sessions
16
+ // column. `parts` is the split as that migration knew it.
17
+ export function createSessionRowsView(
18
+ db: Database.Database,
19
+ parts: Record<PartTable, readonly string[]> = PART_COLUMNS
20
+ ): void {
21
+ const source = new Map<string, string>();
22
+ for (const table of Object.keys(parts) as PartTable[])
23
+ for (const c of parts[table]) source.set(c, ALIAS[table]);
24
+ const sessionColumns = (
25
+ db.prepare(`PRAGMA table_info(sessions)`).all() as { name: string }[]
26
+ ).map((c) => c.name);
27
+ const select = sessionColumns
28
+ .map((c) => {
29
+ const alias = source.get(c);
30
+ if (!alias) return `s.${c}`;
31
+ return c === "peer_mirror"
32
+ ? `COALESCE(${alias}.${c}, 0) AS ${c}`
33
+ : `${alias}.${c} AS ${c}`;
34
+ })
35
+ .join(", ");
36
+ const joins = (Object.keys(parts) as PartTable[])
37
+ .map((t) => `LEFT JOIN ${t} ${ALIAS[t]} ON ${ALIAS[t]}.session_id = s.id`)
38
+ .join(" ");
39
+ db.exec(`DROP VIEW IF EXISTS session_rows`);
40
+ db.exec(
41
+ `CREATE VIEW session_rows AS SELECT ${select} FROM sessions s ${joins}`
42
+ );
43
+ }
@@ -0,0 +1,69 @@
1
+ import type Database from "better-sqlite3";
2
+ import { PART_COLUMNS, type PartPatch, type PartTable } from "./columns";
3
+
4
+ type Value = string | number | null;
5
+
6
+ // A condition on the part row's current values, checked with the write in one
7
+ // transaction: every column must equal one of its listed values (null for
8
+ // "unset").
9
+ export type When<T extends PartTable> = {
10
+ [C in keyof PartPatch<T>]?: Value | readonly Value[];
11
+ };
12
+
13
+ const sameAs = (current: unknown, wanted: Value | readonly Value[]): boolean =>
14
+ (Array.isArray(wanted) ? (wanted as Value[]) : [wanted as Value]).some(
15
+ (w) => (current ?? null) === w
16
+ );
17
+
18
+ // The one writer of the part tables. Writes the patched columns to the part
19
+ // table, then mirrors the same values onto the legacy sessions columns, which
20
+ // stay until they are dropped (docs/decisions/2026-10-15-split-sessions-table.md):
21
+ // that UPDATE is the only dual-write, and the step that drops the columns
22
+ // deletes it. Returns whether it wrote (false: the session is gone or `when`
23
+ // no longer holds).
24
+ export function writePart<T extends PartTable>(
25
+ db: Database.Database,
26
+ table: T,
27
+ sessionId: string,
28
+ patch: PartPatch<T>,
29
+ when?: When<T>
30
+ ): boolean {
31
+ const allowed = PART_COLUMNS[table] as readonly string[];
32
+ const columns = Object.keys(patch).filter(
33
+ (c) => (patch as Record<string, unknown>)[c] !== undefined
34
+ );
35
+ for (const c of [...columns, ...Object.keys(when ?? {})])
36
+ if (!allowed.includes(c)) throw new Error(`${c} is not a ${table} column`);
37
+ if (columns.length === 0) return false;
38
+
39
+ const run = db.transaction((): boolean => {
40
+ if (when && Object.keys(when).length > 0) {
41
+ const current = db
42
+ .prepare(
43
+ `SELECT ${Object.keys(when).join(", ")} FROM session_rows WHERE id = ?`
44
+ )
45
+ .get(sessionId) as Record<string, unknown> | undefined;
46
+ if (!current) return false;
47
+ for (const [c, wanted] of Object.entries(when))
48
+ if (!sameAs(current[c], wanted as Value | readonly Value[]))
49
+ return false;
50
+ }
51
+ const values = { id: sessionId, ...(patch as Record<string, Value>) };
52
+ const inserted = db
53
+ .prepare(
54
+ `INSERT INTO ${table} (session_id, ${columns.join(", ")})
55
+ SELECT id, ${columns.map((c) => `@${c}`).join(", ")}
56
+ FROM sessions WHERE id = @id
57
+ ON CONFLICT(session_id) DO UPDATE SET
58
+ ${columns.map((c) => `${c} = excluded.${c}`).join(", ")}`
59
+ )
60
+ .run(values);
61
+ if (inserted.changes === 0) return false;
62
+ db.prepare(
63
+ `UPDATE sessions SET ${columns.map((c) => `${c} = @${c}`).join(", ")}
64
+ WHERE id = @id`
65
+ ).run(values);
66
+ return true;
67
+ });
68
+ return run();
69
+ }
package/lib/db/types.ts CHANGED
@@ -20,7 +20,7 @@ export interface Session {
20
20
  model_overrides?: string | null;
21
21
  // A task's plan step: planning on the planning model, then implementing.
22
22
  // 'awaiting': a plan-only task's plan is written; it waits for approval.
23
- plan_phase?: "planning" | "awaiting" | "implementing" | null;
23
+ plan_phase?: "planning" | "awaiting" | "handoff" | "implementing" | null;
24
24
  plan_only?: number | null;
25
25
  // The orchestrator session that started this one; null for a person.
26
26
  started_by?: string | null;
@@ -156,6 +156,11 @@ export interface Session {
156
156
  held_for_name?: string | null;
157
157
  // A held task's "plan written" line, for its orchestrator to read.
158
158
  plan_note?: string | null;
159
+ // The task's plan stops for the machine that placed it here to decide what
160
+ // comes next (1); on that machine's mirror, the approval's message waits for
161
+ // the move home.
162
+ plan_waits_for_caller?: number | null;
163
+ plan_handoff_message?: string | null;
159
164
  // Orchestration fields
160
165
  conductor_session_id: string | null;
161
166
  worker_task: string | null;
@@ -34,7 +34,7 @@ export function unarchiveSession(id: string): Session {
34
34
  `UPDATE sessions SET archived_at = NULL WHERE id = ? AND archived_at IS NOT NULL`
35
35
  )
36
36
  .run(id).changes;
37
- const row = db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(id) as
37
+ const row = db.prepare(`SELECT * FROM session_rows WHERE id = ?`).get(id) as
38
38
  | Session
39
39
  | undefined;
40
40
  if (!row) throw new Error("Session not found");
@@ -48,7 +48,7 @@ export function listArchived(workspaceId?: string): ArchivedView[] {
48
48
  .prepare(
49
49
  `SELECT s.id, s.name, s.project_id, s.view, s.task_status, s.branch_name,
50
50
  s.pr_url, s.worktree_path, s.archived_at, p.name AS project_name, p.workspace_id AS project_workspace
51
- FROM sessions s LEFT JOIN projects p ON p.id = s.project_id
51
+ FROM session_rows s LEFT JOIN projects p ON p.id = s.project_id
52
52
  WHERE s.archived_at IS NOT NULL
53
53
  AND (? IS NULL OR p.workspace_id = ?)
54
54
  ORDER BY s.archived_at DESC, s.updated_at DESC`
@@ -33,7 +33,7 @@ const inDone = (s: Session) =>
33
33
  export async function archiveStaleDone(now = Date.now()): Promise<string[]> {
34
34
  const rows = db
35
35
  .prepare(
36
- `SELECT * FROM sessions
36
+ `SELECT * FROM session_rows
37
37
  WHERE archived_at IS NULL AND pinned = 0 AND role IS NULL
38
38
  AND peer_mirror = 0 AND quick_of IS NULL
39
39
  AND (pr_status IS NULL OR pr_status != 'open')`
package/lib/done/bulk.ts CHANGED
@@ -50,7 +50,7 @@ function scopeSessions(scope: DoneScope): Session[] {
50
50
  if (!projectIds.length) return [];
51
51
  return db
52
52
  .prepare(
53
- `SELECT * FROM sessions
53
+ `SELECT * FROM session_rows
54
54
  WHERE project_id IN (${projectIds.map(() => "?").join(", ")})
55
55
  AND role IS NULL AND archived_at IS NULL
56
56
  -- A linked machine's own sessions are cleaned up there.