@saadnvd1/agent-os 0.3.516 → 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 (77) hide show
  1. package/README.md +47 -3
  2. package/app/api/devices/route.ts +2 -0
  3. package/app/api/push/test/route.test.ts +112 -0
  4. package/app/api/push/test/route.ts +38 -0
  5. package/app/api/tasks/[id]/arm/route.ts +5 -0
  6. package/app/api/tasks/[id]/chat-tail/route.ts +15 -0
  7. package/app/api/tasks/[id]/done/route.ts +15 -0
  8. package/app/api/tasks/[id]/export/route.ts +6 -1
  9. package/app/api/tasks/[id]/merge/route.ts +9 -1
  10. package/app/api/tasks/[id]/message/route.ts +39 -0
  11. package/app/api/tasks/[id]/moved/route.ts +6 -1
  12. package/app/api/tasks/[id]/resume/route.ts +6 -1
  13. package/app/api/tasks/[id]/stop/route.ts +14 -0
  14. package/app/api/tasks/import/route.ts +11 -1
  15. package/components/Devices/DeviceRow.tsx +52 -3
  16. package/components/Devices/DevicesPanel.tsx +13 -0
  17. package/data/devices/queries.ts +30 -2
  18. package/lib/away/run.ts +15 -3
  19. package/lib/bus/index.ts +15 -2
  20. package/lib/chat/registry.ts +2 -0
  21. package/lib/chat/runner.ts +3 -0
  22. package/lib/connect/control-api.ts +38 -0
  23. package/lib/db/migrations.ts +56 -0
  24. package/lib/db/types.ts +7 -0
  25. package/lib/done/bulk.ts +8 -0
  26. package/lib/done/index.ts +20 -1
  27. package/lib/load/alert.ts +5 -1
  28. package/lib/models/plan-step.ts +13 -3
  29. package/lib/notify/index.ts +46 -7
  30. package/lib/notify/limiter.ts +6 -1
  31. package/lib/notify/push/encode.ts +42 -0
  32. package/lib/notify/push/events.ts +72 -0
  33. package/lib/notify/push/index.ts +183 -0
  34. package/lib/notify/push/outbox.ts +238 -0
  35. package/lib/notify/push/policy.ts +33 -0
  36. package/lib/notify/push/presence.ts +45 -0
  37. package/lib/notify/push/relay.ts +50 -0
  38. package/lib/notify/push/sender.ts +271 -0
  39. package/lib/notify/push/testing.ts +30 -0
  40. package/lib/notify/push/waiting.ts +29 -0
  41. package/lib/notify/push-types.ts +2 -0
  42. package/lib/notify/redact.ts +13 -2
  43. package/lib/orchestrator/act.ts +41 -1
  44. package/lib/orchestrator/armed.ts +6 -1
  45. package/lib/orchestrator/asks.ts +5 -1
  46. package/lib/orchestrator/brakes.ts +9 -0
  47. package/lib/orchestrator/brief.ts +2 -1
  48. package/lib/orchestrator/facts.ts +72 -21
  49. package/lib/orchestrator/read.ts +14 -2
  50. package/lib/orchestrator/review.ts +3 -0
  51. package/lib/orchestrator/serve.ts +6 -0
  52. package/lib/orchestrator/shell-guard.ts +15 -0
  53. package/lib/orchestrator/tool-names.ts +1 -0
  54. package/lib/orchestrator/tool-schemas.ts +11 -0
  55. package/lib/orchestrator/tools.ts +1 -0
  56. package/lib/program-status/tap.ts +11 -1
  57. package/lib/schedules/start.ts +11 -2
  58. package/lib/security/devices.ts +57 -4
  59. package/lib/sessions/stop.ts +15 -1
  60. package/lib/tasks/branch-worktree.ts +16 -1
  61. package/lib/tasks/finish.ts +7 -1
  62. package/lib/tasks/held-remote.ts +80 -0
  63. package/lib/tasks/held-routes.ts +33 -0
  64. package/lib/tasks/held.ts +28 -0
  65. package/lib/tasks/import.ts +60 -7
  66. package/lib/tasks/index.ts +11 -1
  67. package/lib/tasks/last-active.ts +25 -0
  68. package/lib/tasks/move-bundle.ts +53 -1
  69. package/lib/tasks/move-carry.ts +102 -0
  70. package/lib/tasks/move-flow.ts +34 -7
  71. package/lib/tasks/move-guard.ts +17 -13
  72. package/lib/tasks/move-recover.ts +2 -0
  73. package/lib/tasks/move.ts +34 -3
  74. package/lib/tasks/remote.ts +3 -0
  75. package/lib/tasks/session.ts +27 -3
  76. package/package.json +1 -1
  77. package/server.ts +2 -0
@@ -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)";
@@ -10,6 +10,7 @@
10
10
  * the orchestrator hears the verdict as an event.
11
11
  */
12
12
 
13
+ import { pushReviewBlocked, pushReviewReady } from "../notify/push/events";
13
14
  import { db, type Session } from "../db";
14
15
  import { amendmentsOf } from "./amendments";
15
16
  import type { TaskPR } from "../tasks/state";
@@ -275,6 +276,8 @@ export async function reviewAt(
275
276
  .then((done) => {
276
277
  if (done.status === "pass" && target.task)
277
278
  noteQuickReview(target.task, sha);
279
+ if (done.status === "pass") pushReviewReady(target.id, sha);
280
+ else if (done.status === "block") pushReviewBlocked(target.id, sha);
278
281
  const said =
279
282
  done.status === "pass"
280
283
  ? "passed"
@@ -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:
@@ -24,7 +24,9 @@ import {
24
24
  applyProgramReport,
25
25
  dropProgramTransient,
26
26
  programSessions,
27
+ programSummary,
27
28
  } from "./store";
29
+ import { noteWaiting } from "../notify/push/waiting";
28
30
 
29
31
  const execFileAsync = promisify(execFile);
30
32
  const tmux = (args: string[]) =>
@@ -85,7 +87,15 @@ async function handle(name: string, event: OscEvent): Promise<void> {
85
87
  ? await foreground(name)
86
88
  : undefined
87
89
  );
88
- if (changed) notifyStatusChanged();
90
+ if (changed) {
91
+ notifyStatusChanged();
92
+ const id = (
93
+ getDb()
94
+ .prepare(`SELECT id FROM sessions WHERE tmux_name = ?`)
95
+ .get(name) as { id: string } | undefined
96
+ )?.id;
97
+ if (id) noteWaiting(id, programSummary(name)?.state === "blocked");
98
+ }
89
99
  }
90
100
 
91
101
  // What one pane has said but isn't applied yet: the latest report per
@@ -237,7 +237,11 @@ function notify(schedule: Schedule, why: string, paused?: boolean): void {
237
237
  const text = `Schedule "${schedule.name}" is paused: ${why}. Point it at another session or delete it.`;
238
238
  console.error(`[schedules] ${text}`);
239
239
  addNote(schedule.workspace_id, text, "pause");
240
- notifyPhone(`schedule:${schedule.id}`, text);
240
+ notifyPhone(`schedule:${schedule.id}`, text, {
241
+ category: "system",
242
+ key: `system:schedule:${schedule.id}:${Date.now()}`,
243
+ collapse: `system:schedule:${schedule.id}`,
244
+ });
241
245
  return;
242
246
  }
243
247
  console.error(`[schedules] ${schedule.name} failed: ${why}`);
@@ -248,7 +252,12 @@ function notify(schedule: Schedule, why: string, paused?: boolean): void {
248
252
  );
249
253
  notifyPhone(
250
254
  `schedule:${schedule.id}`,
251
- `Schedule "${schedule.name}" failed: ${why}`
255
+ `Schedule "${schedule.name}" failed: ${why}`,
256
+ {
257
+ category: "system",
258
+ key: `system:schedule:${schedule.id}:${Date.now()}`,
259
+ collapse: `system:schedule:${schedule.id}`,
260
+ }
252
261
  );
253
262
  }
254
263
 
@@ -86,13 +86,52 @@ export function touchDevice(id: string, address?: string): boolean {
86
86
  return true;
87
87
  }
88
88
 
89
- export function listDevices(): Device[] {
90
- return getDb()
89
+ /** What the Devices page shows of a device's push: never the token. */
90
+ export interface DevicePushView {
91
+ enabled: boolean;
92
+ categories: PushCategory[];
93
+ lastError: string | null;
94
+ updatedAt: string | null;
95
+ }
96
+
97
+ export type ListedDevice = Device & { push: DevicePushView };
98
+
99
+ export function listDevices(): ListedDevice[] {
100
+ const rows = getDb()
91
101
  .prepare(
92
- `SELECT id, name, user_agent, created_at, last_seen_at, last_address, revoked_at, can_approve
102
+ `SELECT id, name, user_agent, created_at, last_seen_at, last_address, revoked_at, can_approve,
103
+ ${PUSH_COLS}
93
104
  FROM devices WHERE revoked_at IS NULL ORDER BY created_at DESC`
94
105
  )
95
- .all() as Device[];
106
+ .all() as (Device & PushRow)[];
107
+ return rows.map((r) => {
108
+ const {
109
+ push_token,
110
+ push_platform,
111
+ push_environment,
112
+ push_categories,
113
+ push_updated_at,
114
+ push_last_error,
115
+ ...device
116
+ } = r;
117
+ const push = pushOf({
118
+ push_token,
119
+ push_platform,
120
+ push_environment,
121
+ push_categories,
122
+ push_updated_at,
123
+ push_last_error,
124
+ });
125
+ return {
126
+ ...device,
127
+ push: {
128
+ enabled: !!push,
129
+ categories: push?.categories ?? [],
130
+ lastError: push_last_error,
131
+ updatedAt: push?.updatedAt ?? null,
132
+ },
133
+ };
134
+ });
96
135
  }
97
136
 
98
137
  export function renameDevice(id: string, name: string): boolean {
@@ -301,3 +340,17 @@ function dropDeviceSockets(id: string): void {
301
340
  for (const s of sockets.get(id) ?? []) s.destroy();
302
341
  sockets.delete(id);
303
342
  }
343
+
344
+ /**
345
+ * The relay said this token is dead: forget it, keep the chosen categories,
346
+ * say why. The app registers a fresh token on its next launch.
347
+ */
348
+ export function pruneDevicePush(id: string, reason: string): void {
349
+ getDb()
350
+ .prepare(
351
+ `UPDATE devices SET push_token = NULL, push_platform = NULL,
352
+ push_environment = NULL, push_last_error = ?
353
+ WHERE id = ?`
354
+ )
355
+ .run(reason.slice(0, 200), id);
356
+ }
@@ -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,9 +9,11 @@ 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";
16
+ import { pushMerged } from "../notify/push/events";
15
17
  import { gitFetch, gitPush } from "../git-exec";
16
18
  import {
17
19
  mergePolicy,
@@ -110,11 +112,14 @@ async function finishMerge(
110
112
  // machine merged it and didn't say).
111
113
  export async function signOffTask(
112
114
  id: string,
113
- opts: { wait?: boolean; head?: string } = {}
115
+ opts: { wait?: boolean; head?: string; device?: string | null } = {}
114
116
  ): Promise<MergeMethod | null> {
115
117
  const session = getTaskSession(id);
116
118
  if (session.task_status !== "running")
117
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);
118
123
  // Its own machine checks the gates and merges.
119
124
  // That machine's settings chose the method; null if it didn't say.
120
125
  if (isMirror(session)) return remoteTaskAction(session, "merge", opts.head);
@@ -155,6 +160,7 @@ export async function signOffTask(
155
160
  signingOff.delete(id);
156
161
  }
157
162
  syncTaskCardInBackground(session, "merged", pr);
163
+ pushMerged(id);
158
164
  // Reads the plan file now: cleanup below removes the worktree.
159
165
  void captureMergedTask(id, { head: opts.head ?? pr!.head ?? null });
160
166
  const head = opts.head ?? pr!.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");