@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
@@ -137,7 +137,7 @@ function merged(
137
137
  const rows = db
138
138
  .prepare(
139
139
  `SELECT s.id, s.name, p.name AS project, p.id AS project_id, s.pr_number, s.pr_url
140
- FROM sessions s JOIN projects p ON p.id = s.project_id
140
+ FROM session_rows s JOIN projects p ON p.id = s.project_id
141
141
  WHERE p.workspace_id = ? AND s.task_status = 'merged'
142
142
  AND s.merged_at >= ? AND s.merged_at <= ?
143
143
  ORDER BY s.merged_at, s.id`
@@ -178,7 +178,7 @@ function inFlight(workspaceId: string, awayId: number): InFlightItem[] {
178
178
  .prepare(
179
179
  `SELECT s.id, s.name, p.name AS project, s.pr_number, s.pr_url,
180
180
  s.merge_armed_at AS armed_at
181
- FROM sessions s JOIN projects p ON p.id = s.project_id
181
+ FROM session_rows s JOIN projects p ON p.id = s.project_id
182
182
  WHERE p.workspace_id = ? AND s.role IS NULL AND s.archived_at IS NULL
183
183
  AND s.task_status = 'running'
184
184
  AND EXISTS (SELECT 1 FROM away_actions a WHERE a.away_id = ?
@@ -224,7 +224,7 @@ function failed(workspaceId: string, away: AwayRow, end: string) {
224
224
  const deploys = db
225
225
  .prepare(
226
226
  `SELECT a.detail, s.name, s.pr_number, s.pr_url FROM away_actions a
227
- LEFT JOIN sessions s ON s.id = a.subject
227
+ LEFT JOIN session_rows s ON s.id = a.subject
228
228
  WHERE a.away_id = ? AND a.workspace_id = ? AND a.kind = 'deploy_failed'
229
229
  ORDER BY a.id`
230
230
  )
@@ -236,7 +236,7 @@ function failed(workspaceId: string, away: AwayRow, end: string) {
236
236
  }[];
237
237
  const ended = db
238
238
  .prepare(
239
- `SELECT s.name, s.task_status, s.pr_number, s.pr_url FROM sessions s
239
+ `SELECT s.name, s.task_status, s.pr_number, s.pr_url FROM session_rows s
240
240
  JOIN projects p ON p.id = s.project_id
241
241
  WHERE p.workspace_id = ? AND s.task_status IN ('dropped', 'done')
242
242
  AND s.task_ended_at >= ? AND s.task_ended_at <= ?
package/lib/away/run.ts CHANGED
@@ -69,7 +69,7 @@ function orchestrated(): { workspaceId: string; orchestratorId: string }[] {
69
69
  }
70
70
 
71
71
  const sessionRow = (id: string) =>
72
- db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(id) as
72
+ db.prepare(`SELECT * FROM session_rows WHERE id = ?`).get(id) as
73
73
  | Session
74
74
  | undefined;
75
75
 
@@ -330,7 +330,7 @@ async function checkDeploys(
330
330
  const found: DeployFinding[] = [];
331
331
  const merged = db
332
332
  .prepare(
333
- `SELECT s.* FROM sessions s JOIN projects p ON p.id = s.project_id
333
+ `SELECT s.* FROM session_rows s JOIN projects p ON p.id = s.project_id
334
334
  WHERE p.workspace_id = ? AND s.task_status = 'merged' AND s.merged_at >= ?
335
335
  AND NOT EXISTS (SELECT 1 FROM away_actions a
336
336
  WHERE a.away_id = ? AND a.key = 'deploy:' || s.id)`
@@ -57,7 +57,7 @@ type Joined = Row & {
57
57
  // The bookmark with its chat's state as of now.
58
58
  const SELECT = `
59
59
  SELECT b.*, s.id AS s_id, s.task_status, s.archived_at
60
- FROM chat_bookmarks b LEFT JOIN sessions s ON s.id = b.session_id`;
60
+ FROM chat_bookmarks b LEFT JOIN session_rows s ON s.id = b.session_id`;
61
61
 
62
62
  function toBookmark(r: Joined): Bookmark {
63
63
  return {
@@ -141,7 +141,7 @@ export function createBookmark(input: {
141
141
  throw new BookmarkError("scope must be 'item' or 'turn'", 400);
142
142
  const note = cleanNote(input.note);
143
143
  const session = db
144
- .prepare(`SELECT * FROM sessions WHERE id = ?`)
144
+ .prepare(`SELECT * FROM session_rows WHERE id = ?`)
145
145
  .get(sessionId) as Session | undefined;
146
146
  if (!session) throw new BookmarkError("Session not found", 404);
147
147
  const existing = db
@@ -308,7 +308,7 @@ export function insertMovedBookmarks(
308
308
  ): void {
309
309
  if (!rows.length) return;
310
310
  const session = db
311
- .prepare(`SELECT * FROM sessions WHERE id = ?`)
311
+ .prepare(`SELECT * FROM session_rows WHERE id = ?`)
312
312
  .get(sessionId) as Session | undefined;
313
313
  if (!session) return;
314
314
  const workspace = sessionWorkspace(session);
@@ -68,7 +68,7 @@ export function pendingReplies(device: string, limit = 50): PendingReply[] {
68
68
  db
69
69
  .prepare(
70
70
  `SELECT m.id, m.from_id AS fromId, m.body, m.created_at AS createdAt
71
- FROM bus_messages m JOIN sessions s ON s.id = m.from_id
71
+ FROM bus_messages m JOIN session_rows s ON s.id = m.from_id
72
72
  WHERE m.relay_state = 'pending' AND s.held_by_device = ?
73
73
  ORDER BY m.id LIMIT ?`
74
74
  )
@@ -81,7 +81,7 @@ export function ackReplies(device: string, ids: number[]): void {
81
81
  const mark = db.prepare(
82
82
  `UPDATE bus_messages SET relay_state = 'relayed', delivered_at = datetime('now')
83
83
  WHERE id = ? AND relay_state = 'pending'
84
- AND from_id IN (SELECT id FROM sessions WHERE held_by_device = ?)`
84
+ AND from_id IN (SELECT id FROM session_rows WHERE held_by_device = ?)`
85
85
  );
86
86
  db.transaction(() => {
87
87
  for (const id of ids) mark.run(id, device);
package/lib/bus/index.ts CHANGED
@@ -15,7 +15,7 @@ import { deliverToPane, type Delivery } from "./delivery";
15
15
  import { resolveRef, wasNames, type Candidate } from "./resolve";
16
16
  import { tmuxPane } from "./tmux-pane";
17
17
  import { pauseHoldOf, pausedNote } from "../tasks/pause-hold";
18
- import { isHeldRemote, relayToHeldTask } from "../tasks/held-remote";
18
+ import { isHeldRemote, relayToHeldTask } from "../tasks/contract/held-calls";
19
19
  import {
20
20
  assertTakesMessages,
21
21
  findQueuedTarget,
@@ -43,7 +43,7 @@ export interface Peer {
43
43
  function liveSessions(): Session[] {
44
44
  return db
45
45
  .prepare(
46
- `SELECT * FROM sessions WHERE (task_status IS NULL OR task_status = 'running')
46
+ `SELECT * FROM session_rows WHERE (task_status IS NULL OR task_status = 'running')
47
47
  AND archived_at IS NULL
48
48
  ORDER BY updated_at DESC`
49
49
  )
@@ -63,7 +63,7 @@ function candidates(sessions: Session[]): Candidate[] {
63
63
 
64
64
  const sessionById = (id: string | null | undefined) =>
65
65
  id
66
- ? (db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(id) as
66
+ ? (db.prepare(`SELECT * FROM session_rows WHERE id = ?`).get(id) as
67
67
  | Session
68
68
  | undefined)
69
69
  : undefined;
@@ -28,7 +28,7 @@ async function gitStatus(cwd: string): Promise<string> {
28
28
  export async function compactContext(sessionId: string): Promise<string> {
29
29
  const session = db
30
30
  .prepare(
31
- `SELECT working_directory, task_prompt, role FROM sessions WHERE id = ?`
31
+ `SELECT working_directory, task_prompt, role FROM session_rows WHERE id = ?`
32
32
  )
33
33
  .get(sessionId) as
34
34
  | Pick<Session, "working_directory" | "task_prompt" | "role">
@@ -149,6 +149,10 @@ export type ChatItem =
149
149
  // Saved since messages were tagged where they're sent: without an
150
150
  // origin, the reader typed it. Older items are matched by their text.
151
151
  tagged?: true;
152
+ // Whether it was sent while a turn ran: the agent then reads it once
153
+ // that turn ends, so what follows until then is still that turn's.
154
+ // Always set when saved; absent only on history from before it was.
155
+ midTurn?: boolean;
152
156
  // The provider's id for this message, to undo file changes back to it.
153
157
  checkpoint?: string;
154
158
  })
package/lib/chat/group.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { ChatItem } from "./events";
2
+ import { readingOrder } from "./reading-order";
2
3
  import { eventTurnAt, type EventTurn } from "./turns";
3
4
 
4
5
  export type ToolItem = Extract<ChatItem, { kind: "tool" }>;
@@ -23,9 +24,10 @@ export const STANDALONE_TOOLS = new Set(["Skill", "Task", "Agent"]);
23
24
  // collapsible block, so a long turn reads as what was said, not every step.
24
25
  // Undone messages fold into one block behind the undo that took them back.
25
26
  export function groupTimeline(
26
- items: ChatItem[],
27
+ all: ChatItem[],
27
28
  opts: GroupOptions = {}
28
29
  ): TimelineBlock[] {
30
+ const items = opts.foldEvents ? readingOrder(all) : all;
29
31
  const blocks: TimelineBlock[] = [];
30
32
  let tools: ToolItem[] = [];
31
33
  const flush = () => {
package/lib/chat/hold.ts CHANGED
@@ -15,10 +15,10 @@ import { db } from "../db";
15
15
 
16
16
  // The SQL form, for queries that take the session id as @sid.
17
17
  export const HELD_SQL = `(
18
- EXISTS (SELECT 1 FROM sessions WHERE id = @sid AND task_status = 'moving')
18
+ EXISTS (SELECT 1 FROM session_rows WHERE id = @sid AND task_status = 'moving')
19
19
  OR EXISTS (SELECT 1 FROM stack_items i JOIN stacks s ON s.id = i.stack_id
20
20
  WHERE i.session_id = @sid AND s.status = 'landing')
21
- OR EXISTS (SELECT 1 FROM sessions WHERE id = @sid
21
+ OR EXISTS (SELECT 1 FROM session_rows WHERE id = @sid
22
22
  AND pause_state IN ('pausing', 'paused'))
23
23
  )`;
24
24
 
@@ -58,7 +58,7 @@ const holdRow = (sessionId: string) =>
58
58
  `SELECT task_status, moved_to, quick_of, pause_state, paused_until,
59
59
  EXISTS (SELECT 1 FROM stack_items i JOIN stacks s ON s.id = i.stack_id
60
60
  WHERE i.session_id = @sid AND s.status = 'landing') AS landing
61
- FROM sessions WHERE id = @sid`
61
+ FROM session_rows WHERE id = @sid`
62
62
  )
63
63
  .get({ sid: sessionId }) as HoldRow | undefined;
64
64
 
@@ -0,0 +1,50 @@
1
+ import type { ChatItem } from "./events";
2
+ import { activityOrigin } from "./turns";
3
+
4
+ // Activity sent while a turn runs is saved when sent but read by the agent
5
+ // once that turn ends. It moves to after the turn's end, so the turn a
6
+ // person's message started stays whole and the activity gets its own reply.
7
+ //
8
+ // Several waiting at one turn's end are taken a turn each: the first starts
9
+ // after the end, the next after the following one. Whether the agent
10
+ // answers them together or apart isn't recorded, and this way a turn each
11
+ // keeps every reply with its own event; answered together, the first takes
12
+ // the reply and the rest show as bare rows.
13
+ //
14
+ // A message saved with `midTurn` is trusted either way. Only history from
15
+ // before the field is guessed, from whether a turn was open when it landed.
16
+ export function readingOrder(items: ChatItem[]): ChatItem[] {
17
+ const out: ChatItem[] = [];
18
+ const waiting: ChatItem[] = [];
19
+ let open = false;
20
+ let moved = false;
21
+ const release = () => out.push(...waiting.splice(0));
22
+ for (const item of items) {
23
+ if (item.kind === "user") {
24
+ const waits = item.midTurn === undefined ? open : item.midTurn;
25
+ if (waits && activityOrigin(item)) {
26
+ waiting.push(item);
27
+ moved = true;
28
+ continue;
29
+ }
30
+ release();
31
+ out.push(item);
32
+ open = true;
33
+ } else if (item.kind === "turn_end") {
34
+ out.push(item);
35
+ open = false;
36
+ const next = waiting.shift();
37
+ if (next) out.push(next);
38
+ } else if (
39
+ item.kind === "undo" ||
40
+ item.kind === "error" ||
41
+ item.kind === "command_output"
42
+ ) {
43
+ release();
44
+ out.push(item);
45
+ open = false;
46
+ } else out.push(item);
47
+ }
48
+ release();
49
+ return moved ? out : items;
50
+ }
@@ -163,7 +163,7 @@ export function autoCompactEnv(sessionId: string): Record<string, string> {
163
163
  const row = db
164
164
  .prepare(
165
165
  `SELECT role, task_status, plan_phase, started_by, model_provider
166
- FROM sessions WHERE id = ?`
166
+ FROM session_rows WHERE id = ?`
167
167
  )
168
168
  .get(sessionId) as
169
169
  | {
@@ -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
package/lib/chat/turns.ts CHANGED
@@ -18,8 +18,6 @@ export interface EventTurn {
18
18
  at: number;
19
19
  // Still being answered.
20
20
  live: boolean;
21
- // The reply opened a question for the reader.
22
- hasQuestion: boolean;
23
21
  }
24
22
 
25
23
  const FOLDED_KINDS = new Set(["event", "peer", "schedule", "system"]);
@@ -39,6 +37,26 @@ export function activityOrigin(item: UserItem): ChatOrigin | null {
39
37
  export const isQuestionTool = (item: ChatItem): boolean =>
40
38
  item.kind === "tool" && /(^|__)question$/.test(item.name);
41
39
 
40
+ export const isAskTool = (item: ChatItem): boolean =>
41
+ item.kind === "tool" && /(^|__)ask_saad$/.test(item.name);
42
+
43
+ // The assistant message a question was asked in (its turn's last words
44
+ // before the call), else the call itself.
45
+ export function questionSource(items: ChatItem[], text: string): string | null {
46
+ const at = items.findLastIndex(
47
+ (i) =>
48
+ isQuestionTool(i) &&
49
+ (i as { input?: { text?: string } }).input?.text === text
50
+ );
51
+ if (at === -1) return null;
52
+ for (let i = at - 1; i >= 0; i--) {
53
+ const item = items[i];
54
+ if (item.kind === "user" || item.kind === "turn_end") break;
55
+ if (item.kind === "assistant" && item.text.trim()) return item.id;
56
+ }
57
+ return items[at].id;
58
+ }
59
+
42
60
  export function plainSummary(text: string, max = 140): string {
43
61
  const line =
44
62
  text
@@ -89,6 +107,8 @@ export function eventTurnAt(
89
107
  (i) =>
90
108
  i.kind === "plan" ||
91
109
  i.kind === "error" ||
110
+ isQuestionTool(i) ||
111
+ isAskTool(i) ||
92
112
  (i.kind === "approval" && i.status === "pending")
93
113
  );
94
114
  if (needsReader) return null;
@@ -105,7 +125,6 @@ export function eventTurnAt(
105
125
  task: taskName(origin),
106
126
  at: user.createdAt,
107
127
  live: !ended && end >= items.length,
108
- hasQuestion: replies.some(isQuestionTool),
109
128
  },
110
129
  };
111
130
  }
@@ -564,6 +564,8 @@ export class ChatHost {
564
564
  this.sent.add(m.id);
565
565
  this.currentTurn = m.id;
566
566
  this.hardStopAt = 0;
567
+ // A message sent now stops the turn and is read next: nothing waits.
568
+ const midTurn = this.busy() && !now;
567
569
  if (this.busy()) this.behind++;
568
570
  // Sent now, it stops the running turn, which still ends after this.
569
571
  if (!this.busy()) now = false;
@@ -599,6 +601,7 @@ export class ChatHost {
599
601
  peer: m.peer,
600
602
  origin: m.origin,
601
603
  tagged: true as const,
604
+ midTurn,
602
605
  createdAt: Date.now(),
603
606
  checkpoint,
604
607
  };
@@ -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,19 @@
1
+ import type { Migration } from "../migration-types";
2
+
3
+ export default {
4
+ key: "20261015T1600_plan_waits_for_caller",
5
+ up: (db) => {
6
+ // A plan another machine placed here: it stops at the plan for that
7
+ // machine to decide what comes next. On the machine that placed it, the
8
+ // approval's message waits for the move home. Additive.
9
+ const add = (col: string, type: string) => {
10
+ const columns = db.prepare(`PRAGMA table_info(sessions)`).all() as {
11
+ name: string;
12
+ }[];
13
+ if (!columns.some((c) => c.name === col))
14
+ db.exec(`ALTER TABLE sessions ADD COLUMN ${col} ${type}`);
15
+ };
16
+ add("plan_waits_for_caller", "INTEGER");
17
+ add("plan_handoff_message", "TEXT");
18
+ },
19
+ } satisfies Migration;
@@ -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.