@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
@@ -1,25 +1,22 @@
1
1
  /**
2
- * An orchestrator's calls on a task it moved to a linked machine, which that
3
- * machine's AgentOS answers for the device that sent the task (held.ts). The
4
- * orchestrator polls and calls; the other machine never reaches back.
2
+ * An orchestrator's calls on a task it put on a linked machine, which that
3
+ * machine's AgentOS answers for the device that sent the task (held.ts). A
4
+ * machine that doesn't hold the task yet is asked to, once, and the call
5
+ * repeated. The orchestrator polls and calls; the other machine never reaches
6
+ * back.
5
7
  */
6
8
 
7
- import { db, type Session } from "../db";
9
+ import { db, type Session } from "../../db";
8
10
  import {
9
11
  HostApiError,
10
- cleanRemoteBlock,
11
- cleanRemoteText,
12
- hostApi,
13
12
  hostLink,
14
13
  requireHostLink,
15
- } from "../hosts/remote-api";
16
- import type { Delivery } from "../bus/delivery";
17
- import { wakeLine } from "../bus/format";
18
- import { orchestratedStart } from "./held";
19
- import { orchestratorOf } from "./starter";
20
-
21
- const path = (id: string, leaf: string) =>
22
- `/api/tasks/${encodeURIComponent(id)}/${leaf}`;
14
+ } from "../../hosts/remote-api";
15
+ import type { Delivery } from "../../bus/delivery";
16
+ import { wakeLine } from "../../bus/format";
17
+ import { orchestratedStart } from "../held";
18
+ import { orchestratorOf } from "../starter";
19
+ import * as client from "./client";
23
20
 
24
21
  /**
25
22
  * A task its orchestrator put on a linked machine, whether by moving it or
@@ -54,16 +51,11 @@ async function adoptHeld(task: Held): Promise<boolean> {
54
51
  if (adopted.has(task.id)) return false;
55
52
  adopted.set(task.id, now);
56
53
  try {
57
- const link = requireHostLink(task.host_id);
58
- await hostApi(link, path(task.id, "hold"), {
59
- body: {
60
- orchestrated: orchestratedStart(
61
- task.started_by!,
62
- task.task_since ?? null
63
- ),
64
- },
65
- timeout: 20000,
66
- });
54
+ await client.hold(
55
+ requireHostLink(task.host_id),
56
+ task.id,
57
+ orchestratedStart(task.started_by!, task.task_since ?? null)
58
+ );
67
59
  } catch {
68
60
  return false;
69
61
  }
@@ -72,6 +64,8 @@ async function adoptHeld(task: Held): Promise<boolean> {
72
64
  return true;
73
65
  }
74
66
 
67
+ // compat: task-contract-1. A task the queue placed before machines held them
68
+ // is unheld there; drop this heal once every linked machine holds on start.
75
69
  /** The call, held first if that machine says it isn't, once. */
76
70
  async function withHold<T>(task: Held, call: () => Promise<T>): Promise<T> {
77
71
  try {
@@ -142,16 +136,13 @@ export async function relayToHeldTask(
142
136
  try {
143
137
  const link = requireHostLink(to.host_id);
144
138
  const send = () =>
145
- hostApi<{ state?: unknown }>(link, path(to.id, "message"), {
146
- body: {
147
- id: messageId ? `bus-${messageId}` : undefined,
148
- line,
149
- from: fromName,
150
- },
151
- timeout: 20000,
139
+ client.message(link, to.id, {
140
+ id: messageId ? `bus-${messageId}` : undefined,
141
+ line,
142
+ from: fromName,
152
143
  });
153
- const { state } = await (heal ? withHold(to, send) : send());
154
- return { state: state === "queued" ? "queued" : "delivered" };
144
+ const state = await (heal ? withHold(to, send) : send());
145
+ return { state };
155
146
  } catch (error) {
156
147
  const why = error instanceof Error ? error.message : String(error);
157
148
  return { state: "failed", why: why.slice(0, 300) };
@@ -164,15 +155,9 @@ export async function heldChatTail(
164
155
  lines: number
165
156
  ): Promise<string | null> {
166
157
  const link = requireHostLink(task.host_id);
167
- const { text } = await withHold(task, () =>
168
- hostApi<{ text?: unknown }>(
169
- link,
170
- `${path(task.id, "chat-tail")}?lines=${lines}`,
171
- { timeout: 10000 }
172
- )
173
- ).catch(() => ({ text: undefined }));
174
- // Whole lines, as a local read has them (read.ts caps and trims them).
175
- return typeof text === "string" ? cleanRemoteBlock(text, 16000) : null;
158
+ return withHold(task, () => client.chatTail(link, task.id, lines)).catch(
159
+ () => null
160
+ );
176
161
  }
177
162
 
178
163
  /**
@@ -184,38 +169,22 @@ export async function heldChatFacts(
184
169
  task: Held
185
170
  ): Promise<{ blocked: string | null; turn: string | null } | null> {
186
171
  const link = requireHostLink(task.host_id);
187
- const res = await withHold(task, () =>
188
- hostApi<{ blocked?: unknown; turn?: unknown }>(
189
- link,
190
- path(task.id, "chat-facts"),
191
- { timeout: 10000 }
192
- )
193
- ).catch(() => null);
194
- if (!res || (res.blocked !== null && typeof res.blocked !== "string"))
195
- return null;
196
- if (res.turn !== null && typeof res.turn !== "string") return null;
197
- return {
198
- blocked: res.blocked ? cleanRemoteBlock(res.blocked, 2000) : null,
199
- turn: res.turn,
200
- };
172
+ return withHold(task, () => client.chatFacts(link, task.id)).catch(
173
+ () => null
174
+ );
201
175
  }
202
176
 
203
177
  /** Stops its agent there; its conversation and worktree are kept. */
204
178
  export async function stopHeldTask(task: Held): Promise<string> {
205
179
  const link = requireHostLink(task.host_id);
206
- const { text } = await withHold(task, () =>
207
- hostApi<{ text?: unknown }>(link, path(task.id, "stop"), {
208
- body: {},
209
- timeout: 30000,
210
- })
180
+ return (
181
+ (await withHold(task, () => client.stop(link, task.id))) ||
182
+ `Stopped ${task.name}.`
211
183
  );
212
- return cleanRemoteText(text) || `Stopped ${task.name}.`;
213
184
  }
214
185
 
215
186
  /** Done there: its agent stops and its worktree goes. Never a merge. */
216
187
  export async function doneHeldTask(task: Held): Promise<void> {
217
188
  const link = requireHostLink(task.host_id);
218
- await withHold(task, () =>
219
- hostApi(link, path(task.id, "done"), { body: {}, timeout: 120000 })
220
- );
189
+ await withHold(task, () => client.done(link, task.id));
221
190
  }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * How a task runs on a linked machine: one client for its API
3
+ * (client.ts), one authorization rule per route (authorize.ts), one loop that
4
+ * keeps mirrors current (sync.ts). Local code paths call these through the
5
+ * entry points they already had (signOff, dropTask, deliver, ...).
6
+ */
7
+
8
+ export * as taskClient from "./client";
9
+ export { TASK_CAPABILITIES, type HostTasks } from "./types";
10
+ export {
11
+ isMirror,
12
+ mirrorTask,
13
+ startRemoteTask,
14
+ setMirrorStatus,
15
+ } from "./start";
16
+ export { mergeThere, dropThere } from "./actions";
17
+ export { mirrorViews } from "./views";
18
+ export { syncMirrors } from "./sync";
19
+ export { taskRoute, refuse, callerRefusal, type Rule } from "./authorize";
20
+ export {
21
+ isHeldRemote,
22
+ relayToHeldTask,
23
+ heldChatTail,
24
+ heldChatFacts,
25
+ stopHeldTask,
26
+ doneHeldTask,
27
+ flushUndelivered,
28
+ } from "./held-calls";
@@ -0,0 +1,7 @@
1
+ import path from "path";
2
+ import { plansDir } from "../../plans/features";
3
+
4
+ // A plan written on a linked machine, copied here so this machine's
5
+ // orchestrator and plan view can read it (contract/plan.ts).
6
+ export const remotePlanPath = (sessionId: string) =>
7
+ path.join(plansDir(), "remote", `${sessionId}.md`);
@@ -0,0 +1,274 @@
1
+ /**
2
+ * A plan placed on a linked machine (queue placement, remote.ts). Planning
3
+ * only reads code, so it runs wherever there is room; what follows the plan
4
+ * is decided here, on the machine whose scheduler, needs and workspace apply.
5
+ * The other machine plans, stops, and waits. This machine then copies the
6
+ * plan, says it is written, and either approves it there (the task is built
7
+ * where it planned) or moves the task here once this machine can take it
8
+ * (it needs a Mac, or its plan is split into pieces, which register here).
9
+ * Moving the task reuses the ordinary move (move-flow.ts).
10
+ */
11
+
12
+ import fs from "fs/promises";
13
+ import path from "path";
14
+ import { db, type Session } from "../../db";
15
+ import { isRemoteHost } from "../../hosts";
16
+ import { peerSource } from "../../hosts/peer-load";
17
+ import { hostLink, requireHostLink } from "../../hosts/remote-api";
18
+ import { LOCAL, machineCapabilities } from "../../load/machines";
19
+ import {
20
+ noApprovalsOn,
21
+ recordAutoApproval,
22
+ } from "../../approvals/no-approvals";
23
+ import { implementArrived, planNoteFor } from "../../models/plan-step";
24
+ import { sessionWorkspace } from "../../models/resolve";
25
+ import { queueEvent } from "../../orchestrator/events";
26
+ import { brakesOn } from "../../orchestrator/brakes";
27
+ import { isPaused } from "../../orchestrator/pause";
28
+ import { getProject } from "../../projects";
29
+ import { planFor, splitOf } from "../../plans/features";
30
+ import { moveTask } from "../move-flow";
31
+ import * as client from "./client";
32
+ import { meetsNeeds, needsOf } from "../placement";
33
+ import { remotePlanPath } from "./plan-path";
34
+ import { DEFAULT_PRIORITY, isPriority } from "../schedule-types";
35
+ import { localHolds } from "../hold-checks";
36
+ import { expandHome } from "../session";
37
+ import { orchestratorOf } from "../starter";
38
+ import { setPlan } from "../../db/session-parts";
39
+
40
+ export { remotePlanPath };
41
+
42
+ const getSession = (id: string) =>
43
+ db.prepare(`SELECT * FROM session_rows WHERE id = ?`).get(id) as
44
+ | Session
45
+ | undefined;
46
+
47
+ async function readCopy(id: string): Promise<string | null> {
48
+ try {
49
+ return await fs.readFile(remotePlanPath(id), "utf8");
50
+ } catch {
51
+ return null;
52
+ }
53
+ }
54
+
55
+ function checkedSplit(s: Session, text: string | null) {
56
+ try {
57
+ return text ? splitOf(s, text) : null;
58
+ } catch {
59
+ return null;
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Where a written plan is built: "there" (approved on the machine that
65
+ * planned it) or "home" (moved here first). Pieces are checked against this
66
+ * machine's projects, and work that needs a Mac can't be built on a machine
67
+ * without one, so those come home; everything else stays where it is, since
68
+ * the machine that planned it is the one with room.
69
+ */
70
+ export function implementTarget(
71
+ s: Session,
72
+ split: ReturnType<typeof splitOf>
73
+ ): "there" | "home" {
74
+ const project = s.project_id ? getProject(s.project_id) : null;
75
+ // The project lives there: nothing here to build it in.
76
+ if (!project || isRemoteHost(project.host_id)) return "there";
77
+ if (split) return "home";
78
+ const { needs } = needsOf({
79
+ projectId: project.id,
80
+ projectPath: expandHome(project.working_directory),
81
+ prompt: s.task_prompt ?? "",
82
+ });
83
+ const caps = peerSource(s.host_id!, Date.now()).capabilities;
84
+ if (meetsNeeds(needs, caps)) return "there";
85
+ return meetsNeeds(needs, machineCapabilities()) ? "home" : "there";
86
+ }
87
+
88
+ /**
89
+ * A mirror that is planning, its machine's listing of it in hand: if its plan
90
+ * is written there, copy it, say so, and carry on for a plan-first task.
91
+ */
92
+ export async function planWritten(
93
+ s: Session,
94
+ view: { planReady?: boolean } | undefined
95
+ ): Promise<void> {
96
+ const link = hostLink(s.host_id);
97
+ if (!link || !view?.planReady) return;
98
+ const text = await client.plan(link, s.id);
99
+ const claimed = setPlan(
100
+ db,
101
+ s.id,
102
+ { plan_phase: "awaiting" },
103
+ { plan_phase: "planning" }
104
+ );
105
+ if (!claimed) return;
106
+ if (text !== null) {
107
+ await fs.mkdir(path.dirname(remotePlanPath(s.id)), { recursive: true });
108
+ await fs.writeFile(remotePlanPath(s.id), text);
109
+ }
110
+ const fresh = getSession(s.id)!;
111
+ const split = checkedSplit(fresh, text);
112
+ const errors = split?.errors ?? [];
113
+ // As for a task planned here: a plan-first task goes on by itself, a
114
+ // plan-only one waits unless no-approvals is on, and pieces that couldn't
115
+ // be registered always hold it.
116
+ const auto = !!fresh.plan_only && noApprovalsOn();
117
+ const goOn = (!fresh.plan_only || auto) && !errors.length;
118
+ const target = goOn ? implementTarget(fresh, split) : null;
119
+ const next =
120
+ target === "there"
121
+ ? `implementing on ${hostLink(s.host_id)?.hostName ?? "that machine"}`
122
+ : target === "home"
123
+ ? "moving here to implement once this machine is free"
124
+ : "waiting for approval, not implementing (approve_plan to go ahead, drop to reject)";
125
+ const note = planNoteFor(fresh, remotePlanPath(s.id), next);
126
+ setPlan(db, s.id, { plan_note: note });
127
+ const workspaceId = sessionWorkspace(fresh);
128
+ if (orchestratorOf(fresh.started_by) && workspaceId) {
129
+ queueEvent(workspaceId, `plan-done:${s.id}`, s.id, note);
130
+ if (errors.length)
131
+ queueEvent(
132
+ workspaceId,
133
+ `plan-pieces:${s.id}`,
134
+ s.id,
135
+ `Plan of "${s.name}": couldn't register its pieces: ${errors.join("; ")}. It is held, not building. Message the task to fix the plan, or approve_plan to build it as one piece.`
136
+ );
137
+ }
138
+ if (!goOn) {
139
+ try {
140
+ const planned = text ? planFor(fresh, text) : null;
141
+ (await import("../../notify/push/events")).pushPlanAwaiting(
142
+ s.id,
143
+ s.name,
144
+ planned?.pieces.length ?? 1
145
+ );
146
+ } catch (e) {
147
+ console.error("plan push failed:", e);
148
+ }
149
+ return;
150
+ }
151
+ await continuePlanThere(fresh);
152
+ if (auto)
153
+ recordAutoApproval({
154
+ source: "plan",
155
+ workspaceId,
156
+ sessionId: s.id,
157
+ subject: `plan for ${s.name}`,
158
+ detail: "implementing without waiting for approve_plan",
159
+ });
160
+ }
161
+
162
+ /**
163
+ * Carries a written plan on: approves it on the machine that planned it, or
164
+ * hands it to the move home. Returns what it will implement on.
165
+ */
166
+ export async function continuePlanThere(
167
+ s: Session,
168
+ message?: string
169
+ ): Promise<string> {
170
+ const link = requireHostLink(s.host_id!);
171
+ const split = checkedSplit(s, await readCopy(s.id));
172
+ if (implementTarget(s, split) === "there") {
173
+ const out = await client.approvePlan(link, s.id, message);
174
+ setPlan(
175
+ db,
176
+ s.id,
177
+ { plan_phase: "implementing" },
178
+ { plan_phase: "awaiting" }
179
+ );
180
+ client.forget(link.hostId);
181
+ return out.model ?? link.hostName;
182
+ }
183
+ setPlan(
184
+ db,
185
+ s.id,
186
+ { plan_phase: "handoff", plan_handoff_message: message ?? null },
187
+ { plan_phase: "awaiting" }
188
+ );
189
+ const workspaceId = sessionWorkspace(s);
190
+ if (orchestratorOf(s.started_by) && workspaceId)
191
+ queueEvent(
192
+ workspaceId,
193
+ `plan-handoff:${s.id}`,
194
+ s.id,
195
+ `Task "${s.name}": plan approved; it moves here to be built once this machine is free (${split ? "its plan is in pieces" : "it needs this machine"}).`
196
+ );
197
+ void handoffHome(s.id).catch((e) =>
198
+ console.error(`Moving ${s.id} here failed:`, e)
199
+ );
200
+ return "this machine, once it is free";
201
+ }
202
+
203
+ export const handingOff = new Set<string>();
204
+
205
+ export async function finishArrival(
206
+ id: string,
207
+ message?: string
208
+ ): Promise<boolean> {
209
+ await implementArrived(id, message || undefined);
210
+ setPlan(db, id, { plan_handoff_message: null });
211
+ return true;
212
+ }
213
+
214
+ // Fired, not awaited: a move takes minutes and the queue tick must not wait.
215
+ export function fire(id: string, run: () => Promise<unknown>) {
216
+ if (handingOff.has(id)) return;
217
+ void run().catch((e) => console.error(`Moving ${id} here failed:`, e));
218
+ }
219
+
220
+ /**
221
+ * Moves an approved plan's task here, when this machine can take it, and
222
+ * starts implementing. False while the machine is loaded (tried again on the
223
+ * next look) or another call has it.
224
+ */
225
+ export async function handoffHome(
226
+ id: string,
227
+ now = Date.now()
228
+ ): Promise<boolean> {
229
+ const s = getSession(id);
230
+ if (!s || s.plan_phase !== "handoff" || !isRemoteHost(s.host_id))
231
+ return false;
232
+ if (handingOff.has(id)) return false;
233
+ const project = s.project_id ? getProject(s.project_id) : null;
234
+ if (!project) return false;
235
+ // What an orchestrator started stays under its Pause and brakes.
236
+ const workspaceId = sessionWorkspace(s);
237
+ if (workspaceId && orchestratorOf(s.started_by)) {
238
+ if (isPaused(workspaceId)) return false;
239
+ if ((await brakesOn(workspaceId)).length) return false;
240
+ }
241
+ const { needs } = needsOf({
242
+ projectId: project.id,
243
+ projectPath: expandHome(project.working_directory),
244
+ prompt: s.task_prompt ?? "",
245
+ });
246
+ const holds = localHolds(
247
+ {
248
+ needs,
249
+ priority: isPriority(s.priority) ? s.priority : DEFAULT_PRIORITY,
250
+ },
251
+ now
252
+ );
253
+ if (holds.length) return false;
254
+ handingOff.add(id);
255
+ try {
256
+ const message = s.plan_handoff_message ?? undefined;
257
+ const edits = s.plan_edits ?? null;
258
+ const arrived = await moveTask(id, LOCAL);
259
+ // The arrived row keeps the pending hand-off until implementing has
260
+ // started, so a restart in between is picked up by syncMirrors.
261
+ setPlan(db, arrived.id, {
262
+ plan_edits: edits ?? undefined,
263
+ plan_handoff_message: message ?? "",
264
+ });
265
+ handingOff.add(arrived.id);
266
+ try {
267
+ return await finishArrival(arrived.id, message);
268
+ } finally {
269
+ handingOff.delete(arrived.id);
270
+ }
271
+ } finally {
272
+ handingOff.delete(id);
273
+ }
274
+ }
@@ -0,0 +1,26 @@
1
+ /** The task routes a linked machine's AgentOS answers, and how long to wait for each. */
2
+
3
+ export const taskPath = (id: string, leaf: string) =>
4
+ `/api/tasks/${encodeURIComponent(id)}/${leaf}`;
5
+
6
+ export const LIST = "/api/tasks";
7
+ export const IMPORT = "/api/tasks/import";
8
+ export const HELD_REPLIES = "/api/tasks/held-replies";
9
+ export const arrivedPath = (from: string) =>
10
+ `/api/tasks/arrived?from=${encodeURIComponent(from)}`;
11
+
12
+ export const TIMEOUT = {
13
+ list: 8000,
14
+ start: 180000,
15
+ plan: 30000,
16
+ approvePlan: 60000,
17
+ message: 20000,
18
+ hold: 20000,
19
+ chat: 10000,
20
+ stop: 30000,
21
+ done: 120000,
22
+ merge: 300000,
23
+ replies: 8000,
24
+ exportOut: 300000,
25
+ importBundle: 600000,
26
+ } as const;
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Tasks that run on another machine's AgentOS. That machine schedules them;
3
+ * this one keeps a mirror row (same id, the host's id) so the sidebar lists
4
+ * them and the terminal attaches through that machine's AgentOS.
5
+ */
6
+
7
+ import { db, type Project, type Session } from "../../db";
8
+ import { isRemoteHost } from "../../hosts";
9
+ import { requireHostLink } from "../../hosts/remote-api";
10
+ import { orchestratedStart } from "../held";
11
+ import { projectRef } from "../project-ref";
12
+ import { orchestratorOf } from "../starter";
13
+ import type { TaskStatus } from "../state";
14
+ import * as client from "./client";
15
+ import { setPlan, setTask, startTaskClock } from "../../db/session-parts";
16
+
17
+ export function isMirror(session: Session): boolean {
18
+ return isRemoteHost(session.host_id);
19
+ }
20
+
21
+ export function mirrorTask(
22
+ hostId: string,
23
+ projectId: string | null,
24
+ remote: Session,
25
+ startedBy?: string | null
26
+ ): void {
27
+ db.transaction(() => {
28
+ const inserted = db
29
+ .prepare(
30
+ `INSERT INTO sessions (id, name, tmux_name, working_directory, model, group_path,
31
+ agent_type, auto_approve, project_id, host_id, worktree_path, branch_name,
32
+ base_branch, name_source, view)
33
+ VALUES (?, ?, ?, ?, ?, 'sessions', 'claude', 1, ?, ?, ?, ?, ?, 'user', ?)
34
+ ON CONFLICT(id) DO NOTHING`
35
+ )
36
+ .run(
37
+ remote.id,
38
+ remote.name,
39
+ remote.tmux_name,
40
+ remote.working_directory,
41
+ remote.model,
42
+ projectId,
43
+ hostId,
44
+ remote.worktree_path,
45
+ remote.branch_name,
46
+ remote.base_branch,
47
+ // As that machine runs it: one that predates chat tasks says terminal.
48
+ remote.view === "chat" ? "chat" : "terminal"
49
+ );
50
+ if (inserted.changes)
51
+ setTask(db, remote.id, {
52
+ task_prompt: remote.task_prompt,
53
+ task_status: "running",
54
+ });
55
+ // What carryTaskState gives a moved task's mirror: the orchestrator that
56
+ // holds it, and when it began (so its PR is judged from then).
57
+ if (startedBy) {
58
+ db.prepare(
59
+ `UPDATE sessions SET started_by = COALESCE(started_by, ?) WHERE id = ?`
60
+ ).run(startedBy, remote.id);
61
+ startTaskClock(db, remote.id);
62
+ }
63
+ })();
64
+ }
65
+
66
+ export async function startRemoteTask(
67
+ hostId: string,
68
+ project: Project,
69
+ opts: {
70
+ id: string;
71
+ prompt: string;
72
+ name?: string;
73
+ model?: string;
74
+ baseBranch?: string;
75
+ view?: "chat" | "terminal";
76
+ // The orchestrator that started it: that machine holds the task for it.
77
+ startedBy?: string | null;
78
+ // It plans first: there it plans, stops, and waits for this machine.
79
+ plan?: { only: boolean; reason: string | null };
80
+ }
81
+ ): Promise<Session> {
82
+ const link = requireHostLink(hostId);
83
+ const { startedBy: by, plan, ...rest } = opts;
84
+ const startedBy = orchestratorOf(by);
85
+ if (startedBy)
86
+ await client.requireCapability(
87
+ link,
88
+ "held-start",
89
+ "an orchestrator's task",
90
+ "hold"
91
+ );
92
+ if (plan)
93
+ await client.requireCapability(link, "remote-plan", "a plan step", "plan");
94
+ const { session } = await client.start(link, {
95
+ project: await projectRef(project),
96
+ ...rest,
97
+ ...(plan && {
98
+ plan: true,
99
+ plan_only: true,
100
+ plan_waits: true,
101
+ plan_reason: plan.reason ?? undefined,
102
+ }),
103
+ orchestrated: startedBy ? orchestratedStart(startedBy) : undefined,
104
+ });
105
+ mirrorTask(hostId, project.id, session, startedBy);
106
+ if (plan)
107
+ setPlan(db, session.id, {
108
+ plan_phase: "planning",
109
+ plan_only: plan.only ? 1 : null,
110
+ plan_decided: 1,
111
+ plan_reason: plan.reason,
112
+ plan_waits_for_caller: 1,
113
+ });
114
+ client.forget(hostId);
115
+ return db
116
+ .prepare(`SELECT * FROM session_rows WHERE id = ?`)
117
+ .get(session.id) as Session;
118
+ }
119
+
120
+ export function setMirrorStatus(id: string, status: TaskStatus): void {
121
+ setTask(db, id, { task_status: status }, { task_status: "running" });
122
+ }