@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
@@ -81,7 +81,7 @@ export function loadSessions(
81
81
  `SELECT s.id, s.name, s.created_at, s.role, s.view, s.task_prompt,
82
82
  length(s.task_brief) AS task_brief_chars, s.working_directory,
83
83
  s.worktree_path, s.claude_session_id, s.workspace_id, p.name AS project
84
- FROM sessions s LEFT JOIN projects p ON p.id = s.project_id
84
+ FROM session_rows s LEFT JOIN projects p ON p.id = s.project_id
85
85
  WHERE coalesce(s.host_id, 'local') = 'local'`
86
86
  )
87
87
  .all() as SessionRow[];
@@ -297,8 +297,8 @@ export function loadSignals(
297
297
  }>(
298
298
  `SELECT m.from_id, m.to_id, m.body, f.role AS from_role, (t.task_prompt IS NOT NULL) AS to_task
299
299
  FROM bus_messages m
300
- LEFT JOIN sessions f ON f.id = m.from_id
301
- LEFT JOIN sessions t ON t.id = m.to_id
300
+ LEFT JOIN session_rows f ON f.id = m.from_id
301
+ LEFT JOIN session_rows t ON t.id = m.to_id
302
302
  WHERE m.created_at >= ? AND m.created_at < ?`,
303
303
  a,
304
304
  b
@@ -120,7 +120,7 @@ export async function writeLog(
120
120
  id: string,
121
121
  opts: CaptureOpts & { plan?: string | null } = {}
122
122
  ): Promise<boolean> {
123
- const row = db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(id) as
123
+ const row = db.prepare(`SELECT * FROM session_rows WHERE id = ?`).get(id) as
124
124
  | Session
125
125
  | undefined;
126
126
  const existing = getLog(id);
@@ -283,7 +283,7 @@ export function titleChatFromMessage(
283
283
  ): Promise<void> | void {
284
284
  const s = db
285
285
  .prepare(
286
- `SELECT name, name_source, working_directory, task_prompt FROM sessions WHERE id = ?`
286
+ `SELECT name, name_source, working_directory, task_prompt FROM session_rows WHERE id = ?`
287
287
  )
288
288
  .get(sessionId) as
289
289
  | {
@@ -110,7 +110,7 @@ export function getSetup(sessionId: string): SetupView | null {
110
110
  function savedSetup(sessionId: string): string | null {
111
111
  const row = db
112
112
  .prepare(
113
- `SELECT setup_status FROM sessions WHERE id = ? AND task_status IS NULL`
113
+ `SELECT setup_status FROM session_rows WHERE id = ? AND task_status IS NULL`
114
114
  )
115
115
  .get(sessionId) as { setup_status: string | null } | undefined;
116
116
  return row?.setup_status ?? null;
@@ -2,7 +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 { isHeldRemote, stopHeldTask } from "../tasks/held-remote";
5
+ import { isHeldRemote, stopHeldTask } from "../tasks/contract/held-calls";
6
6
  import { releaseOnEnd } from "../simulator";
7
7
  import { forgetPlanWait } from "../models/plan-step";
8
8
 
@@ -175,7 +175,8 @@ export function failInterruptedSetups(): number {
175
175
  THEN COALESCE((SELECT working_directory FROM projects WHERE id = sessions.project_id), working_directory)
176
176
  ELSE working_directory END,
177
177
  setup_error = 'AgentOS restarted before setup finished. Your message is still queued: send it when you are ready.'
178
- WHERE setup_status = 'running' AND task_status IS NULL`
178
+ WHERE setup_status = 'running'
179
+ AND id IN (SELECT id FROM session_rows WHERE task_status IS NULL)`
179
180
  )
180
181
  .run().changes;
181
182
  }
@@ -70,7 +70,7 @@ export { simulatorAvailable };
70
70
  export { shutTaskSimulators, shutFinishedTaskSimulators } from "./task-devices";
71
71
 
72
72
  const session = (id: string) =>
73
- db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(id) as
73
+ db.prepare(`SELECT * FROM session_rows WHERE id = ?`).get(id) as
74
74
  | Session
75
75
  | undefined;
76
76
 
@@ -148,7 +148,7 @@ export async function shutFinishedTaskSimulators(): Promise<number> {
148
148
  db
149
149
  .prepare(
150
150
  `SELECT id, branch_name, worktree_path, task_status, host_id
151
- FROM sessions WHERE task_status IS NOT NULL`
151
+ FROM session_rows WHERE task_status IS NOT NULL`
152
152
  )
153
153
  .all() as (TaskRef & { host_id: string })[]
154
154
  ).filter((t) => !isRemoteHost(t.host_id));
@@ -44,7 +44,7 @@ export function toPlanCards(
44
44
  function runningTasks(boardId: string): Map<string, string> {
45
45
  const rows = db
46
46
  .prepare(
47
- `SELECT id, lh_card_id FROM sessions
47
+ `SELECT id, lh_card_id FROM session_rows
48
48
  WHERE lh_board_id = ? AND lh_card_id IS NOT NULL AND task_status = 'running'`
49
49
  )
50
50
  .all(boardId) as Array<{ id: string; lh_card_id: string }>;
@@ -67,7 +67,7 @@ export const LAND_DEFAULTS: LandDeps = {
67
67
 
68
68
  const sessionOf = (id: string | null) =>
69
69
  (id
70
- ? db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(id)
70
+ ? db.prepare(`SELECT * FROM session_rows WHERE id = ?`).get(id)
71
71
  : undefined) as Session | undefined;
72
72
 
73
73
  class Stop extends Error {}
@@ -109,7 +109,7 @@ const defaults: RestackDeps = {
109
109
 
110
110
  const sessionOf = (id: string | null) =>
111
111
  id
112
- ? ((db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(id) as
112
+ ? ((db.prepare(`SELECT * FROM session_rows WHERE id = ?`).get(id) as
113
113
  | Session
114
114
  | undefined) ?? null)
115
115
  : null;
@@ -25,7 +25,7 @@ async function stackedBase(
25
25
  item: StackItemRow
26
26
  ) {
27
27
  const session = db
28
- .prepare(`SELECT * FROM sessions WHERE id = ?`)
28
+ .prepare(`SELECT * FROM session_rows WHERE id = ?`)
29
29
  .get(parent.session_id) as Session | undefined;
30
30
  const branch = session?.branch_name;
31
31
  if (!branch) throw new Error(`${itemName(parent)} has no branch to stack on`);
@@ -52,7 +52,7 @@ async function stackedBase(
52
52
  export function existingTask(cardId: string): string | null {
53
53
  const row = db
54
54
  .prepare(
55
- `SELECT id FROM sessions WHERE lh_card_id = ? AND task_status = 'running'
55
+ `SELECT id FROM session_rows WHERE lh_card_id = ? AND task_status = 'running'
56
56
  ORDER BY created_at DESC LIMIT 1`
57
57
  )
58
58
  .get(cardId) as { id: string } | undefined;
@@ -39,7 +39,7 @@ export function setStartGate(gate: StartGate | null): void {
39
39
  const LIVE = new Set(["running", "pr"]);
40
40
 
41
41
  const sessionOf = (id: string) =>
42
- db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(id) as
42
+ db.prepare(`SELECT * FROM session_rows WHERE id = ?`).get(id) as
43
43
  | Session
44
44
  | undefined;
45
45
 
@@ -51,7 +51,7 @@ const prUrlOf = (sessionId: string | null) =>
51
51
  sessionId
52
52
  ? ((
53
53
  db
54
- .prepare(`SELECT pr_url FROM sessions WHERE id = ?`)
54
+ .prepare(`SELECT pr_url FROM session_rows WHERE id = ?`)
55
55
  .get(sessionId) as { pr_url: string | null } | undefined
56
56
  )?.pr_url ?? null)
57
57
  : null;
@@ -60,7 +60,7 @@ export function resetStatusCaches(): void {
60
60
  function loadRows(dirty: Set<string>): void {
61
61
  const rows = getDb()
62
62
  .prepare(
63
- `SELECT s.*, p.workspace_id AS project_workspace_id FROM sessions s
63
+ `SELECT s.*, p.workspace_id AS project_workspace_id FROM session_rows s
64
64
  LEFT JOIN projects p ON p.id = s.project_id
65
65
  WHERE s.archived_at IS NULL`
66
66
  )
@@ -0,0 +1,35 @@
1
+ import type { Session } from "../../db";
2
+ import { requireHostLink } from "../../hosts/remote-api";
3
+ import type { MergeMethod } from "../merge-methods";
4
+ import * as client from "./client";
5
+ import { setMirrorStatus } from "./start";
6
+
7
+ /**
8
+ * Sign off on the machine that runs it. It returns the method that machine
9
+ * merged with; null when it doesn't say (an older AgentOS).
10
+ */
11
+ export async function mergeThere(
12
+ session: Session,
13
+ head?: string,
14
+ base?: string | null
15
+ ): Promise<MergeMethod | null> {
16
+ const link = requireHostLink(session.host_id);
17
+ const res = await client.merge(
18
+ link,
19
+ session.id,
20
+ head ? { head, base } : undefined,
21
+ session.name
22
+ );
23
+ setMirrorStatus(session.id, "merged");
24
+ if (head && res.head !== head)
25
+ throw new Error(
26
+ `${link.hostName} merged without confirming it was ${head.slice(0, 7)}`
27
+ );
28
+ return res.method;
29
+ }
30
+
31
+ /** Drop on the machine that runs it. */
32
+ export async function dropThere(session: Session): Promise<void> {
33
+ await client.drop(requireHostLink(session.host_id), session.id);
34
+ setMirrorStatus(session.id, "dropped");
35
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Who may call a task route on the machine that runs the task. One rule per
3
+ * route, applied the same way everywhere:
4
+ *
5
+ * - holder: a task an orchestrator on another machine holds (held.ts); only
6
+ * the device that holds it, and a task nobody holds is "No such held task".
7
+ * - open: the holder, when there is one; any caller otherwise. A mirror row
8
+ * or a task started here is never held, so this machine's own web UI
9
+ * (which sends no device) is never refused on them.
10
+ * - plan: open, and a plan another machine placed here is read and approved
11
+ * by that machine only.
12
+ */
13
+
14
+ import { NextRequest, NextResponse } from "next/server";
15
+ import { db, type Session } from "../../db";
16
+ import { callingDevice } from "../../security/device-me";
17
+ import { heldRefusal, holderRefusal, planWaitRefusal } from "../held";
18
+
19
+ export type Rule = "holder" | "open" | "plan";
20
+
21
+ type Ctx = { params: Promise<{ id: string }> };
22
+
23
+ export interface Refusal {
24
+ status: 403 | 404;
25
+ error: string;
26
+ }
27
+
28
+ export function callerRefusal(
29
+ id: string,
30
+ device: string | null | undefined,
31
+ rule: Rule
32
+ ): Refusal | null {
33
+ if (rule === "holder") {
34
+ const task = db
35
+ .prepare(
36
+ `SELECT held_by_device, held_from FROM session_rows WHERE id = ?`
37
+ )
38
+ .get(id) as Pick<Session, "held_by_device" | "held_from"> | undefined;
39
+ if (!task?.held_by_device)
40
+ return { status: 404, error: "No such held task" };
41
+ const error = holderRefusal(task, device);
42
+ return error ? { status: 403, error } : null;
43
+ }
44
+ const error =
45
+ heldRefusal(id, device) ??
46
+ (rule === "plan" ? planWaitRefusal(id, device) : null);
47
+ return error ? { status: 403, error } : null;
48
+ }
49
+
50
+ /** The refusal as a response, or null when the caller may go on. */
51
+ export function refuse(
52
+ request: NextRequest,
53
+ id: string,
54
+ rule: Rule
55
+ ): NextResponse | null {
56
+ const refusal = callerRefusal(id, callingDevice(request), rule);
57
+ return refusal
58
+ ? NextResponse.json({ error: refusal.error }, { status: refusal.status })
59
+ : null;
60
+ }
61
+
62
+ export async function taskRoute(
63
+ request: NextRequest,
64
+ { params }: Ctx,
65
+ rule: Rule,
66
+ run: (task: Session) => Promise<object>
67
+ ): Promise<NextResponse> {
68
+ const id = (await params).id;
69
+ const refused = refuse(request, id, rule);
70
+ if (refused) return refused;
71
+ const task = db.prepare(`SELECT * FROM session_rows WHERE id = ?`).get(id) as
72
+ | Session
73
+ | undefined;
74
+ if (!task)
75
+ return NextResponse.json({ error: "No such task" }, { status: 404 });
76
+ try {
77
+ return NextResponse.json(await run(task));
78
+ } catch (error) {
79
+ const message = error instanceof Error ? error.message : String(error);
80
+ return NextResponse.json({ error: message }, { status: 409 });
81
+ }
82
+ }
@@ -0,0 +1,296 @@
1
+ /**
2
+ * The one client for a task running on a linked machine: every call the Mac
3
+ * makes to that machine's task API, with its path, body, timeout and retry in
4
+ * one place. Callers hold a HostLink and say what they want; none of them
5
+ * builds a task URL.
6
+ */
7
+
8
+ import { MERGE_METHODS, type MergeMethod } from "../merge-methods";
9
+ import type { Session } from "../../db";
10
+ import {
11
+ HostApiError,
12
+ cleanRemoteBlock,
13
+ cleanRemoteText,
14
+ hostApi,
15
+ type HostLink,
16
+ } from "../../hosts/remote-api";
17
+ import type { OrchestratedBundle } from "../move-bundle";
18
+ import type { TaskBundle } from "../move-bundle";
19
+ import {
20
+ HELD_REPLIES,
21
+ IMPORT,
22
+ LIST,
23
+ TIMEOUT,
24
+ arrivedPath,
25
+ taskPath,
26
+ } from "./routes";
27
+ import type { ChatFacts, HostTasks } from "./types";
28
+
29
+ export const unknownOutcome = (err: unknown) =>
30
+ err instanceof HostApiError && !err.refused;
31
+
32
+ /** POST a call that's safe to repeat; once more if we can't tell it landed. */
33
+ export async function postIdempotent<T>(
34
+ link: HostLink,
35
+ path: string,
36
+ body: unknown,
37
+ timeout: number
38
+ ): Promise<T> {
39
+ try {
40
+ return await hostApi<T>(link, path, { body, timeout });
41
+ } catch (err) {
42
+ if (!unknownOutcome(err)) throw err;
43
+ return hostApi<T>(link, path, { body, timeout });
44
+ }
45
+ }
46
+
47
+ /** POST, repeated when the outcome is unknown (the calls are idempotent). */
48
+ export async function tell(
49
+ link: HostLink,
50
+ path: string,
51
+ body: object = {}
52
+ ): Promise<void> {
53
+ for (let i = 0; ; i++) {
54
+ try {
55
+ await hostApi(link, path, { body });
56
+ return;
57
+ } catch (err) {
58
+ if (!unknownOutcome(err) || i === 2) throw err;
59
+ }
60
+ }
61
+ }
62
+
63
+ const cache = new Map<string, { at: number; list: Promise<HostTasks> }>();
64
+
65
+ /** The machine's task list; the last answer within 3 s unless `fresh`. */
66
+ export function list(link: HostLink, fresh = false): Promise<HostTasks> {
67
+ const hit = cache.get(link.hostId);
68
+ if (!fresh && hit && Date.now() - hit.at < 3000) return hit.list;
69
+ const next = hostApi<HostTasks>(link, LIST, { timeout: TIMEOUT.list });
70
+ cache.set(link.hostId, { at: Date.now(), list: next });
71
+ next.catch(() => cache.delete(link.hostId));
72
+ return next;
73
+ }
74
+
75
+ export const forget = (hostId: string) => cache.delete(hostId);
76
+
77
+ export async function view(
78
+ link: HostLink,
79
+ id: string,
80
+ fresh = false
81
+ ): Promise<HostTasks["tasks"][number] | undefined> {
82
+ const { tasks } = await list(link, fresh);
83
+ return (Array.isArray(tasks) ? tasks : []).find((t) => t?.id === id);
84
+ }
85
+
86
+ /**
87
+ * A machine on an older AgentOS would take a chat task as a terminal one,
88
+ * or an orchestrator's task as a plain one that anyone there could merge, or
89
+ * refuse to hand one over.
90
+ */
91
+ export async function requireCapability(
92
+ link: HostLink,
93
+ capability: string,
94
+ what: string,
95
+ verb = "move"
96
+ ): Promise<void> {
97
+ const { capabilities = [] } = await list(link, true).catch((err) => {
98
+ throw new Error(
99
+ `Can't ask ${link.hostName} whether it takes ${what}: ${(err as Error).message}`
100
+ );
101
+ });
102
+ if (!capabilities.includes(capability))
103
+ throw new Error(
104
+ `${link.hostName}'s AgentOS can't ${verb} ${what} yet; update it first`
105
+ );
106
+ }
107
+
108
+ export const start = (link: HostLink, body: object) =>
109
+ postIdempotent<{ session: Session }>(link, LIST, body, TIMEOUT.start);
110
+
111
+ export async function plan(link: HostLink, id: string): Promise<string | null> {
112
+ const { text } = await hostApi<{ text: string | null }>(
113
+ link,
114
+ taskPath(id, "plan"),
115
+ { timeout: TIMEOUT.plan }
116
+ );
117
+ return text;
118
+ }
119
+
120
+ export const approvePlan = (link: HostLink, id: string, message?: string) =>
121
+ hostApi<{ model?: string }>(link, taskPath(id, "approve-plan"), {
122
+ body: message ? { message } : {},
123
+ timeout: TIMEOUT.approvePlan,
124
+ });
125
+
126
+ export async function hold(
127
+ link: HostLink,
128
+ id: string,
129
+ orchestrated: OrchestratedBundle
130
+ ): Promise<void> {
131
+ await hostApi(link, taskPath(id, "hold"), {
132
+ body: { orchestrated },
133
+ timeout: TIMEOUT.hold,
134
+ });
135
+ }
136
+
137
+ export async function message(
138
+ link: HostLink,
139
+ id: string,
140
+ body: { id?: string; line: string; from: string }
141
+ ): Promise<"queued" | "delivered"> {
142
+ const { state } = await hostApi<{ state?: unknown }>(
143
+ link,
144
+ taskPath(id, "message"),
145
+ { body, timeout: TIMEOUT.message }
146
+ );
147
+ return state === "queued" ? "queued" : "delivered";
148
+ }
149
+
150
+ export async function chatTail(
151
+ link: HostLink,
152
+ id: string,
153
+ lines: number
154
+ ): Promise<string | null> {
155
+ const { text } = await hostApi<{ text?: unknown }>(
156
+ link,
157
+ `${taskPath(id, "chat-tail")}?lines=${lines}`,
158
+ { timeout: TIMEOUT.chat }
159
+ );
160
+ // Whole lines, as a local read has them (read.ts caps and trims them).
161
+ return typeof text === "string" ? cleanRemoteBlock(text, 16000) : null;
162
+ }
163
+
164
+ export async function chatFacts(
165
+ link: HostLink,
166
+ id: string
167
+ ): Promise<ChatFacts | null> {
168
+ const res = await hostApi<{ blocked?: unknown; turn?: unknown }>(
169
+ link,
170
+ taskPath(id, "chat-facts"),
171
+ { timeout: TIMEOUT.chat }
172
+ );
173
+ if (res.blocked !== null && typeof res.blocked !== "string") return null;
174
+ if (res.turn !== null && typeof res.turn !== "string") return null;
175
+ return {
176
+ blocked: res.blocked ? cleanRemoteBlock(res.blocked, 2000) : null,
177
+ turn: res.turn,
178
+ };
179
+ }
180
+
181
+ export async function stop(link: HostLink, id: string): Promise<string> {
182
+ const { text } = await hostApi<{ text?: unknown }>(
183
+ link,
184
+ taskPath(id, "stop"),
185
+ {
186
+ body: {},
187
+ timeout: TIMEOUT.stop,
188
+ }
189
+ );
190
+ return cleanRemoteText(text);
191
+ }
192
+
193
+ export async function done(link: HostLink, id: string): Promise<void> {
194
+ await hostApi(link, taskPath(id, "done"), {
195
+ body: {},
196
+ timeout: TIMEOUT.done,
197
+ });
198
+ }
199
+
200
+ /** A merge returns the method that machine merged with; null when it doesn't say. */
201
+ export async function merge(
202
+ link: HostLink,
203
+ id: string,
204
+ pin?: { head: string; base?: string | null },
205
+ label = "it"
206
+ ): Promise<{ head: string | null; method: MergeMethod | null }> {
207
+ // An AgentOS that predates pinned merges would ignore the head and merge
208
+ // whatever the PR is at: refuse before asking it.
209
+ if (pin)
210
+ await requireCapability(link, "pinned-merge", "a merge to a commit", "pin");
211
+ const res = await hostApi<{ head?: string | null; method?: unknown }>(
212
+ link,
213
+ taskPath(id, "merge"),
214
+ {
215
+ // base: what the gates here were judged against; the machine that
216
+ // merges compares the PR with it, not with a base only it recorded.
217
+ body: {
218
+ ...(pin && { head: pin.head }),
219
+ ...(pin?.base && { base: pin.base }),
220
+ },
221
+ timeout: TIMEOUT.merge,
222
+ }
223
+ ).catch(async (err) => {
224
+ if (!unknownOutcome(err)) throw err;
225
+ // A proxy or a dropped connection after the merge ran answers 502 with
226
+ // nothing from AgentOS: ask what state the task is in before failing.
227
+ const merged = await view(link, id, true)
228
+ .then((t) => t?.state === "merged")
229
+ .catch(() => false);
230
+ if (!merged)
231
+ throw new Error(
232
+ `${(err as Error).message}: couldn't confirm whether ${link.hostName} merged ${label}; check its PR before signing off again`
233
+ );
234
+ return { head: pin?.head ?? null, method: undefined };
235
+ });
236
+ forget(link.hostId);
237
+ return {
238
+ head: res.head ?? null,
239
+ method: MERGE_METHODS.find((m) => m === res.method) ?? null,
240
+ };
241
+ }
242
+
243
+ export async function drop(link: HostLink, id: string): Promise<void> {
244
+ await hostApi(link, taskPath(id, "drop"), {
245
+ body: {},
246
+ timeout: TIMEOUT.merge,
247
+ });
248
+ forget(link.hostId);
249
+ }
250
+
251
+ export const resume = (link: HostLink, id: string, body: object = {}) =>
252
+ tell(link, taskPath(id, "resume"), body);
253
+
254
+ export const confirmMoved = (link: HostLink, id: string, to: string) =>
255
+ tell(link, taskPath(id, "moved"), { to });
256
+
257
+ export async function exportOut(
258
+ link: HostLink,
259
+ id: string,
260
+ to: string
261
+ ): Promise<TaskBundle> {
262
+ const { bundle } = await postIdempotent<{ bundle: TaskBundle }>(
263
+ link,
264
+ taskPath(id, "export"),
265
+ { to },
266
+ TIMEOUT.exportOut
267
+ );
268
+ return bundle;
269
+ }
270
+
271
+ export const importBundle = (link: HostLink, bundle: TaskBundle) =>
272
+ postIdempotent<{ session: Session }>(
273
+ link,
274
+ IMPORT,
275
+ bundle,
276
+ TIMEOUT.importBundle
277
+ );
278
+
279
+ export async function arrived(
280
+ link: HostLink,
281
+ from: string
282
+ ): Promise<Session | null> {
283
+ const { session } = await hostApi<{ session: Session | null }>(
284
+ link,
285
+ arrivedPath(from)
286
+ );
287
+ return session;
288
+ }
289
+
290
+ export const replies = (link: HostLink) =>
291
+ hostApi<{ messages?: unknown }>(link, HELD_REPLIES, {
292
+ timeout: TIMEOUT.replies,
293
+ });
294
+
295
+ export const ackReplies = (link: HostLink, ids: number[]) =>
296
+ hostApi(link, HELD_REPLIES, { body: { ids }, timeout: TIMEOUT.replies });