@saadnvd1/agent-os 0.3.517 → 0.3.518

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 (46) hide show
  1. package/README.md +20 -3
  2. package/app/api/tasks/[id]/arm/route.ts +5 -0
  3. package/app/api/tasks/[id]/chat-tail/route.ts +15 -0
  4. package/app/api/tasks/[id]/done/route.ts +15 -0
  5. package/app/api/tasks/[id]/export/route.ts +6 -1
  6. package/app/api/tasks/[id]/merge/route.ts +9 -1
  7. package/app/api/tasks/[id]/message/route.ts +39 -0
  8. package/app/api/tasks/[id]/moved/route.ts +6 -1
  9. package/app/api/tasks/[id]/resume/route.ts +6 -1
  10. package/app/api/tasks/[id]/stop/route.ts +14 -0
  11. package/app/api/tasks/import/route.ts +11 -1
  12. package/lib/bus/index.ts +15 -2
  13. package/lib/db/migrations.ts +25 -0
  14. package/lib/db/types.ts +7 -0
  15. package/lib/done/bulk.ts +8 -0
  16. package/lib/done/index.ts +15 -0
  17. package/lib/models/plan-step.ts +13 -3
  18. package/lib/orchestrator/act.ts +41 -1
  19. package/lib/orchestrator/armed.ts +6 -1
  20. package/lib/orchestrator/brakes.ts +9 -0
  21. package/lib/orchestrator/brief.ts +2 -1
  22. package/lib/orchestrator/facts.ts +72 -21
  23. package/lib/orchestrator/read.ts +14 -2
  24. package/lib/orchestrator/serve.ts +6 -0
  25. package/lib/orchestrator/shell-guard.ts +15 -0
  26. package/lib/orchestrator/tool-names.ts +1 -0
  27. package/lib/orchestrator/tool-schemas.ts +11 -0
  28. package/lib/orchestrator/tools.ts +1 -0
  29. package/lib/sessions/stop.ts +15 -1
  30. package/lib/tasks/branch-worktree.ts +16 -1
  31. package/lib/tasks/finish.ts +5 -1
  32. package/lib/tasks/held-remote.ts +80 -0
  33. package/lib/tasks/held-routes.ts +33 -0
  34. package/lib/tasks/held.ts +28 -0
  35. package/lib/tasks/import.ts +60 -7
  36. package/lib/tasks/index.ts +11 -1
  37. package/lib/tasks/last-active.ts +25 -0
  38. package/lib/tasks/move-bundle.ts +53 -1
  39. package/lib/tasks/move-carry.ts +102 -0
  40. package/lib/tasks/move-flow.ts +34 -7
  41. package/lib/tasks/move-guard.ts +17 -13
  42. package/lib/tasks/move-recover.ts +2 -0
  43. package/lib/tasks/move.ts +34 -3
  44. package/lib/tasks/remote.ts +3 -0
  45. package/lib/tasks/session.ts +1 -1
  46. package/package.json +1 -1
@@ -7,7 +7,7 @@
7
7
 
8
8
  import { db, type Session, type StackItemStatus } from "../db";
9
9
  import { statusDetector } from "../status-detector";
10
- import { hostLink } from "../hosts/remote-api";
10
+ import { cleanRemoteText, hostLink } from "../hosts/remote-api";
11
11
  import { peerStatus } from "../hosts/peer-sessions";
12
12
  import { chatCompacting, chatState } from "../chat/runner";
13
13
  import { chatActivityLine, chatStall, type Stall } from "../chat/activity";
@@ -16,11 +16,16 @@ import { needsYou } from "../needs-you";
16
16
  import { programSummary } from "../program-status/store";
17
17
  import { sessionRowInfo } from "../session-meta";
18
18
  import { taskView, type TaskPR, type TaskState } from "../tasks";
19
- import { storedPR } from "../tasks/session";
19
+ import { prFor, storedPR } from "../tasks/session";
20
+ import { lastActiveOf } from "../tasks/last-active";
21
+ import { hostTasks } from "../tasks/remote";
22
+ import { deriveTaskState } from "../tasks";
23
+ import { waitingState } from "./task-state";
20
24
  import { commitTime, repoOf } from "./repo";
21
25
  import { CHECKLESS_MS, checklessFor, ciSettleIn } from "./task-state";
22
26
  import { repoHasCI } from "./repo-ci";
23
27
  import { markStatusDirty } from "../status/dirty";
28
+ import { queueEvent } from "./events";
24
29
 
25
30
  export type FactStatus = "running" | "waiting" | "idle" | "dead";
26
31
 
@@ -68,9 +73,6 @@ export interface SessionFacts {
68
73
  } | null;
69
74
  }
70
75
 
71
- const sqliteMs = (t: string | null | undefined) =>
72
- t ? Date.parse(`${t.replace(" ", "T")}Z`) || 0 : 0;
73
-
74
76
  // Live sessions in the workspace's projects, plus tasks merged in the last
75
77
  // hour so their merge still reads. Never an orchestrator or an archived one.
76
78
  export function workspaceSessions(
@@ -83,6 +85,8 @@ export function workspaceSessions(
83
85
  WHERE p.workspace_id = ? AND s.role IS NULL AND s.archived_at IS NULL
84
86
  -- A linked machine's own sessions are its orchestrator's to run.
85
87
  AND s.peer_mirror = 0
88
+ -- A task another machine's orchestrator moved here is held for it.
89
+ AND s.held_by_device IS NULL
86
90
  AND (s.task_status IS NULL OR s.task_status = 'running'
87
91
  OR (s.task_status = 'merged' AND s.updated_at > datetime('now', '-1 hour')))
88
92
  ORDER BY s.created_at`
@@ -121,17 +125,6 @@ function stackOf(sessionId: string): SessionFacts["stack"] {
121
125
  };
122
126
  }
123
127
 
124
- function latestChatAt(sessionId: string): number {
125
- const row = db
126
- .prepare(
127
- `SELECT data FROM chat_items WHERE session_id = ? ORDER BY seq DESC LIMIT 1`
128
- )
129
- .get(sessionId) as { data: string } | undefined;
130
- return row
131
- ? ((JSON.parse(row.data) as { createdAt?: number }).createdAt ?? 0)
132
- : 0;
133
- }
134
-
135
128
  export async function statusOf(s: Session): Promise<{
136
129
  status: FactStatus;
137
130
  activity: string | null;
@@ -251,6 +244,7 @@ async function taskFacts(
251
244
  const state = s.task_status === "moved" ? "done" : s.task_status;
252
245
  return { state, pr, blocked: null };
253
246
  }
247
+ if (hostLink(s.host_id)) return remoteTaskFacts(workspaceId, s);
254
248
  const view = await taskView(s);
255
249
  return {
256
250
  state: view.state,
@@ -261,6 +255,67 @@ async function taskFacts(
261
255
  };
262
256
  }
263
257
 
258
+ // A task another machine runs: its state is that machine's word (the same
259
+ // reading the gates use), and the PR is read here, from GitHub, by branch.
260
+ // What it can't say reads as blocked, never as clear.
261
+ async function remoteTaskFacts(
262
+ workspaceId: string,
263
+ s: Session
264
+ ): Promise<NonNullable<SessionFacts["task"]>> {
265
+ const [pr, waiting] = await Promise.all([
266
+ prFor(s).catch(() => null),
267
+ waitingState(s),
268
+ ]);
269
+ await announceRemotePlan(workspaceId, s);
270
+ const status = peerStatus(s.host_id, s.id) ?? "running";
271
+ const state = deriveTaskState({
272
+ taskStatus: "running",
273
+ sessionStatus: status,
274
+ pr,
275
+ blocked: waiting.blocked !== null,
276
+ });
277
+ return {
278
+ state,
279
+ pr,
280
+ blocked: waiting.blocked,
281
+ ciSettleIn: await settleIn(workspaceId, s, pr),
282
+ ciSilentMs: await silentFor(s, pr),
283
+ };
284
+ }
285
+
286
+ // The plan-written line a held task stored on its machine (plan-step.ts),
287
+ // once per distinct plan: the mirror keeps the last one it told about.
288
+ async function announceRemotePlan(
289
+ workspaceId: string,
290
+ s: Session
291
+ ): Promise<void> {
292
+ const link = hostLink(s.host_id);
293
+ if (!link) return;
294
+ const view = await hostTasks(link)
295
+ .then(({ tasks }) => tasks.find((t) => t?.id === s.id))
296
+ .catch(() => undefined);
297
+ const note =
298
+ typeof view?.planNote === "string"
299
+ ? cleanRemoteText(view.planNote).slice(0, 2000)
300
+ : "";
301
+ if (!note || note === s.plan_note) return;
302
+ db.prepare(`UPDATE sessions SET plan_note = ? WHERE id = ?`).run(note, s.id);
303
+ queueEvent(workspaceId, `plan-done:${s.id}`, s.id, note);
304
+ }
305
+
306
+ // A task on another machine is as active as that machine says; one it can't
307
+ // reach is taken as active just now, so nothing reads it as abandoned.
308
+ async function lastActiveFor(s: Session): Promise<number> {
309
+ const link = hostLink(s.host_id);
310
+ if (!link) return lastActiveOf(s);
311
+ const view = await hostTasks(link)
312
+ .then(({ tasks }) => tasks.find((t) => t?.id === s.id))
313
+ .catch(() => undefined);
314
+ return typeof view?.lastActiveAt === "number"
315
+ ? view.lastActiveAt
316
+ : Date.now();
317
+ }
318
+
264
319
  export async function sessionFacts(
265
320
  workspaceId: string
266
321
  ): Promise<SessionFacts[]> {
@@ -283,11 +338,7 @@ export async function sessionFacts(
283
338
  s.view === "chat" && !hostLink(s.host_id) ? chatStall(s.id) : null,
284
339
  compacting:
285
340
  s.view === "chat" && !hostLink(s.host_id) && !!chatCompacting(s.id),
286
- lastActive: Math.max(
287
- sqliteMs(s.updated_at),
288
- statusDetector.getTimestamp(s.tmux_name) * 1000,
289
- latestChatAt(s.id)
290
- ),
341
+ lastActive: await lastActiveFor(s),
291
342
  branch: s.branch_name,
292
343
  quick: !!s.quick_of,
293
344
  plan:
@@ -10,6 +10,7 @@ import { hostLink } from "../hosts/remote-api";
10
10
  import { peerPane } from "../hosts/peer-sessions";
11
11
  import { workspaceSessions } from "./facts";
12
12
  import { untrusted } from "./untrusted";
13
+ import { heldChatTail } from "../tasks/held-remote";
13
14
  import {
14
15
  findExternalSession,
15
16
  knownExternalSession,
@@ -104,6 +105,12 @@ export function chatText(items: ChatItem[]): string {
104
105
  .join("\n");
105
106
  }
106
107
 
108
+ // The last lines of a chat task's conversation, unwrapped.
109
+ export function chatTailText(sessionId: string, lines = DEFAULT_LINES): string {
110
+ const n = Math.max(1, Math.min(Math.floor(lines) || DEFAULT_LINES, 400));
111
+ return tail(chatText(lastItems(sessionId, n * 2)), n);
112
+ }
113
+
107
114
  export async function readSession(
108
115
  workspaceId: string,
109
116
  ref: string,
@@ -121,8 +128,13 @@ export async function readSession(
121
128
  return readExternal(external, n);
122
129
  }
123
130
  const link = hostLink(s.host_id);
124
- if (s.view === "chat" && link)
125
- return `${s.name} (chat on ${link.hostName}): its messages are on that machine; open it to read them.`;
131
+ if (s.view === "chat" && link) {
132
+ // Its orchestrator's task is read through the machine that holds it.
133
+ const text = s.started_by ? await heldChatTail(s, n) : null;
134
+ return text
135
+ ? `${s.name} (chat on ${link.hostName}), last ${n} lines:\n${untrusted(s.name, text)}`
136
+ : `${s.name} (chat on ${link.hostName}): its messages are on that machine; open it to read them.`;
137
+ }
126
138
  if (s.view === "chat") {
127
139
  const text = chatText(lastItems(s.id, n * 2));
128
140
  const body = text ? untrusted(s.name, tail(text, n)) : "(no messages yet)";
@@ -4,6 +4,7 @@ import { getWorkspace } from "../workspaces";
4
4
  import {
5
5
  done,
6
6
  drop,
7
+ moveTaskTo,
7
8
  send,
8
9
  stack,
9
10
  stackStatus,
@@ -46,6 +47,7 @@ const ACTING = new Set<ToolName>([
46
47
  "stack",
47
48
  "land",
48
49
  "drop",
50
+ "move",
49
51
  "stop",
50
52
  "done",
51
53
  "review",
@@ -156,6 +158,10 @@ export async function runTool(
156
158
  const a = p(tool, raw);
157
159
  return drop(w, a.task, a.reason);
158
160
  }
161
+ case "move": {
162
+ const a = p(tool, raw);
163
+ return moveTaskTo(w, a.task, a.machine);
164
+ }
159
165
  case "stop":
160
166
  return stop(w, p(tool, raw).session);
161
167
  case "done":
@@ -211,6 +211,20 @@ const PULL_MERGE = /(^|\/)pulls\/[^/?\s]+\/merge\/?(\?|$)/;
211
211
 
212
212
  const MERGE_MSG = `Refused: a PR merges only through the gates: ${T.sign_off} (or ${T.arm}) for a task's or an external PR, ${T.land} for a stack. Nothing merges, closes or retargets a PR from the shell.`;
213
213
 
214
+ // aos move, done, stop and drop reach a session by name, past the tools'
215
+ // Pause check and logging; the tools do the same work.
216
+ function aosRefusal(args: string[]): string | null {
217
+ const [sub] = positionals(args);
218
+ const tool: Record<string, string> = {
219
+ move: T.move,
220
+ done: T.done,
221
+ stop: T.stop,
222
+ };
223
+ if (sub && tool[sub])
224
+ return `Refused: \`aos ${sub}\` isn't for you; use ${tool[sub]}, which is scoped to this workspace and held by Pause.`;
225
+ return null;
226
+ }
227
+
214
228
  // gh: pr merge, close and edit --base, and api calls that merge or change a
215
229
  // pull request.
216
230
  function ghRefusal(args: string[]): string | null {
@@ -522,6 +536,7 @@ export async function shellRefusal(
522
536
  secretRefusal(inv, cwd) ??
523
537
  (inv.name === "gh" ? ghRefusal(inv.args) : null) ??
524
538
  (inv.name === "curl" ? curlRefusal(inv.args) : null) ??
539
+ (inv.name === "aos" ? aosRefusal(inv.args) : null) ??
525
540
  (inv.name === "git" ? await gitRefusal(inv.args, ctx, cwd) : null) ??
526
541
  (inv.name === "sqlite3"
527
542
  ? await sqliteRefusal(inv, ctx, cwd, command)
@@ -23,6 +23,7 @@ export const TOOL_NAMES = {
23
23
  stack_status: name("stack_status"),
24
24
  land: name("land"),
25
25
  drop: name("drop"),
26
+ move: name("move"),
26
27
  stop: name("stop"),
27
28
  done: name("done"),
28
29
  add_project: name("add_project"),
@@ -193,6 +193,17 @@ export const TOOL_SHAPES = {
193
193
  stack_status: { id: stackId },
194
194
  land: { id: stackId },
195
195
  drop: { task, reason: z.string().trim().min(1).max(2000) },
196
+ move: {
197
+ task,
198
+ machine: z
199
+ .string()
200
+ .trim()
201
+ .min(1)
202
+ .max(200)
203
+ .describe(
204
+ 'A linked machine\'s name (see Machines), or "here" to bring the task back'
205
+ ),
206
+ },
196
207
  stop: { session },
197
208
  done: { session },
198
209
  add_project: {
@@ -76,6 +76,7 @@ const DESCRIPTIONS: Record<ToolName, string> = {
76
76
  stack_status: "One stack's items, PRs and progress.",
77
77
  land: "Merge a whole stack bottom-up, each PR through the sign-off gates at its current head.",
78
78
  drop: "Reject a task: close its PR and remove its worktree. A task still waiting in the queue is removed by its id. Logged with the reason.",
79
+ move: 'Carry a running task to a linked machine, or back with "here": for heavy test or build work. It keeps its branch, conversation and plan; the gates, events, read, send, stop, done and drop work the same wherever it runs, and the machine it lands on can\'t merge it. Takes a few minutes and runs in the background; an event says when it lands. Refused while paused, for a task waiting on Saad (an escalation or open ask), and for card and stacked tasks.',
79
80
  stop: "Stop a session's agent mid-turn, even while it's thinking, keeping its conversation, worktree and branch; your next message runs as its next turn.",
80
81
  done: "Finish a session whose work is complete: a task's open PR merges through the sign-off gates first (refused naming the failing gate), then its agent stops, its worktree is removed if nothing would be lost, and it's archived. Not a rejection.",
81
82
  add_project:
@@ -2,6 +2,7 @@ import { endTmuxSession } from "../tmux/end";
2
2
  import { hostLink } from "../hosts/remote-api";
3
3
  import { stopChatTurn } from "../chat/stop";
4
4
  import type { Session } from "../db";
5
+ import { stopHeldTask } from "../tasks/held-remote";
5
6
 
6
7
  // Stops a session's agent and keeps everything else, the same for every
7
8
  // caller (the orchestrator's stop tool, `aos stop`): a chat's turn ends
@@ -9,8 +10,21 @@ import type { Session } from "../db";
9
10
  // and conversation stay, and the next message runs as the next turn. Throws
10
11
  // when the chat can't be shown to have stopped.
11
12
  export async function stopSession(
12
- s: Pick<Session, "id" | "name" | "view" | "host_id" | "tmux_name">
13
+ s: Pick<
14
+ Session,
15
+ | "id"
16
+ | "name"
17
+ | "view"
18
+ | "host_id"
19
+ | "tmux_name"
20
+ | "started_by"
21
+ | "task_prompt"
22
+ >
13
23
  ): Promise<string> {
24
+ // A task its orchestrator moved to a linked machine stops through that
25
+ // machine's AgentOS, chat or terminal.
26
+ if (s.started_by && s.task_prompt && hostLink(s.host_id))
27
+ return stopHeldTask(s);
14
28
  if (s.view === "chat") {
15
29
  if (hostLink(s.host_id))
16
30
  throw new Error(
@@ -8,6 +8,7 @@ import fs from "fs";
8
8
  import path from "path";
9
9
  import { getRepoName, slugify } from "../git";
10
10
  import { WORKTREES_DIR } from "../worktrees";
11
+ import { installWorktreeHooks } from "../worktree-hooks";
11
12
  import { git as runGit } from "../git-exec";
12
13
  import { pushedRef } from "./move";
13
14
 
@@ -39,7 +40,21 @@ export function worktreeDirFor(
39
40
  return dir;
40
41
  }
41
42
 
42
- export async function checkoutBranchWorktree(
43
+ // The hook that strips attribution from commit messages, as every worktree
44
+ // AgentOS makes has (best effort: a worktree without it still works).
45
+ const withHooks = async (r: { worktreePath: string; reused: boolean }) => {
46
+ await installWorktreeHooks(r.worktreePath).catch((err) =>
47
+ console.error(`[worktrees] hooks not installed in ${r.worktreePath}:`, err)
48
+ );
49
+ return r;
50
+ };
51
+
52
+ export const checkoutBranchWorktree = async (
53
+ projectPath: string,
54
+ branch: string
55
+ ) => withHooks(await checkoutBranch(projectPath, branch));
56
+
57
+ async function checkoutBranch(
43
58
  projectPath: string,
44
59
  branch: string
45
60
  ): Promise<{ worktreePath: string; reused: boolean }> {
@@ -9,6 +9,7 @@ import {
9
9
  import { republishAfterMerge } from "../lumifyhub/publish";
10
10
  import { restackAfterMerge } from "../stacks/restack";
11
11
  import { itemName, liveChildren, signOffRefusal } from "../stacks/guard";
12
+ import { holderRefusal } from "./held";
12
13
  import { resolveMergedTaskAsks } from "../orchestrator/ask-settle";
13
14
  import { endSessionAgent } from "../sessions/end";
14
15
  import { run } from "./gh";
@@ -111,11 +112,14 @@ async function finishMerge(
111
112
  // machine merged it and didn't say).
112
113
  export async function signOffTask(
113
114
  id: string,
114
- opts: { wait?: boolean; head?: string } = {}
115
+ opts: { wait?: boolean; head?: string; device?: string | null } = {}
115
116
  ): Promise<MergeMethod | null> {
116
117
  const session = getTaskSession(id);
117
118
  if (session.task_status !== "running")
118
119
  throw new Error(`Task is already ${session.task_status}`);
120
+ // A task another machine's orchestrator moved here merges only from there.
121
+ const held = holderRefusal(session, opts.device);
122
+ if (held) throw new Error(held);
119
123
  // Its own machine checks the gates and merges.
120
124
  // That machine's settings chose the method; null if it didn't say.
121
125
  if (isMirror(session)) return remoteTaskAction(session, "merge", opts.head);
@@ -0,0 +1,80 @@
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.
5
+ */
6
+
7
+ import type { Session } from "../db";
8
+ import { cleanRemoteText, hostApi, requireHostLink } from "../hosts/remote-api";
9
+ import type { Delivery } from "../bus/delivery";
10
+
11
+ const path = (id: string, leaf: string) =>
12
+ `/api/tasks/${encodeURIComponent(id)}/${leaf}`;
13
+
14
+ /**
15
+ * Delivered by that machine's AgentOS as a message from the orchestrator,
16
+ * never as the user's own. Keyed by the stored message, so a retry that
17
+ * already landed is not typed twice.
18
+ */
19
+ export async function relayToHeldTask(
20
+ to: Session,
21
+ line: string,
22
+ fromName: string,
23
+ messageId?: number
24
+ ): Promise<Delivery> {
25
+ try {
26
+ const link = requireHostLink(to.host_id);
27
+ const { state } = await hostApi<{ state?: unknown }>(
28
+ link,
29
+ path(to.id, "message"),
30
+ {
31
+ body: {
32
+ id: messageId ? `bus-${messageId}` : undefined,
33
+ line,
34
+ from: fromName,
35
+ },
36
+ timeout: 20000,
37
+ }
38
+ );
39
+ return { state: state === "queued" ? "queued" : "delivered" };
40
+ } catch (error) {
41
+ const why = error instanceof Error ? error.message : String(error);
42
+ return { state: "failed", why: why.slice(0, 300) };
43
+ }
44
+ }
45
+
46
+ /** A held chat task's last lines of conversation, as that machine reads them. */
47
+ export async function heldChatTail(
48
+ task: Session,
49
+ lines: number
50
+ ): Promise<string | null> {
51
+ const link = requireHostLink(task.host_id);
52
+ const { text } = await hostApi<{ text?: unknown }>(
53
+ link,
54
+ `${path(task.id, "chat-tail")}?lines=${lines}`,
55
+ { timeout: 10000 }
56
+ ).catch(() => ({ text: undefined }));
57
+ return typeof text === "string" ? cleanRemoteText(text) : null;
58
+ }
59
+
60
+ /** Stops its agent there; its conversation and worktree are kept. */
61
+ export async function stopHeldTask(
62
+ task: Pick<Session, "id" | "name" | "host_id">
63
+ ): Promise<string> {
64
+ const link = requireHostLink(task.host_id);
65
+ const { text } = await hostApi<{ text?: unknown }>(
66
+ link,
67
+ path(task.id, "stop"),
68
+ {
69
+ body: {},
70
+ timeout: 30000,
71
+ }
72
+ );
73
+ return cleanRemoteText(text) || `Stopped ${task.name}.`;
74
+ }
75
+
76
+ /** Done there: its agent stops and its worktree goes. Never a merge. */
77
+ export async function doneHeldTask(task: Session): Promise<void> {
78
+ const link = requireHostLink(task.host_id);
79
+ await hostApi(link, path(task.id, "done"), { body: {}, timeout: 120000 });
80
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The routes only the device that moved a task here may call on it
3
+ * (held.ts): message, stop, done and read. Each is refused for any other
4
+ * device, and for a task that isn't held.
5
+ */
6
+
7
+ import { NextRequest, NextResponse } from "next/server";
8
+ import { db, type Session } from "../db";
9
+ import { callingDevice } from "../security/device-me";
10
+ import { holderRefusal } from "./held";
11
+
12
+ type Ctx = { params: Promise<{ id: string }> };
13
+
14
+ export async function heldRoute(
15
+ request: NextRequest,
16
+ { params }: Ctx,
17
+ run: (task: Session) => Promise<object>
18
+ ): Promise<NextResponse> {
19
+ const id = (await params).id;
20
+ const task = db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(id) as
21
+ | Session
22
+ | undefined;
23
+ if (!task?.held_by_device)
24
+ return NextResponse.json({ error: "No such held task" }, { status: 404 });
25
+ const refusal = holderRefusal(task, callingDevice(request));
26
+ if (refusal) return NextResponse.json({ error: refusal }, { status: 403 });
27
+ try {
28
+ return NextResponse.json(await run(task));
29
+ } catch (error) {
30
+ const message = error instanceof Error ? error.message : String(error);
31
+ return NextResponse.json({ error: message }, { status: 409 });
32
+ }
33
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * A task an orchestrator moved here belongs to that orchestrator's machine:
3
+ * only the device that sent it may merge, arm, move, message or finish it.
4
+ * Without this a moved task would be a plain task here, merged by anyone with
5
+ * a green CI, past the gates its orchestrator judges it by.
6
+ */
7
+
8
+ import { db, type Session } from "../db";
9
+
10
+ export function holderRefusal(
11
+ session: Pick<Session, "held_by_device" | "held_from">,
12
+ device: string | null | undefined
13
+ ): string | null {
14
+ if (!session.held_by_device) return null;
15
+ if (device && device === session.held_by_device) return null;
16
+ return `Its orchestrator on ${session.held_from || "another machine"} decides this; do it from there`;
17
+ }
18
+
19
+ /** The refusal for the task with this id, or null (also when it's unknown). */
20
+ export function heldRefusal(
21
+ id: string,
22
+ device: string | null | undefined
23
+ ): string | null {
24
+ const session = db
25
+ .prepare(`SELECT held_by_device, held_from FROM sessions WHERE id = ?`)
26
+ .get(id) as Pick<Session, "held_by_device" | "held_from"> | undefined;
27
+ return session ? holderRefusal(session, device) : null;
28
+ }
@@ -5,7 +5,9 @@
5
5
  * again returns the task it made the first time.
6
6
  */
7
7
 
8
+ import fs from "fs";
8
9
  import os from "os";
10
+ import path from "path";
9
11
  import { randomUUID } from "crypto";
10
12
  import { db, queries, type Session } from "../db";
11
13
  import { setupWorktree } from "../env-setup";
@@ -18,6 +20,9 @@ import {
18
20
  import { runInBackground } from "../async-operations";
19
21
  import { launchClaude } from "../agents/launch";
20
22
  import { resolveModelForAgent } from "../model-catalog";
23
+ import { providerFor } from "../models/resolve";
24
+ import { preparePlanDir, PLAN_FILE } from "../models/plan-step";
25
+ import { carryTaskState } from "./move-carry";
21
26
  import { buildTaskBrief } from "./brief";
22
27
  import { expandHome } from "./session";
23
28
  import { ensureProject } from "./project-ref";
@@ -57,7 +62,16 @@ function refuseBusyBranch(projectId: string, branch: string): void {
57
62
  if (busy) throw new Error(`${branch} already has a task running here`);
58
63
  }
59
64
 
60
- export async function importTask(input: TaskBundle): Promise<Session> {
65
+ export interface ImportOptions {
66
+ // The paired device that sent an orchestrator's task: it holds the task
67
+ // here (held.ts). Absent for this machine taking its own task back.
68
+ holder?: string | null;
69
+ }
70
+
71
+ export async function importTask(
72
+ input: TaskBundle,
73
+ opts: ImportOptions = {}
74
+ ): Promise<Session> {
61
75
  const bundle = validateBundle(input);
62
76
  const done = arrived(bundle.moveId);
63
77
  if (done) return done;
@@ -65,13 +79,16 @@ export async function importTask(input: TaskBundle): Promise<Session> {
65
79
  throw new InProgressError("It's arriving already");
66
80
  importing.add(bundle.moveId);
67
81
  try {
68
- return await arrive(bundle);
82
+ return await arrive(bundle, opts);
69
83
  } finally {
70
84
  importing.delete(bundle.moveId);
71
85
  }
72
86
  }
73
87
 
74
- async function arrive(bundle: TaskBundle): Promise<Session> {
88
+ async function arrive(
89
+ bundle: TaskBundle,
90
+ opts: ImportOptions
91
+ ): Promise<Session> {
75
92
  const project = await ensureProject(bundle.project);
76
93
  const projectPath = expandHome(project.working_directory);
77
94
  refuseBusyBranch(project.id, bundle.branch);
@@ -81,12 +98,29 @@ async function arrive(bundle: TaskBundle): Promise<Session> {
81
98
  projectPath,
82
99
  bundle.branch
83
100
  );
101
+ const orch = bundle.orchestrated;
102
+ const holder = orch ? (opts.holder ?? null) : null;
103
+ // Its provider as it was, or this machine says no and the source resumes
104
+ // it: a task never silently changes provider.
105
+ const provider = holder
106
+ ? (orch?.modelProvider ?? null)
107
+ : ((
108
+ db
109
+ .prepare(`SELECT model_provider FROM sessions WHERE id = ?`)
110
+ .get(bundle.moveId) as { model_provider: string | null } | undefined
111
+ )?.model_provider ?? null);
112
+ if (provider) providerFor(provider);
84
113
  const claude = bundle.claude;
85
114
  const paths = claude && {
86
115
  from: { cwd: claude.cwd, home: claude.home },
87
116
  to: { cwd: worktreePath, home: os.homedir() },
88
117
  };
89
118
  stepProgress(bundle.moveId, "conversation");
119
+ // The plan is never in git: it comes in the bundle.
120
+ if (orch?.planFile != null) {
121
+ preparePlanDir(worktreePath);
122
+ fs.writeFileSync(path.join(worktreePath, PLAN_FILE), orch.planFile);
123
+ }
90
124
  if (claude && paths) {
91
125
  await writeTranscript(
92
126
  worktreePath,
@@ -97,10 +131,9 @@ async function arrive(bundle: TaskBundle): Promise<Session> {
97
131
 
98
132
  const id = randomUUID();
99
133
  const tmuxName = `claude-${id}`;
100
- const model = resolveModelForAgent(
101
- "claude",
102
- bundle.model || project.default_model
103
- );
134
+ const model = provider
135
+ ? bundle.model
136
+ : resolveModelForAgent("claude", bundle.model || project.default_model);
104
137
  const baseBranch = bundle.baseBranch ?? "main";
105
138
  const brief = buildTaskBrief({ branch: bundle.branch, baseBranch });
106
139
  // Checked and written with no await between, so two can't both pass.
@@ -131,6 +164,26 @@ async function arrive(bundle: TaskBundle): Promise<Session> {
131
164
  claude_session_id = ?, moved_from = ? WHERE id = ?`
132
165
  ).run(bundle.prompt, brief, claude?.sessionId ?? null, bundle.moveId, id);
133
166
  if (bundle.chat) unpackChat(id, bundle.chat, paths);
167
+ if (orch) {
168
+ db.prepare(
169
+ `UPDATE sessions SET plan_phase = ?, task_since = ? WHERE id = ?`
170
+ ).run(orch.planPhase, orch.since, id);
171
+ // Held for the orchestrator's device. The orchestrator's own state
172
+ // (started_by, its gates) never comes from the bundle: it stays on its
173
+ // machine, and a task coming back takes it from the mirror's record.
174
+ if (holder)
175
+ db.prepare(
176
+ `UPDATE sessions SET held_by_device = ?, held_from = ?,
177
+ model_provider = ?, model_overrides = ? WHERE id = ?`
178
+ ).run(
179
+ holder,
180
+ orch.heldFrom,
181
+ orch.modelProvider,
182
+ orch.modelOverrides,
183
+ id
184
+ );
185
+ }
186
+ carryTaskState(bundle.moveId, id);
134
187
  })();
135
188
 
136
189
  stepProgress(bundle.moveId, "resume");
@@ -58,11 +58,12 @@ import { remoteTaskViews } from "./remote-views";
58
58
  import { chatStateNow } from "../chat/runner";
59
59
  import { saidSinceLastMessage } from "../chat/store";
60
60
 
61
+ import { lastActiveOf } from "./last-active";
61
62
  export * from "./state";
62
63
  export { codeReviewGate, codeReviewRefusal } from "./code-review";
63
64
  export { signOffTask, dropTask, signingOff, mergeSettled } from "./finish";
64
65
  export { prFor as taskPR } from "./session";
65
- export { moveTask } from "./move-flow";
66
+ export { assertMovable, moveTask } from "./move-flow";
66
67
 
67
68
  export interface TaskView {
68
69
  id: string;
@@ -106,6 +107,12 @@ export interface TaskView {
106
107
  quickOf?: string | null;
107
108
  // What it has cost so far in weighted tokens (lib/usage/weights).
108
109
  weighted?: number;
110
+ // Its last sign of life, in ms, for an orchestrator on another machine.
111
+ lastActiveAt?: number;
112
+ // Held for another machine's orchestrator (held.ts): which machine, and
113
+ // the "plan written" line it would have been told.
114
+ heldFrom?: string | null;
115
+ planNote?: string | null;
109
116
  }
110
117
 
111
118
  export async function createTask(opts: {
@@ -394,6 +401,9 @@ export async function taskView(session: Session): Promise<TaskView> {
394
401
  planning: session.plan_phase === "planning",
395
402
  plan: taskPlan(session),
396
403
  quickOf: session.quick_of ?? null,
404
+ lastActiveAt: session.held_by_device ? lastActiveOf(session) : undefined,
405
+ heldFrom: session.held_by_device ? (session.held_from ?? null) : null,
406
+ planNote: session.held_by_device ? (session.plan_note ?? null) : null,
397
407
  };
398
408
  }
399
409