@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
@@ -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
 
@@ -0,0 +1,25 @@
1
+ // A session's last sign of life, in ms: its row, its tmux pane, its chat.
2
+
3
+ import { db, type Session } from "../db";
4
+ import { statusDetector } from "../status-detector";
5
+
6
+ export const sqliteMs = (t: string | null | undefined) =>
7
+ t ? Date.parse(`${t.replace(" ", "T")}Z`) || 0 : 0;
8
+
9
+ export function latestChatAt(sessionId: string): number {
10
+ const row = db
11
+ .prepare(
12
+ `SELECT data FROM chat_items WHERE session_id = ? ORDER BY seq DESC LIMIT 1`
13
+ )
14
+ .get(sessionId) as { data: string } | undefined;
15
+ return row
16
+ ? ((JSON.parse(row.data) as { createdAt?: number }).createdAt ?? 0)
17
+ : 0;
18
+ }
19
+
20
+ export const lastActiveOf = (s: Session): number =>
21
+ Math.max(
22
+ sqliteMs(s.updated_at),
23
+ statusDetector.getTimestamp(s.tmux_name) * 1000,
24
+ latestChatAt(s.id)
25
+ );
@@ -14,8 +14,10 @@ export const MAX_TRANSCRIPT_BYTES = 64 * 1024 * 1024;
14
14
  // whole bundle stays under what a linked machine reads of an answer
15
15
  // (lib/hosts/remote-api), since a move back here reads it as one.
16
16
  export const MAX_CHAT_BYTES = 12 * 1024 * 1024;
17
+ // A task's plan file is a page or two; this is far past that.
18
+ export const MAX_PLAN_BYTES = 1024 * 1024;
17
19
  export const MAX_BUNDLE_BYTES =
18
- MAX_TRANSCRIPT_BYTES + MAX_CHAT_BYTES + 1024 * 1024;
20
+ MAX_TRANSCRIPT_BYTES + MAX_CHAT_BYTES + MAX_PLAN_BYTES + 1024 * 1024;
19
21
 
20
22
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
21
23
  export const isSessionId = (id: unknown): id is string =>
@@ -50,6 +52,25 @@ export interface TaskBundle {
50
52
  // A chat task's own state; absent for a terminal task, or from a machine
51
53
  // that predates chat moves.
52
54
  chat?: ChatBundle;
55
+ // What a task started by an orchestrator carries besides its work; absent
56
+ // for any other task, or from a machine that predates these moves.
57
+ orchestrated?: OrchestratedBundle;
58
+ }
59
+
60
+ export interface OrchestratedBundle {
61
+ // The machine whose orchestrator holds the task, for the other machine's
62
+ // screens and refusals.
63
+ heldFrom: string;
64
+ // When the task first started (an ISO time), so a PR opened before the
65
+ // move still counts as the task's.
66
+ since: string | null;
67
+ // The model's provider and a task's own models, as they were; an arriving
68
+ // machine without that provider refuses the task rather than change it.
69
+ modelProvider: string | null;
70
+ modelOverrides: string | null;
71
+ planPhase: "planning" | "implementing" | null;
72
+ // .agentos/plan.md, which is never in git.
73
+ planFile: string | null;
53
74
  }
54
75
 
55
76
  export interface ChatBundle {
@@ -101,6 +122,34 @@ function validateChat(c: ChatBundle): ChatBundle {
101
122
  return { ...c, plan: !!c.plan };
102
123
  }
103
124
 
125
+ const PHASES = ["planning", "implementing"];
126
+
127
+ function validateOrchestrated(o: OrchestratedBundle): OrchestratedBundle {
128
+ if (!o || typeof o !== "object") throw new Error("Bad orchestrated task");
129
+ if (
130
+ !isStrOrNull(o.since) ||
131
+ !isStrOrNull(o.modelProvider) ||
132
+ !isStrOrNull(o.modelOverrides) ||
133
+ !isStrOrNull(o.planFile) ||
134
+ !(o.planPhase === null || PHASES.includes(o.planPhase as string))
135
+ )
136
+ throw new Error("Bad orchestrated task");
137
+ if (o.since !== null && Number.isNaN(Date.parse(o.since)))
138
+ throw new Error("Bad start time");
139
+ if ((o.planFile?.length ?? 0) > MAX_PLAN_BYTES)
140
+ throw new Error("The plan is too large to move");
141
+ return {
142
+ heldFrom: String(o.heldFrom || "another machine")
143
+ .replace(/[^\w.@ -]/g, "")
144
+ .slice(0, 100),
145
+ since: o.since,
146
+ modelProvider: o.modelProvider,
147
+ modelOverrides: o.modelOverrides,
148
+ planPhase: o.planPhase,
149
+ planFile: o.planFile,
150
+ };
151
+ }
152
+
104
153
  export function validateBundle(b: TaskBundle): TaskBundle {
105
154
  if (!b || typeof b !== "object") throw new Error("No task to import");
106
155
  if (!isSessionId(b.moveId)) throw new Error("Bad move id");
@@ -130,6 +179,9 @@ export function validateBundle(b: TaskBundle): TaskBundle {
130
179
  }
131
180
  return {
132
181
  ...b,
182
+ orchestrated: b.orchestrated
183
+ ? validateOrchestrated(b.orchestrated)
184
+ : undefined,
133
185
  chat: b.chat ? validateChat(b.chat) : undefined,
134
186
  name: b.name.slice(0, 200),
135
187
  from: String(b.from || "another machine")
@@ -0,0 +1,102 @@
1
+ /**
2
+ * An orchestrator's state about a task is keyed by the task's session id, and
3
+ * a move gives the task a new one on each machine. Carrying re-keys that state
4
+ * from the old id to the new, inside the transaction that makes the new row,
5
+ * so the gates, failure counts, asks, scope changes and events judge the task
6
+ * the same after a move. move-carry.test.ts fails on any table that keys by a
7
+ * session and is in neither list, so a new one gets decided.
8
+ */
9
+
10
+ import type Database from "better-sqlite3";
11
+ import { db } from "../db";
12
+
13
+ // table, column, and how the id sits in it: the whole value, `task:<id>`, or
14
+ // the tail of a `<condition>:<id>` key.
15
+ export const REKEYED = [
16
+ ["orchestrator_starts", "target", "id"],
17
+ ["orchestrator_checks", "session_id", "id"],
18
+ ["orchestrator_gate_failures", "session_id", "id"],
19
+ ["orchestrator_gate_failure_commits", "session_id", "id"],
20
+ ["orchestrator_asks", "subject", "task"],
21
+ ["brief_amendments", "session_id", "id"],
22
+ ["task_pr_wakes", "session_id", "id"],
23
+ ["orchestrator_events", "subject", "id"],
24
+ ["orchestrator_events", "key", "tail"],
25
+ ["check_runs", "session_id", "id"],
26
+ ["away_actions", "subject", "task"],
27
+ ["task_queue", "after_task", "id"],
28
+ ] as const;
29
+
30
+ // Session-keyed columns that stay on the old id, and why.
31
+ export const KEPT = [
32
+ ["orchestrator_event_log", "subject", "history is never rewritten"],
33
+ ["stack_items", "session_id", "stacked tasks don't move"],
34
+ [
35
+ "task_queue",
36
+ "after_any",
37
+ "a JSON list; a queued task waits on any of them",
38
+ ],
39
+ ] as const;
40
+
41
+ // Copied onto the new row where it has no value.
42
+ export const CARRIED_COLUMNS = [
43
+ "started_by",
44
+ "plan_decided",
45
+ "plan_reason",
46
+ "plan_phase",
47
+ "model_provider",
48
+ "model_overrides",
49
+ "merge_armed_at",
50
+ "merge_armed_seen",
51
+ "rebased_from",
52
+ "rebased_to",
53
+ "rebase_asked_head",
54
+ "pr_wake_since",
55
+ "pr_url",
56
+ "pr_number",
57
+ "pr_status",
58
+ ] as const;
59
+
60
+ /** The orchestration columns that make a session "an orchestrator's task". */
61
+ export const orchestrationOf = (session: Record<string, unknown>) =>
62
+ Object.fromEntries(
63
+ CARRIED_COLUMNS.map((c) => [c, session[c] ?? null] as const)
64
+ );
65
+
66
+ export function carryTaskState(
67
+ fromId: string,
68
+ toId: string,
69
+ handle: Database.Database = db
70
+ ): void {
71
+ if (fromId === toId) return;
72
+ for (const [table, col, shape] of REKEYED) {
73
+ if (shape === "id")
74
+ handle
75
+ .prepare(`UPDATE ${table} SET ${col} = ? WHERE ${col} = ?`)
76
+ .run(toId, fromId);
77
+ else if (shape === "task")
78
+ handle
79
+ .prepare(`UPDATE ${table} SET ${col} = ? WHERE ${col} = ?`)
80
+ .run(`task:${toId}`, `task:${fromId}`);
81
+ else
82
+ handle
83
+ .prepare(
84
+ `UPDATE OR IGNORE ${table}
85
+ SET ${col} = substr(${col}, 1, length(${col}) - ?) || ?
86
+ WHERE ${col} LIKE ?`
87
+ )
88
+ .run(fromId.length, toId, `%:${fromId}`);
89
+ }
90
+ const from = handle
91
+ .prepare(`SELECT * FROM sessions WHERE id = ?`)
92
+ .get(fromId) as Record<string, unknown> | undefined;
93
+ if (!from) return;
94
+ const sets = [...CARRIED_COLUMNS, "task_since"]
95
+ .map((c) => `${c} = COALESCE(${c}, @${c})`)
96
+ .join(", ");
97
+ handle.prepare(`UPDATE sessions SET ${sets} WHERE id = @id`).run({
98
+ ...orchestrationOf(from),
99
+ task_since: (from.task_since as string | null) ?? from.created_at ?? null,
100
+ id: toId,
101
+ });
102
+ }
@@ -11,7 +11,10 @@ import { isRemoteHost } from "../hosts";
11
11
  import { requireHostLink, type HostLink } from "../hosts/remote-api";
12
12
  import { getTaskSession } from "./session";
13
13
  import { exportOrResume, markMoved, resumeTask, type TaskBundle } from "./move";
14
- import { moveRefusal } from "./move-guard";
14
+ import { isOrchestrated, moveRefusal } from "./move-guard";
15
+ import { holderRefusal } from "./held";
16
+ import { isPaused } from "../orchestrator/pause";
17
+ import { sessionWorkspace } from "../models/resolve";
15
18
  import { arrived as arrivedHere, importTask } from "./import";
16
19
  import {
17
20
  forgetHostTasks,
@@ -34,7 +37,8 @@ const LIVE = new Set(["running", "moving"]);
34
37
  const g = globalThis as unknown as { __agentosMovesInFlight?: Set<string> };
35
38
  const inFlight = (g.__agentosMovesInFlight ??= new Set());
36
39
 
37
- export async function moveTask(id: string, toHostId: string): Promise<Session> {
40
+ /** Why this task can't move to that machine now, as an error; or nothing. */
41
+ export function assertMovable(id: string, toHostId: string): Session {
38
42
  const session = getTaskSession(id);
39
43
  if (!LIVE.has(session.task_status ?? ""))
40
44
  throw new Error(`Task is already ${session.task_status}`);
@@ -47,9 +51,22 @@ export async function moveTask(id: string, toHostId: string): Promise<Session> {
47
51
  : "It's already on this machine"
48
52
  );
49
53
  }
54
+ // A person here can't move what another machine's orchestrator holds.
55
+ const held = holderRefusal(session, null);
56
+ if (held) throw new Error(held);
50
57
  const refusal = moveRefusal(session);
51
58
  if (refusal) throw new Error(refusal);
59
+ // Pause stops the orchestrator's new work, and a move is some.
60
+ const workspace = sessionWorkspace(session);
61
+ if (workspace && isOrchestrated(session) && isPaused(workspace))
62
+ throw new Error("The orchestrator is paused; moves wait for Resume");
52
63
  if (inFlight.has(id)) throw new Error("It's already moving");
64
+ return session;
65
+ }
66
+
67
+ export async function moveTask(id: string, toHostId: string): Promise<Session> {
68
+ const session = assertMovable(id, toHostId);
69
+ const toRemote = isRemoteHost(toHostId);
53
70
  inFlight.add(id);
54
71
  try {
55
72
  const moved = toRemote
@@ -66,19 +83,27 @@ export async function moveTask(id: string, toHostId: string): Promise<Session> {
66
83
  }
67
84
 
68
85
  // A machine on an older AgentOS would take a chat task as a terminal one,
69
- // or refuse to hand one over.
70
- async function requireChatMoves(link: HostLink): Promise<void> {
86
+ // or an orchestrator's task as a plain one that anyone there could merge, or
87
+ // refuse to hand one over.
88
+ async function requireCapability(
89
+ link: HostLink,
90
+ capability: string,
91
+ what: string
92
+ ): Promise<void> {
71
93
  const { capabilities = [] } = await hostTasks(link, true).catch((err) => {
72
94
  throw new Error(
73
- `Can't ask ${link.hostName} whether it takes chat tasks: ${(err as Error).message}`
95
+ `Can't ask ${link.hostName} whether it takes ${what}: ${(err as Error).message}`
74
96
  );
75
97
  });
76
- if (!capabilities.includes("chat-move"))
98
+ if (!capabilities.includes(capability))
77
99
  throw new Error(
78
- `${link.hostName}'s AgentOS can't move chat tasks yet; update it first`
100
+ `${link.hostName}'s AgentOS can't move ${what} yet; update it first`
79
101
  );
80
102
  }
81
103
 
104
+ const requireChatMoves = (link: HostLink) =>
105
+ requireCapability(link, "chat-move", "chat tasks");
106
+
82
107
  const unconfirmed = (where: string, err: unknown) =>
83
108
  new Error(
84
109
  `Couldn't confirm the move with ${where} (${(err as Error).message}). It's paused as "moving": press Move again to finish it.`
@@ -88,6 +113,8 @@ async function moveOut(session: Session, hostId: string): Promise<Session> {
88
113
  const link = requireHostLink(hostId);
89
114
  const chat = session.view === "chat";
90
115
  if (chat) await requireChatMoves(link);
116
+ if (isOrchestrated(session))
117
+ await requireCapability(link, "orchestrated-move", "orchestrator tasks");
91
118
  startProgress(
92
119
  session.id,
93
120
  link.hostName,
@@ -1,13 +1,24 @@
1
1
  /**
2
2
  * Tasks that can't move: one waiting on a person (an escalated gate or an
3
- * open ask), on a LumifyHub card, in a stack, or started by an orchestrator
4
- * (its brakes, ceiling and Pause count only what runs here). Their guard state is keyed
5
- * by this session and wouldn't follow it, so the other machine could merge
6
- * what's waiting on them.
3
+ * open ask), on a LumifyHub card, or in a stack. A task an orchestrator
4
+ * started does move: its state is re-keyed to the new session (move-carry),
5
+ * and the machine it lands on holds it for that orchestrator's device
6
+ * (held.ts), so nothing there merges it.
7
7
  */
8
8
 
9
9
  import { db, type Session } from "../db";
10
10
  import { escalations } from "../orchestrator/gates";
11
+ import { taskSubject } from "../orchestrator/asks";
12
+
13
+ /** Started by an orchestrator, or held for one by the machine it left. */
14
+ export function isOrchestrated(session: Session): boolean {
15
+ if (session.started_by || session.held_by_device) return true;
16
+ return !!db
17
+ .prepare(
18
+ `SELECT 1 FROM orchestrator_starts WHERE kind != 'stack' AND target = ? LIMIT 1`
19
+ )
20
+ .get(session.id);
21
+ }
11
22
 
12
23
  export function moveRefusal(session: Session): string | null {
13
24
  if (session.quick_of)
@@ -16,9 +27,9 @@ export function moveRefusal(session: Session): string | null {
16
27
  return "It's waiting on you (an escalated gate), so it stays here";
17
28
  const ask = db
18
29
  .prepare(
19
- `SELECT 1 FROM orchestrator_asks WHERE subject = ? AND status = 'open' LIMIT 1`
30
+ `SELECT 1 FROM orchestrator_asks WHERE subject IN (?, ?) AND status = 'open' LIMIT 1`
20
31
  )
21
- .get(session.id);
32
+ .get(session.id, taskSubject(session.id));
22
33
  if (ask) return "It has an open ask waiting on you, so it stays here";
23
34
  const row = db
24
35
  .prepare(
@@ -31,12 +42,5 @@ export function moveRefusal(session: Session): string | null {
31
42
  | undefined;
32
43
  if (row?.lh_card_id) return "Card tasks run on this machine only for now";
33
44
  if (row?.stacked) return "Stacked tasks run on this machine only for now";
34
- const orchestrated = db
35
- .prepare(
36
- `SELECT 1 FROM orchestrator_starts WHERE kind != 'stack' AND target = ? LIMIT 1`
37
- )
38
- .get(session.id);
39
- if (orchestrated)
40
- return "An orchestrator's tasks run on this machine only for now";
41
45
  return null;
42
46
  }
@@ -12,6 +12,7 @@ import { hostApi, hostLink, type HostLink } from "../hosts/remote-api";
12
12
  import { getTaskSession } from "./session";
13
13
  import { markMoved, resumeTask } from "./move";
14
14
  import { forgetHostTasks, mirrorTask, unknownOutcome } from "./remote";
15
+ import { carryTaskState } from "./move-carry";
15
16
 
16
17
  /** POST, repeated when the outcome is unknown (the calls are idempotent). */
17
18
  export async function tell(
@@ -50,6 +51,7 @@ export function settleMovedOut(
50
51
  db.transaction(() => {
51
52
  markMoved(session.id, hostName);
52
53
  mirrorTask(hostId, session.project_id, there);
54
+ carryTaskState(session.id, there.id);
53
55
  })();
54
56
  forgetHostTasks(hostId);
55
57
  return db
package/lib/tasks/move.ts CHANGED
@@ -6,18 +6,26 @@
6
6
  * it off and a second move of it is refused.
7
7
  */
8
8
 
9
+ import fs from "fs";
9
10
  import os from "os";
11
+ import path from "path";
10
12
  import { db, type Session } from "../db";
11
13
  import { getProject } from "../projects";
12
14
  import { endTmuxSession } from "../tmux/end";
13
15
  import { launchClaude } from "../agents/launch";
14
16
  import { buildTaskBrief } from "./brief";
15
17
  import { git as runGit } from "../git-exec";
16
- import { getTaskSession } from "./session";
18
+ import { getTaskSession, isoUTC } from "./session";
17
19
  import { projectRef } from "./project-ref";
18
- import { moveRefusal } from "./move-guard";
19
- import { MAX_TRANSCRIPT_BYTES, type TaskBundle } from "./move-bundle";
20
+ import { isOrchestrated, moveRefusal } from "./move-guard";
21
+ import {
22
+ MAX_PLAN_BYTES,
23
+ MAX_TRANSCRIPT_BYTES,
24
+ type OrchestratedBundle,
25
+ type TaskBundle,
26
+ } from "./move-bundle";
20
27
  import { stepProgress } from "./move-progress";
28
+ import { PLAN_FILE } from "../models/plan-step";
21
29
  import { dropSessionDatabase } from "../project-config/database";
22
30
  import { releaseChat } from "../chat/runner";
23
31
  import { collectWhileMoving } from "../chat/hold";
@@ -98,6 +106,28 @@ function claimMoving(id: string, to: string): { fresh: boolean } {
98
106
  // Set by exportTask: did this call stop a running agent, or retry a move?
99
107
  const freshClaims = new Set<string>();
100
108
 
109
+ const readPlanFile = (cwd: string): string | null => {
110
+ try {
111
+ const text = fs.readFileSync(path.join(cwd, PLAN_FILE), "utf8");
112
+ return text.length > MAX_PLAN_BYTES ? null : text;
113
+ } catch {
114
+ return null;
115
+ }
116
+ };
117
+
118
+ // What an orchestrator's task carries besides its work (the orchestrator's
119
+ // own state is re-keyed here; see move-carry).
120
+ function orchestratedBundle(session: Session): OrchestratedBundle {
121
+ return {
122
+ heldFrom: session.held_from || os.hostname(),
123
+ since: isoUTC(session.task_since ?? session.created_at),
124
+ modelProvider: session.model_provider ?? null,
125
+ modelOverrides: session.model_overrides ?? null,
126
+ planPhase: session.plan_phase ?? null,
127
+ planFile: readPlanFile(session.working_directory),
128
+ };
129
+ }
130
+
101
131
  /**
102
132
  * Stops the agent and pushes everything. On failure the task stays moving;
103
133
  * the caller resumes it (exportOrResume) when it knows nothing arrived.
@@ -163,6 +193,7 @@ export async function exportTask(id: string, to: string): Promise<TaskBundle> {
163
193
  ? { sessionId: claudeId, cwd, home: os.homedir(), transcript }
164
194
  : null,
165
195
  chat: chatBundle,
196
+ orchestrated: isOrchestrated(now) ? orchestratedBundle(now) : undefined,
166
197
  };
167
198
  } finally {
168
199
  packed();
@@ -27,6 +27,9 @@ export const TASK_CAPABILITIES = [
27
27
  "chat-turn",
28
28
  "keyed-start",
29
29
  "chat-move",
30
+ // Holds an orchestrator's task for the machine that sent it, and answers
31
+ // its messages, reads, stops and done (lib/tasks/held.ts).
32
+ "orchestrated-move",
30
33
  ] as const;
31
34
 
32
35
  export interface HostTasks {
@@ -16,6 +16,12 @@ import { findPRStrict } from "./gh";
16
16
  import { currentBranch, git } from "../git-exec";
17
17
  import { lookupPR } from "./pr-poll";
18
18
  import { captureMergedTask } from "../session-logs";
19
+ import { getOrchestrator } from "../orchestrator/home";
20
+ import {
21
+ pushCiFailed,
22
+ pushMerged,
23
+ pushReviewReady,
24
+ } from "../notify/push/events";
19
25
  import { notifyStatusChanged } from "../status/hub";
20
26
  import type { TaskPR } from "./state";
21
27
  import { markStatusDirty } from "../status/dirty";
@@ -164,7 +170,7 @@ export async function prFor(
164
170
  const branch = renamed ?? session.branch_name;
165
171
  if (!branch) return null;
166
172
  // A PR older than the task is an earlier use of the branch's name.
167
- const since = isoUTC(session.created_at);
173
+ const since = isoUTC(session.task_since ?? session.created_at);
168
174
  let pr: TaskPR | null;
169
175
  if (renamed) {
170
176
  // Follow a rename only on gh's answer: a failure leaves the row put.
@@ -193,14 +199,19 @@ export async function prFor(
193
199
  prCache.set(session.id, { at: Date.now(), pr });
194
200
  markStatusDirty(session.id);
195
201
  // A PR whose stage inputs moved is news for the sidebar's badge.
196
- if (prSignature(before) !== prSignature(pr)) notifyStatusChanged();
202
+ if (prSignature(before) !== prSignature(pr)) {
203
+ notifyStatusChanged();
204
+ if (pr) pushPrChange(session, pr);
205
+ }
197
206
  if (pr) {
198
207
  db.prepare(
199
208
  `UPDATE sessions SET pr_url = ?, pr_number = ?, pr_status = ? WHERE id = ?`
200
209
  ).run(pr.url, pr.number, pr.state.toLowerCase(), session.id);
201
210
  // Merged on GitHub rather than through AgentOS: its log is due too.
202
- if (pr.state === "MERGED")
211
+ if (pr.state === "MERGED") {
212
+ if (session.pr_status !== "merged") pushMerged(session.id);
203
213
  void captureMergedTask(session.id, { head: pr.head });
214
+ }
204
215
  }
205
216
  // Only an answer just read from GitHub: a cached one may predate a
206
217
  // retarget AgentOS made itself (a restack).
@@ -283,3 +294,16 @@ export function forgetPR(sessionId: string): void {
283
294
  prCache.delete(sessionId);
284
295
  markStatusDirty(sessionId);
285
296
  }
297
+
298
+ // CI failing is news for the phone in any workspace. Green is "ready for
299
+ // review" only where no orchestrator runs a review of its own (its verdict is
300
+ // the signal there). Each head is pushed once: the event key holds the sha.
301
+ export function pushPrChange(session: Session, pr: TaskPR): void {
302
+ if (pr.state !== "OPEN" || !pr.head) return;
303
+ if (pr.checks === "fail") pushCiFailed(session.id, pr.head);
304
+ else if (
305
+ pr.checks === "pass" &&
306
+ !(session.workspace_id && getOrchestrator(session.workspace_id))
307
+ )
308
+ pushReviewReady(session.id, pr.head);
309
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.516",
3
+ "version": "0.3.518",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"
package/server.ts CHANGED
@@ -15,6 +15,7 @@ import { startOrchestratorWatcher } from "./lib/orchestrator/watcher";
15
15
  import { realDeps, schedulesEnabled, startScheduler } from "./lib/schedules";
16
16
  import { startUsageRollup, usageRollupEnabled } from "./lib/usage/nightly";
17
17
  import { resumePhoneOutbox } from "./lib/notify";
18
+ import { resumePushOutbox } from "./lib/notify/push";
18
19
  import {
19
20
  bindAddresses,
20
21
  requestAllowed,
@@ -395,6 +396,7 @@ app.prepare().then(async () => {
395
396
  // Schedules tick here, once a minute, and nowhere else.
396
397
  if (schedulesEnabled(process.env)) startScheduler(realDeps);
397
398
  resumePhoneOutbox();
399
+ resumePushOutbox();
398
400
  // The usage ledger's daily rollup and its ratchet alerts.
399
401
  if (usageRollupEnabled(process.env)) startUsageRollup();
400
402
  // Machine load: a gauge, notes on heavy commands, one alert when red.