@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/done/index.ts CHANGED
@@ -31,10 +31,11 @@ import { releasePorts } from "../ports";
31
31
  import { dropSessionDatabase } from "../project-config/database";
32
32
  import { doneOnPeer, peerSessionLink } from "../hosts/peer-actions";
33
33
  import { hostLink } from "../hosts/remote-api";
34
- import { doneHeldTask, isHeldRemote } from "../tasks/held-remote";
34
+ import { doneHeldTask, isHeldRemote } from "../tasks/contract/held-calls";
35
35
  import { captureMergedTask } from "../session-logs";
36
36
  import { sessionEnded } from "../experiments/hooks";
37
37
  import { pushMerged } from "../notify/push/events";
38
+ import { setPr, setTask } from "../db/session-parts";
38
39
 
39
40
  export interface DoneOptions {
40
41
  // Who asked: the orchestrator's gate failures count toward Saad, as its
@@ -108,11 +109,10 @@ async function mergeThroughGates(
108
109
  }
109
110
 
110
111
  function mark(s: Session, status: "merged" | "done", pr: TaskPR | null) {
111
- db.prepare(
112
- status === "merged"
113
- ? `UPDATE sessions SET task_status = 'merged', pr_status = 'merged' WHERE id = ?`
114
- : `UPDATE sessions SET task_status = 'done' WHERE id = ?`
115
- ).run(s.id);
112
+ db.transaction(() => {
113
+ setTask(db, s.id, { task_status: status });
114
+ if (status === "merged") setPr(db, s.id, { pr_status: "merged" });
115
+ })();
116
116
  endPauseOnFinish(s.id);
117
117
  syncTaskCardInBackground(s, status, pr);
118
118
  void sessionEnded(s.id, status);
@@ -133,10 +133,7 @@ export async function doneSession(
133
133
  if (s.role === "orchestrator")
134
134
  throw new Error("An orchestrator isn't marked done.");
135
135
  if (s.archived_at) throw new Error(`${s.name} is already archived.`);
136
- if (s.task_prompt)
137
- db.prepare(`UPDATE sessions SET task_status = 'done' WHERE id = ?`).run(
138
- s.id
139
- );
136
+ if (s.task_prompt) setTask(db, s.id, { task_status: "done" });
140
137
  archiveSession(s.id);
141
138
  return {
142
139
  id: s.id,
@@ -180,10 +177,7 @@ export async function doneSession(
180
177
  // agent and removes the worktree, which is where they are.
181
178
  await doneHeldTask(s);
182
179
  if (plan.mark) mark(s, plan.mark, plan.pr);
183
- else
184
- db.prepare(
185
- `UPDATE sessions SET task_status = 'done' WHERE id = ? AND task_status = 'running'`
186
- ).run(s.id);
180
+ else setTask(db, s.id, { task_status: "done" }, { task_status: "running" });
187
181
  worktree = {
188
182
  action: "removed",
189
183
  why: `on ${hostLink(s.host_id)?.hostName ?? "its machine"}`,
package/lib/env-setup.ts CHANGED
@@ -8,6 +8,7 @@
8
8
 
9
9
  import * as fs from "fs";
10
10
  import * as path from "path";
11
+ import { gitOk } from "./git-exec";
11
12
  import { bringDependencies, runCommand } from "./worktree-deps";
12
13
  import { inside, realExisting } from "./worktree-deps/paths";
13
14
  import { seedBuildCaches } from "./worktree-deps/caches";
@@ -40,16 +41,20 @@ export interface SetupResult {
40
41
  durationMs: number;
41
42
  }
42
43
 
44
+ // A gitignored CLAUDE.md holds the project's private notes. Only one the
45
+ // worktree confirms is ignored is copied: any other would be committable.
46
+ const PRIVATE_NOTES = "CLAUDE.md";
47
+
43
48
  /**
44
- * Find env files to copy (excludes .env.example)
49
+ * Find env files to copy (excludes .env.example), and the private notes
45
50
  */
46
51
  export function findEnvFiles(projectPath: string): string[] {
47
52
  try {
48
53
  const files = fs.readdirSync(projectPath);
49
54
  return files.filter(
50
55
  (f) =>
51
- f.startsWith(".env") &&
52
- !f.endsWith(".example") &&
56
+ ((f.startsWith(".env") && !f.endsWith(".example")) ||
57
+ f === PRIVATE_NOTES) &&
53
58
  fs.statSync(path.join(projectPath, f)).isFile()
54
59
  );
55
60
  } catch {
@@ -58,7 +63,7 @@ export function findEnvFiles(projectPath: string): string[] {
58
63
  }
59
64
 
60
65
  /**
61
- * Copy env files from source to worktree
66
+ * Copy env files (and the private CLAUDE.md) from source to worktree
62
67
  */
63
68
  export async function copyEnvFiles(
64
69
  sourcePath: string,
@@ -71,6 +76,14 @@ export async function copyEnvFiles(
71
76
  try {
72
77
  const src = path.join(sourcePath, file);
73
78
  const dest = path.join(worktreePath, file);
79
+ const there = await fs.promises.lstat(dest).catch(() => null);
80
+ if (file === PRIVATE_NOTES) {
81
+ if (there || !(await gitOk(worktreePath, ["check-ignore", "-q", file])))
82
+ continue;
83
+ } else if (there?.isSymbolicLink()) {
84
+ // Never written through: a committed link could point outside.
85
+ await fs.promises.unlink(dest);
86
+ }
74
87
  await fs.promises.copyFile(src, dest);
75
88
  copied.push(file);
76
89
  } catch (error) {
@@ -61,7 +61,7 @@ function schedulerSnapshot(f: HealthFacts): string {
61
61
  const running = db
62
62
  .prepare(
63
63
  `SELECT s.name, s.pause_state, s.plan_only, s.plan_phase, s.task_status
64
- FROM sessions s WHERE s.task_status IN ('running', 'moving')
64
+ FROM session_rows s WHERE s.task_status IN ('running', 'moving')
65
65
  AND s.archived_at IS NULL AND s.quick_of IS NULL LIMIT 20`
66
66
  )
67
67
  .all() as {
@@ -123,7 +123,7 @@ function armed(): ArmedFact[] {
123
123
  db
124
124
  .prepare(
125
125
  `SELECT s.id, s.name, s.merge_armed_at, s.pr_number, p.workspace_id
126
- FROM sessions s LEFT JOIN projects p ON p.id = s.project_id
126
+ FROM session_rows s LEFT JOIN projects p ON p.id = s.project_id
127
127
  WHERE s.merge_armed_at IS NOT NULL AND s.archived_at IS NULL
128
128
  AND COALESCE(s.task_status, 'running') = 'running'`
129
129
  )
@@ -39,7 +39,7 @@ export function mergedSince(
39
39
  return (
40
40
  db
41
41
  .prepare(
42
- `SELECT id, name, pr_number, task_prompt, updated_at FROM sessions
42
+ `SELECT id, name, pr_number, task_prompt, updated_at FROM session_rows
43
43
  WHERE project_id = ? AND task_status = 'merged'
44
44
  AND updated_at >= datetime(?)`
45
45
  )
@@ -15,7 +15,7 @@ import { isValidTmuxName } from "./attach";
15
15
  import { homeRelative } from "./discover";
16
16
  import { toPeerSession } from "./peer-sessions";
17
17
  import { insertMirror } from "./peer-sync";
18
- import { hostTasks, postIdempotent } from "../tasks/remote";
18
+ import { list as hostTasks, postIdempotent } from "../tasks/contract/client";
19
19
  import type { ProjectRef } from "../tasks/project-ref";
20
20
  import {
21
21
  HostApiError,
@@ -83,7 +83,7 @@ const path = (id: string, rest = "") =>
83
83
  `/api/sessions/${encodeURIComponent(id)}${rest}`;
84
84
 
85
85
  const row = (id: string) =>
86
- db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(id) as Session;
86
+ db.prepare(`SELECT * FROM session_rows WHERE id = ?`).get(id) as Session;
87
87
 
88
88
  export async function renameOnPeer(
89
89
  link: HostLink,
@@ -16,7 +16,7 @@ export async function mirrorPeerSession(
16
16
  sessionId: string
17
17
  ): Promise<Session> {
18
18
  const existing = db
19
- .prepare(`SELECT * FROM sessions WHERE id = ?`)
19
+ .prepare(`SELECT * FROM session_rows WHERE id = ?`)
20
20
  .get(sessionId) as Session | undefined;
21
21
  if (existing) {
22
22
  if (existing.host_id !== hostId)
@@ -43,6 +43,6 @@ export async function mirrorPeerSession(
43
43
  )
44
44
  );
45
45
  return db
46
- .prepare(`SELECT * FROM sessions WHERE id = ?`)
46
+ .prepare(`SELECT * FROM session_rows WHERE id = ?`)
47
47
  .get(peer.id) as Session;
48
48
  }
@@ -6,37 +6,45 @@
6
6
  * go there (peer-actions.ts).
7
7
  */
8
8
 
9
- import { db, queries, type Project } from "../db";
9
+ import { db, queries, type Project, type Session } from "../db";
10
10
  import { isValidAgentType } from "../providers";
11
11
  import { projectMatcher } from "./discover";
12
12
  import type { PeerSession } from "./peer-sessions";
13
+ import { setPlacement, setPr } from "../db/session-parts";
13
14
 
14
15
  export function insertMirror(
15
16
  peer: PeerSession,
16
17
  projectId: string | null
17
18
  ): void {
18
- db.prepare(
19
- `INSERT INTO sessions (id, name, tmux_name, working_directory, model, group_path,
20
- agent_type, project_id, host_id, view, name_source, pr_url, pr_number,
21
- pr_status, updated_at, peer_mirror)
22
- VALUES (?, ?, ?, ?, ?, 'sessions', ?, ?, ?, ?, 'user', ?, ?, ?,
23
- COALESCE(?, datetime('now')), 1)
24
- ON CONFLICT(id) DO NOTHING`
25
- ).run(
26
- peer.id,
27
- peer.name,
28
- peer.tmuxName || `${peer.agentType}-${peer.id}`,
29
- peer.path || "~",
30
- peer.model,
31
- isValidAgentType(peer.agentType) ? peer.agentType : "shell",
32
- projectId,
33
- peer.hostId,
34
- peer.view,
35
- peer.prUrl,
36
- peer.prNumber,
37
- peer.prStatus,
38
- peer.updatedAt
39
- );
19
+ db.transaction(() => {
20
+ const inserted = db
21
+ .prepare(
22
+ `INSERT INTO sessions (id, name, tmux_name, working_directory, model, group_path,
23
+ agent_type, project_id, host_id, view, name_source, updated_at)
24
+ VALUES (?, ?, ?, ?, ?, 'sessions', ?, ?, ?, ?, 'user',
25
+ COALESCE(?, datetime('now')))
26
+ ON CONFLICT(id) DO NOTHING`
27
+ )
28
+ .run(
29
+ peer.id,
30
+ peer.name,
31
+ peer.tmuxName || `${peer.agentType}-${peer.id}`,
32
+ peer.path || "~",
33
+ peer.model,
34
+ isValidAgentType(peer.agentType) ? peer.agentType : "shell",
35
+ projectId,
36
+ peer.hostId,
37
+ peer.view,
38
+ peer.updatedAt
39
+ );
40
+ if (!inserted.changes) return;
41
+ setPr(db, peer.id, {
42
+ pr_url: peer.prUrl,
43
+ pr_number: peer.prNumber,
44
+ pr_status: peer.prStatus as Session["pr_status"],
45
+ });
46
+ setPlacement(db, peer.id, { peer_mirror: 1 });
47
+ })();
40
48
  }
41
49
 
42
50
  // One listing mirrors at most this many: a listing is another machine's
@@ -71,17 +79,46 @@ export function syncPeerMirrors(
71
79
  );
72
80
  // That machine lists the id, so it's its session: a row mirrored on open
73
81
  // before this column existed is marked too.
74
- const update = db.prepare(
82
+ const current = db.prepare(
83
+ `SELECT name, view, tmux_name, project_id, pr_url, pr_number, pr_status,
84
+ updated_at, peer_mirror
85
+ FROM session_rows WHERE id = ? AND host_id = ? AND task_prompt IS NULL`
86
+ );
87
+ const touch = db.prepare(
75
88
  `UPDATE sessions SET name = ?, view = ?, tmux_name = ?, project_id = ?,
76
- pr_url = ?, pr_number = ?, pr_status = ?,
77
- updated_at = COALESCE(?, updated_at), peer_mirror = 1
78
- WHERE id = ? AND host_id = ? AND task_prompt IS NULL
79
- AND NOT (name IS ? AND view IS ? AND tmux_name IS ? AND project_id IS ?
80
- AND pr_url IS ? AND pr_number IS ? AND pr_status IS ?
81
- AND updated_at IS COALESCE(?, updated_at) AND peer_mirror = 1)`
89
+ updated_at = COALESCE(?, updated_at) WHERE id = ?`
82
90
  );
91
+ const update = (
92
+ peer: PeerSession,
93
+ fields: (string | number | null)[]
94
+ ): void => {
95
+ const [name, view, tmuxName, projectId, prUrl, prNumber, prStatus, at] =
96
+ fields;
97
+ const cur = current.get(peer.id, hostId) as
98
+ | Record<string, string | number | null>
99
+ | undefined;
100
+ if (!cur) return;
101
+ const same =
102
+ cur.name === name &&
103
+ cur.view === view &&
104
+ cur.tmux_name === tmuxName &&
105
+ cur.project_id === projectId &&
106
+ cur.pr_url === prUrl &&
107
+ cur.pr_number === prNumber &&
108
+ cur.pr_status === prStatus &&
109
+ cur.updated_at === (at ?? cur.updated_at) &&
110
+ cur.peer_mirror === 1;
111
+ if (same) return;
112
+ touch.run(name, view, tmuxName, projectId, at, peer.id);
113
+ setPr(db, peer.id, {
114
+ pr_url: prUrl as string | null,
115
+ pr_number: prNumber as number | null,
116
+ pr_status: prStatus as Session["pr_status"],
117
+ });
118
+ setPlacement(db, peer.id, { peer_mirror: 1 });
119
+ };
83
120
  const mirrors = db.prepare(
84
- `SELECT id FROM sessions WHERE host_id = ? AND peer_mirror = 1
121
+ `SELECT id FROM session_rows WHERE host_id = ? AND peer_mirror = 1
85
122
  AND task_prompt IS NULL AND archived_at IS NULL
86
123
  AND created_at < datetime(?, 'unixepoch')`
87
124
  );
@@ -112,7 +149,7 @@ export function syncPeerMirrors(
112
149
  peer.prStatus,
113
150
  peer.updatedAt,
114
151
  ];
115
- update.run(...fields, peer.id, hostId, ...fields);
152
+ update(peer, fields);
116
153
  }
117
154
  if (!complete) return;
118
155
  const now = new Set(listed.map((p) => p.id));
@@ -80,7 +80,7 @@ export async function readSession(id: string): Promise<SessionInput | null> {
80
80
  ? (
81
81
  db
82
82
  .prepare(
83
- `SELECT s.task_prompt AS p FROM sessions s JOIN projects p ON p.id = s.project_id
83
+ `SELECT s.task_prompt AS p FROM session_rows s JOIN projects p ON p.id = s.project_id
84
84
  WHERE p.workspace_id = ? AND s.id != ? AND s.quick_of IS NULL
85
85
  AND s.task_prompt IS NOT NULL
86
86
  AND s.created_at >= datetime('now', ?)
@@ -30,7 +30,7 @@ export function listPending(): PendingRow[] {
30
30
  `SELECT lc.*, s.name AS session_name,
31
31
  COALESCE(lc.workspace_id, s.workspace_id, p.workspace_id) AS ws
32
32
  FROM learning_cards lc
33
- JOIN sessions s ON s.id = lc.session_id
33
+ JOIN session_rows s ON s.id = lc.session_id
34
34
  LEFT JOIN projects p ON p.id = s.project_id
35
35
  WHERE lc.status = 'pending'
36
36
  ORDER BY lc.created_at DESC, lc.id DESC`
@@ -24,6 +24,7 @@ import { addNote } from "../orchestrator/notes";
24
24
  import { amendBrief, scopeChangesOf } from "../orchestrator/amendments";
25
25
  import { PIECES_PROMPT, foldedBrief, pieceBrief } from "../plans/pieces";
26
26
  import { orchestratorOf } from "../tasks/starter";
27
+ import { isRemoteHost } from "../hosts";
27
28
  import { refreshBase } from "../tasks/refresh-base";
28
29
  import {
29
30
  createFeature,
@@ -32,6 +33,7 @@ import {
32
33
  planPathOf,
33
34
  splitOf,
34
35
  } from "../plans/features";
36
+ import { advancePlan, setPlan } from "../db/session-parts";
35
37
 
36
38
  export const PLAN_FILE = ".agentos/plan.md";
37
39
 
@@ -121,8 +123,35 @@ function piecesLine(r: NonNullable<ReturnType<typeof planFor>>): string {
121
123
  return `${r.pieces.length} pieces${folded}: ${parts.join("; ")}`;
122
124
  }
123
125
 
126
+ // The "plan written" line for a task: where the plan is, what comes next,
127
+ // its first lines and how it split. `file` is where this machine can read it.
128
+ export function planNoteFor(
129
+ s: Session,
130
+ file: string,
131
+ next: string,
132
+ again = "",
133
+ after = ""
134
+ ): string {
135
+ let head = `Task "${s.name}": no plan file was written${again}; ${next}${after}.`;
136
+ try {
137
+ const text = fs.readFileSync(file, "utf8");
138
+ const summary = planSummary(text);
139
+ head = `Task "${s.name}": plan written to ${file}; ${next}.`;
140
+ if (summary.length) head += `\n${summary.map((l) => `- ${l}`).join("\n")}`;
141
+ const pieces = planFor(s, text);
142
+ if (pieces && pieces.pieces.length > 1) head += `\n${piecesLine(pieces)}`;
143
+ if (pieces?.folded.length)
144
+ head += `\nPlan listed ${pieces.listed} pieces, building ${pieces.pieces.length}: ${pieces.folded.map((f) => `${f.name} folded (${f.why})`).join("; ")}`;
145
+ } catch {
146
+ // No plan file: the line says so.
147
+ }
148
+ return head;
149
+ }
150
+
124
151
  // Tells the orchestrator that started a task its plan is written, once.
125
152
  function announcePlan(s: Session, ref: string | null, retried = false): void {
153
+ // The machine that placed it announces the plan, from its own copy.
154
+ if (s.plan_waits_for_caller) return;
126
155
  if (!orchestratorOf(s.started_by) && !s.held_by_device) return;
127
156
  const workspaceId = sessionWorkspace(s);
128
157
  // A task held for another machine's orchestrator can't reach it: the line
@@ -134,23 +163,9 @@ function announcePlan(s: Session, ref: string | null, retried = false): void {
134
163
  ? `implementing on ${ref}`
135
164
  : "waiting for approval, not implementing (approve_plan to go ahead, drop to reject)";
136
165
  const again = retried ? " (asked once more for it first)" : "";
137
- let head = `Task "${s.name}": no plan file was written${again}; ${next}${ref ? " anyway" : ""}.`;
138
- try {
139
- const summary = planSummary(fs.readFileSync(file, "utf8"));
140
- head = `Task "${s.name}": plan written to ${file}; ${next}.`;
141
- if (summary.length) head += `\n${summary.map((l) => `- ${l}`).join("\n")}`;
142
- const pieces = planFor(s, fs.readFileSync(file, "utf8"));
143
- if (pieces && pieces.pieces.length > 1) head += `\n${piecesLine(pieces)}`;
144
- if (pieces?.folded.length)
145
- head += `\nPlan listed ${pieces.listed} pieces, building ${pieces.pieces.length}: ${pieces.folded.map((f) => `${f.name} folded (${f.why})`).join("; ")}`;
146
- } catch {
147
- // No plan file: the line says so.
148
- }
166
+ const head = planNoteFor(s, file, next, again, ref ? " anyway" : "");
149
167
  if (s.held_by_device) {
150
- db.prepare(`UPDATE sessions SET plan_note = ? WHERE id = ?`).run(
151
- head,
152
- s.id
153
- );
168
+ setPlan(db, s.id, { plan_note: head });
154
169
  return;
155
170
  }
156
171
  queueEvent(workspaceId!, `plan-done:${s.id}`, s.id, head);
@@ -210,9 +225,9 @@ export const implementMessageId = (sessionId: string) =>
210
225
  type Send = (sessionId: string, id: string) => Promise<void>;
211
226
 
212
227
  function getSession(sessionId: string): Session | undefined {
213
- return db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(sessionId) as
214
- | Session
215
- | undefined;
228
+ return db
229
+ .prepare(`SELECT * FROM session_rows WHERE id = ?`)
230
+ .get(sessionId) as Session | undefined;
216
231
  }
217
232
 
218
233
  // Planning (or, once approved, awaiting) becomes implementing, once: the
@@ -244,7 +259,10 @@ async function startImplementing(
244
259
  ? undefined
245
260
  : fresh.note &&
246
261
  `Note: ${fresh.note}. Rebase onto the latest base before you open the PR.`;
247
- let split = planText ? splitOf(s, planText) : null;
262
+ // A plan another machine placed here was checked there, against its
263
+ // projects; this one's workspace doesn't know them.
264
+ let split =
265
+ planText && !s.plan_waits_for_caller ? splitOf(s, planText) : null;
248
266
  const workspaceId = sessionWorkspace(s);
249
267
  // Approved despite pieces that couldn't be registered: built as one piece,
250
268
  // and told so, rather than guessing which repository each belongs to.
@@ -253,13 +271,7 @@ async function startImplementing(
253
271
  let nothingOwn = false;
254
272
  let featureId: string | null = null;
255
273
  const claimed = db.transaction(() => {
256
- const changed = db
257
- .prepare(
258
- `UPDATE sessions SET plan_phase = 'implementing'
259
- WHERE id = ? AND plan_phase = ? AND task_status = 'running'`
260
- )
261
- .run(sessionId, from).changes;
262
- if (!changed) return false;
274
+ if (!advancePlan(db, sessionId, from, "implementing")) return false;
263
275
  setSessionModel(sessionId, routed);
264
276
  let piece: string | undefined;
265
277
  let note = amendment;
@@ -441,9 +453,12 @@ export async function handoffAfterPlan(
441
453
  forget(sessionId);
442
454
  // No approvals mode doesn't stop at the plan to wait for one. Pieces that
443
455
  // couldn't be registered always do: building on a guess is the bug.
444
- const auto = !!s.plan_only && noApprovalsOn();
456
+ const auto = !!s.plan_only && noApprovalsOn() && !s.plan_waits_for_caller;
445
457
  const planText = readPlan(s);
446
- const pieceErrors = (planText ? splitOf(s, planText)?.errors : null) ?? [];
458
+ const pieceErrors =
459
+ (planText && !s.plan_waits_for_caller
460
+ ? splitOf(s, planText)?.errors
461
+ : null) ?? [];
447
462
  if ((!s.plan_only || auto) && !pieceErrors.length) {
448
463
  const ref = await startImplementing(
449
464
  s,
@@ -462,13 +477,7 @@ export async function handoffAfterPlan(
462
477
  });
463
478
  return ref !== null;
464
479
  }
465
- const awaiting = db
466
- .prepare(
467
- `UPDATE sessions SET plan_phase = 'awaiting'
468
- WHERE id = ? AND plan_phase = 'planning' AND task_status = 'running'`
469
- )
470
- .run(sessionId).changes;
471
- if (!awaiting) return false;
480
+ if (!advancePlan(db, sessionId, "planning", "awaiting")) return false;
472
481
  try {
473
482
  announcePlan(s, null, retried);
474
483
  } catch (e) {
@@ -482,14 +491,17 @@ export async function handoffAfterPlan(
482
491
  s.id,
483
492
  `Plan of "${s.name}": couldn't register its pieces: ${pieceErrors.join("; ")}. It is held, not building. Message the task to fix the plan, or approve_plan to build it as one piece.`
484
493
  );
494
+ // The machine that placed a plan sends its own push.
485
495
  try {
486
- const text = readPlan(s);
487
- const planned = text ? planFor(s, text) : null;
488
- (await import("../notify/push/events")).pushPlanAwaiting(
489
- s.id,
490
- s.name,
491
- planned?.pieces.length ?? 1
492
- );
496
+ if (!s.plan_waits_for_caller) {
497
+ const text = readPlan(s);
498
+ const planned = text ? planFor(s, text) : null;
499
+ (await import("../notify/push/events")).pushPlanAwaiting(
500
+ s.id,
501
+ s.name,
502
+ planned?.pieces.length ?? 1
503
+ );
504
+ }
493
505
  } catch (e) {
494
506
  console.error("plan push failed:", e);
495
507
  }
@@ -512,7 +524,7 @@ export async function recheckPlanning(
512
524
  ): Promise<void> {
513
525
  const rows = db
514
526
  .prepare(
515
- `SELECT id FROM sessions
527
+ `SELECT id FROM session_rows
516
528
  WHERE plan_phase = 'planning' AND task_status = 'running'
517
529
  AND archived_at IS NULL AND worktree_path IS NOT NULL`
518
530
  )
@@ -560,9 +572,12 @@ export async function approvePlan(
560
572
  );
561
573
  const text = message?.trim() || undefined;
562
574
  const workspaceId = sessionWorkspace(s);
575
+ // A plan another machine placed here is approved by that machine, which
576
+ // keeps the brief its review reads.
577
+ const placedHere = !!s.plan_waits_for_caller && !isRemoteHost(s.host_id);
563
578
  // Every approval is on the brief, so the review judges the PR as the
564
579
  // implementation of the plan, not as plan-only work.
565
- if (workspaceId) {
580
+ if (workspaceId && !placedHere) {
566
581
  if (text) {
567
582
  amendBrief(workspaceId, s.id, text);
568
583
  addNote(
@@ -571,7 +586,11 @@ export async function approvePlan(
571
586
  );
572
587
  } else amendBrief(workspaceId, s.id, APPROVAL_TEXT[by], "approval");
573
588
  }
574
- const ref = await startImplementing(s, "awaiting", send, text);
589
+ // On another machine, the plan was written there: this machine decides
590
+ // where it is built, and the approval follows it.
591
+ const ref = isRemoteHost(s.host_id)
592
+ ? await (await import("../tasks/contract/plan")).continuePlanThere(s, text)
593
+ : await startImplementing(s, "awaiting", send, text);
575
594
  if (!ref) throw new Error(`${s.name} isn't waiting for plan approval`);
576
595
  if (by === "no-approvals")
577
596
  recordAutoApproval({
@@ -583,3 +602,21 @@ export async function approvePlan(
583
602
  });
584
603
  return { ref, amendments: scopeChangesOf(s.id).length };
585
604
  }
605
+
606
+ // A plan that moved here to be built (contract/plan.ts): implementation starts
607
+ // on this machine, once, with the approval's change if it had one.
608
+ export async function implementArrived(
609
+ sessionId: string,
610
+ message?: string
611
+ ): Promise<string | null> {
612
+ const s = getSession(sessionId);
613
+ if (!s || s.plan_phase !== "awaiting" || s.task_status !== "running")
614
+ throw new Error(`${s?.name ?? "That task"} isn't waiting for a plan`);
615
+ const { sendQueuedNow } = await import("../chat/runner");
616
+ return startImplementing(
617
+ s,
618
+ "awaiting",
619
+ (id, m) => sendQueuedNow(id, m),
620
+ message
621
+ );
622
+ }
@@ -19,6 +19,7 @@ import {
19
19
  type ModelOverride,
20
20
  } from "./resolve";
21
21
  import { sessionRole } from "./session-role";
22
+ import { setTask } from "../db/session-parts";
22
23
 
23
24
  export const RESUME_MESSAGE =
24
25
  "You were paused. Carry on where you left off: check the worktree and the conversation above for where things stand, then finish the task.";
@@ -45,9 +46,9 @@ export async function resumeOnModel(
45
46
  model: unknown,
46
47
  message = RESUME_MESSAGE
47
48
  ): Promise<ResumeResult> {
48
- const s = db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(sessionId) as
49
- | Session
50
- | undefined;
49
+ const s = db
50
+ .prepare(`SELECT * FROM session_rows WHERE id = ?`)
51
+ .get(sessionId) as Session | undefined;
51
52
  if (!s?.task_status) throw new Error("No such task");
52
53
  if (s.task_status !== "running")
53
54
  throw new Error(
@@ -65,10 +66,7 @@ export async function resumeOnModel(
65
66
  throw new Error(
66
67
  "It's mid-turn: stop it first, or wait for the turn to end"
67
68
  );
68
- db.prepare(`UPDATE sessions SET model_overrides = ? WHERE id = ?`).run(
69
- JSON.stringify(override),
70
- sessionId
71
- );
69
+ setTask(db, sessionId, { model_overrides: JSON.stringify(override) });
72
70
  if (launchPending(sessionId)) {
73
71
  setSessionModel(sessionId, routed);
74
72
  return { ref: routed.ref, resumed: false };
@@ -160,7 +160,7 @@ export async function notifyFrom(
160
160
  text: string
161
161
  ): Promise<Outcome> {
162
162
  const session = from
163
- ? (db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(from) as
163
+ ? (db.prepare(`SELECT * FROM session_rows WHERE id = ?`).get(from) as
164
164
  | Session
165
165
  | undefined)
166
166
  : undefined;
@@ -92,7 +92,7 @@ export const pushPlanAwaiting = (
92
92
  export const pushPlanReady = (sessionId: string, itemId: string) => {
93
93
  // Only a task has a screen to open; a plain chat's plan goes to the list.
94
94
  const task = db
95
- .prepare(`SELECT task_status FROM sessions WHERE id = ?`)
95
+ .prepare(`SELECT task_status FROM session_rows WHERE id = ?`)
96
96
  .get(sessionId) as { task_status: string | null } | undefined;
97
97
  fire(
98
98
  {
@@ -57,7 +57,7 @@ export interface PushOptions {
57
57
 
58
58
  function isMirrored(sessionId: string): boolean {
59
59
  const s = db
60
- .prepare(`SELECT host_id, peer_mirror FROM sessions WHERE id = ?`)
60
+ .prepare(`SELECT host_id, peer_mirror FROM session_rows WHERE id = ?`)
61
61
  .get(sessionId) as
62
62
  | { host_id: string | null; peer_mirror: number | null }
63
63
  | undefined;
@@ -439,7 +439,7 @@ export async function resurrect(
439
439
  if (!reason.trim()) throw new Error("Say why it's restored");
440
440
  const id = resolveEnded(ref, { workspaceId });
441
441
  const row = db
442
- .prepare(`SELECT task_status, archived_at FROM sessions WHERE id = ?`)
442
+ .prepare(`SELECT task_status, archived_at FROM session_rows WHERE id = ?`)
443
443
  .get(id) as { task_status: string | null; archived_at: string | null };
444
444
  const task =
445
445
  row.task_status === "dropped" ||