@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
@@ -0,0 +1,281 @@
1
+ /**
2
+ * Every reason a task waits, as one type and one ordered list. A hold is
3
+ * pure data: `holdsFor` turns a candidate and what we know of its machine
4
+ * into the holds that apply, always in HOLD_ORDER, and the first is the
5
+ * reason shown. The queue view, a queued row's note, a paused task's resume
6
+ * note and the start decision all read that list, so "why is this waiting"
7
+ * has one source. No database and no clock of its own: the checks that need
8
+ * either are in hold-checks.ts.
9
+ */
10
+
11
+ import {
12
+ DEFAULT_PRIORITY,
13
+ type Priority,
14
+ type WaitCode,
15
+ } from "./schedule-types";
16
+ import type { Candidate } from "./schedule";
17
+
18
+ // Out of red this long before automatic starts resume.
19
+ export const LOAD_SETTLE_MS = 120_000;
20
+ // On amber, only this priority and above start.
21
+ export const AMBER_MIN_PRIORITY: Priority = "blocking";
22
+ // Between automatic starts on one machine.
23
+ export const START_GAP_MS = 60_000;
24
+ // Between starts of tasks that boot a simulator.
25
+ export const HEAVY_START_GAP_MS = 5 * 60_000;
26
+
27
+ export const PRIORITY_POINTS: Record<Priority, number> = {
28
+ urgent: 400,
29
+ blocking: 300,
30
+ normal: 200,
31
+ background: 100,
32
+ };
33
+ const priorityRank = (p: Priority) => PRIORITY_POINTS[p] ?? 0;
34
+
35
+ export type MachineLevel = "green" | "amber" | "red";
36
+
37
+ // What the machine a candidate would start on looks like right now.
38
+ export interface MachineView {
39
+ // Shown in hold texts, e.g. "this machine".
40
+ label: string;
41
+ // False: no load holds at all (AGENTOS_LOAD=off).
42
+ enabled: boolean;
43
+ // No sample read yet since AgentOS started.
44
+ reading: boolean;
45
+ level: MachineLevel;
46
+ // Not red, and calm for LOAD_SETTLE_MS since it last was.
47
+ settled: boolean;
48
+ // Seconds of calm still needed, when not settled and not red.
49
+ settleLeftMs: number;
50
+ booted: number;
51
+ maxBooted: number;
52
+ // Heavy tasks started within HEAVY_START_GAP_MS that hold no lease yet.
53
+ heavyStarting: number;
54
+ lastAutoStartAt: number | null;
55
+ lastHeavyStartAt: number | null;
56
+ }
57
+
58
+ export type MachineCode =
59
+ | "needs"
60
+ | "load_red"
61
+ | "load_amber"
62
+ | "heavy"
63
+ | "spacing";
64
+
65
+ // A reason a task isn't starting. `code` is also what a queue row stores and
66
+ // the client reads. A hold that rests on a machine says which one, and when
67
+ // it knows, the time (ms) it lifts.
68
+ export type Hold =
69
+ | { code: "after"; text: string }
70
+ | { code: "after_any"; text: string }
71
+ | { code: "orch_paused"; text: string }
72
+ | { code: "window"; text: string }
73
+ | {
74
+ code: MachineCode;
75
+ text: string;
76
+ machine: string;
77
+ until?: number;
78
+ // Why it waits here instead of starting on a linked machine.
79
+ stay?: string;
80
+ }
81
+ | { code: "slot"; text: string };
82
+
83
+ // The order holds are reported in; the first that applies is the reason.
84
+ export const HOLD_ORDER: readonly WaitCode[] = [
85
+ "after",
86
+ "after_any",
87
+ "orch_paused",
88
+ "window",
89
+ "needs",
90
+ "load_red",
91
+ "load_amber",
92
+ "heavy",
93
+ "spacing",
94
+ "slot",
95
+ ];
96
+
97
+ // Whether the hold lifts without anyone acting. A Pause is lifted by a
98
+ // person, and a task that needs a Mac never gets one by waiting.
99
+ const CLEARS_BY_ITSELF: Record<WaitCode, boolean> = {
100
+ after: true,
101
+ after_any: true,
102
+ orch_paused: false,
103
+ window: true,
104
+ needs: false,
105
+ load_red: true,
106
+ load_amber: true,
107
+ heavy: true,
108
+ spacing: true,
109
+ slot: true,
110
+ };
111
+
112
+ export const clearsByItself = (h: Hold): boolean => CLEARS_BY_ITSELF[h.code];
113
+
114
+ // The holds a linked machine could lift by taking the start.
115
+ const PLACEABLE = new Set<WaitCode>([
116
+ "load_red",
117
+ "load_amber",
118
+ "heavy",
119
+ "needs",
120
+ ]);
121
+
122
+ export const placeable = (h: Pick<Hold, "code">): boolean =>
123
+ PLACEABLE.has(h.code);
124
+
125
+ // The text a person sees: the hold, and for a task that can't go elsewhere,
126
+ // why it stays here.
127
+ export function holdText(h: Hold): string {
128
+ return "stay" in h && h.stay ? `${h.text} (${h.stay})` : h.text;
129
+ }
130
+
131
+ // The same without the "Waiting: " lead, for a note that has its own.
132
+ export const holdReason = (h: Hold): string =>
133
+ holdText(h).replace(/^Waiting: /, "");
134
+
135
+ // What a row stores of its first hold: null code and text when none.
136
+ export function queueNote(holds: readonly Hold[] | undefined): {
137
+ code: WaitCode | null;
138
+ text: string | null;
139
+ } | null {
140
+ if (!holds) return null;
141
+ const head = holds[0];
142
+ return head
143
+ ? { code: head.code, text: holdText(head) }
144
+ : { code: null, text: null };
145
+ }
146
+
147
+ // What a timed resume waiting for its turn says on its task.
148
+ export function resumeNote(
149
+ holds: readonly Hold[] | undefined,
150
+ slots: { running: number; limit: number | null }
151
+ ): string | null {
152
+ if (!holds) return null;
153
+ const head = holds[0];
154
+ if (head && head.code !== "slot") return `held: ${holdReason(head)}`;
155
+ return slots.limit === null
156
+ ? "resuming: waiting for its turn"
157
+ : `resuming: waiting for a slot, ${slots.running} of ${slots.limit} running`;
158
+ }
159
+
160
+ const seconds = (ms: number) => Math.max(1, Math.ceil(ms / 1000));
161
+
162
+ export function slotText(rank: number, of: number, priority: Priority): string {
163
+ const word = priority === DEFAULT_PRIORITY ? "" : ` · ${priority}`;
164
+ return `#${rank} of ${of}${word}`;
165
+ }
166
+
167
+ type MachineAsk = Pick<
168
+ Candidate,
169
+ "heavy" | "needsUnmet" | "remote" | "priority"
170
+ > &
171
+ Partial<Pick<Candidate, "planOnly">>;
172
+
173
+ // The holds the machine puts on a start, in order. Empty: it may start here
174
+ // now. A machine with no reading yet says only that; one with load checks
175
+ // off says only what it lacks.
176
+ export function machineHolds(
177
+ c: MachineAsk,
178
+ m: MachineView,
179
+ now: number
180
+ ): Hold[] {
181
+ if (c.remote || c.planOnly) return [];
182
+ const out: Hold[] = [];
183
+ const add = (code: MachineCode, text: string, until?: number): void => {
184
+ out.push({
185
+ code,
186
+ text: `Waiting: ${text}`,
187
+ machine: m.label,
188
+ ...(until === undefined ? {} : { until }),
189
+ });
190
+ };
191
+ if (c.needsUnmet) add("needs", "needs a Mac");
192
+ if (!m.enabled) return out;
193
+ if (m.reading) {
194
+ add("load_red", "reading machine load");
195
+ return out;
196
+ }
197
+ if (m.level === "red") add("load_red", `${m.label} load red`);
198
+ else if (!m.settled)
199
+ add(
200
+ "load_red",
201
+ `${m.label} load was red; calm for ${seconds(m.settleLeftMs)}s more`,
202
+ now + m.settleLeftMs
203
+ );
204
+ else if (
205
+ m.level === "amber" &&
206
+ priorityRank(c.priority) < priorityRank(AMBER_MIN_PRIORITY)
207
+ )
208
+ add(
209
+ "load_amber",
210
+ `${m.label} load amber; ${c.priority} work starts on green`
211
+ );
212
+ if (c.heavy && m.level !== "red") {
213
+ if (m.level !== "green")
214
+ add("heavy", `${m.label} load amber; simulator work starts on green`);
215
+ else if (m.heavyStarting > 0)
216
+ add("heavy", `a simulator is booting on ${m.label}`);
217
+ else if (m.booted >= m.maxBooted)
218
+ add("heavy", `${m.booted} of ${m.maxBooted} simulators in use`);
219
+ else if (
220
+ m.lastHeavyStartAt !== null &&
221
+ now - m.lastHeavyStartAt < HEAVY_START_GAP_MS
222
+ )
223
+ add(
224
+ "heavy",
225
+ `a simulator task just started on ${m.label} (next in ${seconds(HEAVY_START_GAP_MS - (now - m.lastHeavyStartAt))}s)`,
226
+ m.lastHeavyStartAt + HEAVY_START_GAP_MS
227
+ );
228
+ }
229
+ if (m.lastAutoStartAt !== null && now - m.lastAutoStartAt < START_GAP_MS)
230
+ add(
231
+ "spacing",
232
+ `spacing starts (next in ${seconds(START_GAP_MS - (now - m.lastAutoStartAt))}s)`,
233
+ m.lastAutoStartAt + START_GAP_MS
234
+ );
235
+ return out;
236
+ }
237
+
238
+ // The first machine hold, or null: the one every placement check asks.
239
+ export const machineHold = (
240
+ c: MachineAsk,
241
+ m: MachineView,
242
+ now: number
243
+ ): Hold | null => machineHolds(c, m, now)[0] ?? null;
244
+
245
+ export interface HoldContext {
246
+ machine: MachineView;
247
+ now: number;
248
+ // Finishes other waiters have already taken this look.
249
+ used: ReadonlySet<string>;
250
+ // Running tasks in its line, and the line's limit.
251
+ inFlight: number;
252
+ limit: number;
253
+ // Its place in the line's score order.
254
+ rank: number;
255
+ of: number;
256
+ }
257
+
258
+ // Every hold on a candidate, in HOLD_ORDER. The first is why it waits; none:
259
+ // it starts.
260
+ export function holdsFor(c: Candidate, ctx: HoldContext): Hold[] {
261
+ const out: Hold[] = [];
262
+ if (c.before) out.push(c.before);
263
+ if (c.any && !c.any.released) {
264
+ const free = c.any.finished.some((id) => !ctx.used.has(id));
265
+ const spent =
266
+ c.any.ids.length > 0 && c.any.finished.length === c.any.ids.length;
267
+ if (!free && !spent)
268
+ out.push({
269
+ code: "after_any",
270
+ text: `Waiting: after any running task (${c.any.label})`,
271
+ });
272
+ }
273
+ if (c.auto) out.push(...([] as Hold[]).concat(c.auto));
274
+ out.push(...machineHolds(c, ctx.machine, ctx.now));
275
+ if (!c.planOnly && ctx.inFlight >= ctx.limit)
276
+ out.push({
277
+ code: "slot",
278
+ text: slotText(ctx.rank, ctx.of, c.priority),
279
+ });
280
+ return out;
281
+ }
@@ -38,6 +38,7 @@ import {
38
38
  validateBundle,
39
39
  type TaskBundle,
40
40
  } from "./move-bundle";
41
+ import { setPlan, setTask } from "../db/session-parts";
41
42
 
42
43
  // Imports in flight in this process, by move id.
43
44
  const g = globalThis as unknown as { __agentosImporting?: Set<string> };
@@ -47,7 +48,7 @@ const importing = (g.__agentosImporting ??= new Set());
47
48
  export const arrived = (moveId: string) =>
48
49
  db
49
50
  .prepare(
50
- `SELECT * FROM sessions WHERE moved_from = ? AND host_id = 'local'
51
+ `SELECT * FROM session_rows WHERE moved_from = ? AND host_id = 'local'
51
52
  AND task_status IN ('running', 'moving', 'moved', 'merged', 'done')`
52
53
  )
53
54
  .get(moveId) as Session | undefined;
@@ -56,7 +57,7 @@ export const arrived = (moveId: string) =>
56
57
  function refuseBusyBranch(projectId: string, branch: string): void {
57
58
  const busy = db
58
59
  .prepare(
59
- `SELECT id FROM sessions WHERE project_id = ? AND branch_name = ?
60
+ `SELECT id FROM session_rows WHERE project_id = ? AND branch_name = ?
60
61
  AND task_status IN ('running', 'moving') AND host_id = 'local'`
61
62
  )
62
63
  .get(projectId, branch);
@@ -118,9 +119,10 @@ async function arrive(
118
119
  };
119
120
  stepProgress(bundle.moveId, "conversation");
120
121
  // The plan is never in git: it comes in the bundle.
121
- if (orch?.planFile != null) {
122
+ const planFile = bundle.planWait?.file ?? orch?.planFile ?? null;
123
+ if (planFile != null) {
122
124
  preparePlanDir(worktreePath);
123
- fs.writeFileSync(path.join(worktreePath, PLAN_FILE), orch.planFile);
125
+ fs.writeFileSync(path.join(worktreePath, PLAN_FILE), planFile);
124
126
  }
125
127
  if (claude && paths) {
126
128
  await writeTranscript(
@@ -165,22 +167,29 @@ async function arrive(
165
167
  .run(worktreePath, bundle.branch, baseBranch, null, id);
166
168
  // A chat's worker reads its brief from the row; a terminal's is passed.
167
169
  db.prepare(
168
- `UPDATE sessions SET task_prompt = ?, task_brief = ?, task_status = 'running', name_source = 'user',
169
- claude_session_id = ?, moved_from = ? WHERE id = ?`
170
- ).run(bundle.prompt, brief, claude?.sessionId ?? null, bundle.moveId, id);
170
+ `UPDATE sessions SET name_source = 'user', claude_session_id = ? WHERE id = ?`
171
+ ).run(claude?.sessionId ?? null, id);
172
+ setTask(db, id, {
173
+ task_prompt: bundle.prompt,
174
+ task_brief: brief,
175
+ task_status: "running",
176
+ moved_from: bundle.moveId,
177
+ });
171
178
  if (bundle.chat) unpackChat(id, bundle.chat, paths);
179
+ if (bundle.planWait) setPlan(db, id, { plan_phase: "awaiting" });
172
180
  if (orch) {
173
- db.prepare(
174
- `UPDATE sessions SET plan_phase = ?, task_since = ? WHERE id = ?`
175
- ).run(orch.planPhase, orch.since, id);
181
+ setPlan(db, id, { plan_phase: orch.planPhase });
182
+ setTask(db, id, { task_since: orch.since });
176
183
  // Held for the orchestrator's device. The orchestrator's own state
177
184
  // (started_by, its gates) never comes from the bundle: it stays on its
178
185
  // machine, and a task coming back takes it from the mirror's record.
179
186
  if (holder) {
180
187
  holdFor(id, holder, orch);
181
- db.prepare(
182
- `UPDATE sessions SET model_provider = ?, model_overrides = ? WHERE id = ?`
183
- ).run(orch.modelProvider, orch.modelOverrides, id);
188
+ db.prepare(`UPDATE sessions SET model_provider = ? WHERE id = ?`).run(
189
+ orch.modelProvider,
190
+ id
191
+ );
192
+ setTask(db, id, { model_overrides: orch.modelOverrides });
184
193
  }
185
194
  }
186
195
  carryTaskState(bundle.moveId, id);
@@ -205,7 +214,10 @@ async function arrive(
205
214
  }
206
215
  const note = arrivalNote(bundle, worktreePath, os.hostname());
207
216
  const prompt = claude ? note : `${bundle.prompt}\n\n${note}`;
208
- if (bundle.chat) {
217
+ if (bundle.planWait) {
218
+ // Waiting on its plan's approval: nothing runs until the message that
219
+ // implements it, which resumes the carried conversation.
220
+ } else if (bundle.chat) {
209
221
  // Its worker starts on the carried conversation, takes the note
210
222
  // first, then what was queued.
211
223
  await sendChat(id, {
@@ -59,12 +59,13 @@ import { finishTaskStart } from "./start";
59
59
  import { isRemoteHost } from "../hosts";
60
60
  import { DEFAULT_START_VIEW, type StartView } from "../sessions/launch";
61
61
  import { viewOf } from "./move-targets";
62
- import { isMirror, startRemoteTask } from "./remote";
63
- import { remoteTaskViews } from "./remote-views";
62
+ import { isMirror, startRemoteTask } from "./contract/start";
63
+ import { mirrorViews } from "./contract/views";
64
64
  import { chatStateNow } from "../chat/runner";
65
65
  import { saidSinceLastMessage } from "../chat/store";
66
66
 
67
67
  import { lastActiveOf } from "./last-active";
68
+ import { setPlan, setTask } from "../db/session-parts";
68
69
  export * from "./state";
69
70
  export { codeReviewGate, codeReviewRefusal } from "./code-review";
70
71
  export { signOffTask, dropTask, signingOff, mergeSettled } from "./finish";
@@ -173,6 +174,9 @@ export async function createTask(opts: {
173
174
  held?: { device: string; orchestrated: OrchestratedBundle };
174
175
  // The paired device that sent this keyed start: the only one that may hold it later.
175
176
  startedByDevice?: string | null;
177
+ // Another machine placed this plan here and decides what follows it: the
178
+ // task plans, then waits for that machine (implies planOnly).
179
+ planWaits?: boolean;
176
180
  }): Promise<Session> {
177
181
  if (opts.id) {
178
182
  const existing = queries.getSession(db).get(opts.id) as Session | undefined;
@@ -244,12 +248,27 @@ async function startTask(
244
248
  throw new Error(`${agentLabel(agent)} tasks run on this machine for now`);
245
249
  }
246
250
  if (isRemoteHost(hostId)) {
247
- if (opts.planOnly)
248
- throw new Error("A plan-only task runs on this machine for now");
249
251
  if (opts.base || opts.cardId)
250
252
  throw new Error(
251
253
  "Stacked and card tasks run on this machine only for now"
252
254
  );
255
+ if (opts.planOnly && opts.plan === false)
256
+ throw new Error(
257
+ "planOnly implies plan; it can't be combined with plan false"
258
+ );
259
+ if (opts.planOnly && view !== "chat")
260
+ throw new Error("A plan-only task needs a chat, not a terminal");
261
+ // The plan step is this machine's to run and decide: it plans there,
262
+ // stops, and what follows is chosen here (contract/plan.ts).
263
+ const planned =
264
+ !!(opts.plan || opts.planOnly) &&
265
+ taskRouting(
266
+ project.workspace_id,
267
+ view,
268
+ opts.model,
269
+ opts.plan,
270
+ opts.planOnly
271
+ ).plan;
253
272
  const session = await startRemoteTask(hostId, project, {
254
273
  id,
255
274
  prompt,
@@ -258,8 +277,11 @@ async function startTask(
258
277
  baseBranch: opts.baseBranch,
259
278
  view,
260
279
  startedBy: opts.startedBy,
280
+ plan: planned
281
+ ? { only: !!opts.planOnly, reason: opts.planReason?.trim() || null }
282
+ : undefined,
261
283
  });
262
- // The mirror's started_by stays orchestrator-only: a held-remote task is
284
+ // The mirror's started_by stays orchestrator-only: a held task on a linked machine is
263
285
  // relayed, stopped and answered through it. A plain calling task is its
264
286
  // parent and started_via only.
265
287
  recordStart(session.id, {
@@ -345,29 +367,41 @@ async function startTask(
345
367
  .run(wt.worktreePath, wt.branchName, baseBranch, null, id);
346
368
  // Everything the launch needs is on the row, so a restart can resume it.
347
369
  db.prepare(
348
- `UPDATE sessions SET task_prompt = ?, task_brief = ?, task_status = 'running', setup_status = 'running', name_source = ?, view = ?,
349
- model_provider = ?, model_overrides = ?, plan_phase = ?, plan_only = ?,
350
- plan_decided = ?, plan_reason = ? WHERE id = ?`
370
+ `UPDATE sessions SET setup_status = 'running', name_source = ?, view = ?, model_provider = ? WHERE id = ?`
351
371
  ).run(
352
- prompt,
353
- buildTaskBrief({
354
- branch: wt.branchName,
355
- baseBranch,
356
- stack: opts.base?.stack,
357
- test: declaredTest(wt.worktreePath),
358
- }),
359
372
  naming.source,
360
373
  view,
361
374
  routing.first.provider.id === ANTHROPIC
362
375
  ? null
363
376
  : routing.first.provider.id,
364
- routing.override ? JSON.stringify(routing.override) : null,
365
- routing.plan ? "planning" : null,
366
- routing.planOnly ? 1 : null,
367
- routing.planOnly ? 1 : opts.plan === undefined ? null : opts.plan ? 1 : 0,
368
- opts.planReason?.trim() || null,
369
377
  id
370
378
  );
379
+ setTask(db, id, {
380
+ task_prompt: prompt,
381
+ task_brief: buildTaskBrief({
382
+ branch: wt.branchName,
383
+ baseBranch,
384
+ stack: opts.base?.stack,
385
+ test: declaredTest(wt.worktreePath),
386
+ }),
387
+ task_status: "running",
388
+ model_overrides: routing.override
389
+ ? JSON.stringify(routing.override)
390
+ : null,
391
+ });
392
+ setPlan(db, id, {
393
+ plan_phase: routing.plan ? "planning" : null,
394
+ plan_only: routing.planOnly ? 1 : null,
395
+ plan_decided: routing.planOnly
396
+ ? 1
397
+ : opts.plan === undefined
398
+ ? null
399
+ : opts.plan
400
+ ? 1
401
+ : 0,
402
+ plan_reason: opts.planReason?.trim() || null,
403
+ plan_waits_for_caller: routing.planOnly && opts.planWaits ? 1 : null,
404
+ });
371
405
  recordStart(id, {
372
406
  startedBy: opts.startedBy ?? null,
373
407
  parentSessionId: opts.parentSessionId ?? null,
@@ -510,7 +544,7 @@ export async function taskView(session: Session): Promise<TaskView> {
510
544
  export function movedTasks(): { id: string; movedTo: string | null }[] {
511
545
  return db
512
546
  .prepare(
513
- `SELECT id, moved_to AS movedTo FROM sessions
547
+ `SELECT id, moved_to AS movedTo FROM session_rows
514
548
  WHERE task_status = 'moved' AND host_id = 'local'
515
549
  ORDER BY updated_at DESC LIMIT 500`
516
550
  )
@@ -522,7 +556,7 @@ export async function listTasks(): Promise<TaskView[]> {
522
556
  const sessions = taskSessions(false);
523
557
  const [local, remote] = await Promise.all([
524
558
  Promise.all(sessions.filter((s) => !isMirror(s)).map(taskView)),
525
- remoteTaskViews(sessions.filter(isMirror)),
559
+ mirrorViews(sessions.filter(isMirror)),
526
560
  ]);
527
561
  const costs = taskCosts(local.map((t) => t.id));
528
562
  for (const t of local) {
@@ -9,7 +9,7 @@ import { db } from "../db";
9
9
  export function launchPending(sessionId: string): boolean {
10
10
  const row = db
11
11
  .prepare(
12
- `SELECT 1 FROM sessions WHERE id = ? AND task_status = 'running'
12
+ `SELECT 1 FROM session_rows WHERE id = ? AND task_status = 'running'
13
13
  AND setup_status IN ('running', 'held')`
14
14
  )
15
15
  .get(sessionId);
@@ -57,6 +57,10 @@ export interface TaskBundle {
57
57
  // What a task started by an orchestrator carries besides its work; absent
58
58
  // for any other task, or from a machine that predates these moves.
59
59
  orchestrated?: OrchestratedBundle;
60
+ // A plan written and waiting for the machine that placed it (it is moving
61
+ // there to be built): the plan file, which is never in git. The arriving
62
+ // machine runs no turn; the approval that follows starts one.
63
+ planWait?: { file: string | null };
60
64
  }
61
65
 
62
66
  export interface OrchestratedBundle {
@@ -70,7 +74,7 @@ export interface OrchestratedBundle {
70
74
  // machine without that provider refuses the task rather than change it.
71
75
  modelProvider: string | null;
72
76
  modelOverrides: string | null;
73
- planPhase: "planning" | "implementing" | null;
77
+ planPhase: "planning" | "awaiting" | "implementing" | null;
74
78
  // .agentos/plan.md, which is never in git.
75
79
  planFile: string | null;
76
80
  // The orchestrator that holds the task (its id and name on the machine
@@ -153,7 +157,7 @@ function validateChat(c: ChatBundle): ChatBundle {
153
157
  return { ...c, plan: !!c.plan };
154
158
  }
155
159
 
156
- const PHASES = ["planning", "implementing"];
160
+ const PHASES = ["planning", "awaiting", "implementing"];
157
161
 
158
162
  export function validateOrchestrated(
159
163
  o: OrchestratedBundle
@@ -196,6 +200,16 @@ export function validateOrchestrated(
196
200
  };
197
201
  }
198
202
 
203
+ function validatePlanWait(w: unknown): TaskBundle["planWait"] {
204
+ if (w === undefined || w === null) return undefined;
205
+ const file = (w as { file?: unknown }).file;
206
+ if (typeof w !== "object" || !(file === null || isStr(file)))
207
+ throw new Error("Bad plan");
208
+ if ((file?.length ?? 0) > MAX_PLAN_BYTES)
209
+ throw new Error("The plan is too large to move");
210
+ return { file };
211
+ }
212
+
199
213
  export function validateBundle(b: TaskBundle): TaskBundle {
200
214
  if (!b || typeof b !== "object") throw new Error("No task to import");
201
215
  if (!isSessionId(b.moveId)) throw new Error("Bad move id");
@@ -228,6 +242,7 @@ export function validateBundle(b: TaskBundle): TaskBundle {
228
242
  orchestrated: b.orchestrated
229
243
  ? validateOrchestrated(b.orchestrated)
230
244
  : undefined,
245
+ planWait: validatePlanWait(b.planWait),
231
246
  chat: b.chat ? validateChat(b.chat) : undefined,
232
247
  name: b.name.slice(0, 200),
233
248
  from: String(b.from || "another machine")
@@ -10,6 +10,7 @@
10
10
  import type Database from "better-sqlite3";
11
11
  import { db } from "../db";
12
12
  import { orchestratorOf } from "./starter";
13
+ import { fillMissing } from "../db/session-parts";
13
14
 
14
15
  // table, column, and how the id sits in it: the whole value, `task:<id>`, or
15
16
  // the tail of a `<condition>:<id>` key.
@@ -100,15 +101,22 @@ export function carryTaskState(
100
101
  .run(fromId.length, toId, `%:${fromId}`);
101
102
  }
102
103
  const from = handle
103
- .prepare(`SELECT * FROM sessions WHERE id = ?`)
104
+ .prepare(`SELECT * FROM session_rows WHERE id = ?`)
104
105
  .get(fromId) as Record<string, unknown> | undefined;
105
106
  if (!from) return;
106
- const sets = [...CARRIED_COLUMNS, "task_since"]
107
- .map((c) => `${c} = COALESCE(${c}, @${c})`)
108
- .join(", ");
109
- handle.prepare(`UPDATE sessions SET ${sets} WHERE id = @id`).run({
107
+ const carried = {
110
108
  ...orchestrationOf(from),
111
109
  task_since: (from.task_since as string | null) ?? from.created_at ?? null,
112
- id: toId,
113
- });
110
+ } as Record<string, string | number | null>;
111
+ handle
112
+ .prepare(
113
+ `UPDATE sessions SET started_by = COALESCE(started_by, @started_by),
114
+ model_provider = COALESCE(model_provider, @model_provider) WHERE id = @id`
115
+ )
116
+ .run({
117
+ started_by: carried.started_by,
118
+ model_provider: carried.model_provider,
119
+ id: toId,
120
+ });
121
+ fillMissing(handle, toId, carried);
114
122
  }