@saadnvd1/agent-os 0.3.517 → 0.3.519

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 (57) hide show
  1. package/README.md +23 -3
  2. package/app/api/bus/peers/route.ts +8 -2
  3. package/app/api/tasks/[id]/arm/route.ts +5 -0
  4. package/app/api/tasks/[id]/chat-facts/route.ts +21 -0
  5. package/app/api/tasks/[id]/chat-tail/route.ts +15 -0
  6. package/app/api/tasks/[id]/done/route.ts +15 -0
  7. package/app/api/tasks/[id]/export/route.ts +6 -1
  8. package/app/api/tasks/[id]/merge/route.ts +9 -1
  9. package/app/api/tasks/[id]/message/route.ts +39 -0
  10. package/app/api/tasks/[id]/moved/route.ts +13 -1
  11. package/app/api/tasks/[id]/resume/route.ts +6 -1
  12. package/app/api/tasks/[id]/stop/route.ts +14 -0
  13. package/app/api/tasks/held-replies/route.ts +31 -0
  14. package/app/api/tasks/import/route.ts +11 -1
  15. package/lib/bus/held-reply.ts +89 -0
  16. package/lib/bus/index.ts +101 -12
  17. package/lib/db/migrations.ts +50 -0
  18. package/lib/db/types.ts +9 -0
  19. package/lib/done/bulk.ts +8 -0
  20. package/lib/done/index.ts +15 -0
  21. package/lib/done/worktree.ts +24 -10
  22. package/lib/hosts/remote-api.ts +12 -0
  23. package/lib/models/plan-step.ts +13 -3
  24. package/lib/orchestrator/act.ts +41 -1
  25. package/lib/orchestrator/armed.ts +6 -1
  26. package/lib/orchestrator/brakes.ts +9 -0
  27. package/lib/orchestrator/brief.ts +2 -1
  28. package/lib/orchestrator/facts.ts +72 -21
  29. package/lib/orchestrator/read.ts +14 -2
  30. package/lib/orchestrator/serve.ts +6 -0
  31. package/lib/orchestrator/shell-guard.ts +15 -0
  32. package/lib/orchestrator/task-state.ts +40 -15
  33. package/lib/orchestrator/tool-names.ts +1 -0
  34. package/lib/orchestrator/tool-schemas.ts +11 -0
  35. package/lib/orchestrator/tools.ts +1 -0
  36. package/lib/security/demo.ts +1 -0
  37. package/lib/sessions/stop.ts +15 -1
  38. package/lib/status-detector.ts +4 -0
  39. package/lib/tasks/branch-worktree.ts +16 -1
  40. package/lib/tasks/finish.ts +5 -1
  41. package/lib/tasks/held-remote.ts +109 -0
  42. package/lib/tasks/held-replies.ts +106 -0
  43. package/lib/tasks/held-routes.ts +33 -0
  44. package/lib/tasks/held.ts +28 -0
  45. package/lib/tasks/import.ts +65 -10
  46. package/lib/tasks/index.ts +11 -1
  47. package/lib/tasks/last-active.ts +25 -0
  48. package/lib/tasks/move-bundle.ts +72 -3
  49. package/lib/tasks/move-carry.ts +102 -0
  50. package/lib/tasks/move-flow.ts +55 -9
  51. package/lib/tasks/move-guard.ts +17 -13
  52. package/lib/tasks/move-recover.ts +2 -0
  53. package/lib/tasks/move.ts +49 -3
  54. package/lib/tasks/moved-away.ts +37 -0
  55. package/lib/tasks/remote.ts +6 -0
  56. package/lib/tasks/session.ts +1 -1
  57. package/package.json +1 -1
@@ -1689,6 +1689,56 @@ const migrations: Migration[] = [
1689
1689
  `);
1690
1690
  },
1691
1691
  },
1692
+ {
1693
+ id: 73,
1694
+ name: "orchestrated_task_moves",
1695
+ up: (db) => {
1696
+ // task_since: when the task first started, kept across moves (null:
1697
+ // created_at). held_by_device / held_from: the device and machine of
1698
+ // the orchestrator that moved this task here; only that device may
1699
+ // merge, arm, message or move it. plan_note: the "plan written" line
1700
+ // of a held task, for its orchestrator to read.
1701
+ const add = (col: string) => {
1702
+ const columns = db.prepare(`PRAGMA table_info(sessions)`).all() as {
1703
+ name: string;
1704
+ }[];
1705
+ if (!columns.some((c) => c.name === col))
1706
+ db.exec(`ALTER TABLE sessions ADD COLUMN ${col} TEXT`);
1707
+ };
1708
+ for (const col of [
1709
+ "task_since",
1710
+ "held_by_device",
1711
+ "held_from",
1712
+ "plan_note",
1713
+ ])
1714
+ add(col);
1715
+ },
1716
+ },
1717
+ {
1718
+ id: 74,
1719
+ name: "held_task_replies",
1720
+ up: (db) => {
1721
+ // held_for / held_for_name: the orchestrator (id, name there) a held
1722
+ // task messages. relay_state: a held task's message to it, waiting for
1723
+ // the holder to pull it (pending) or pulled (relayed). relay_key: a
1724
+ // pulled message's key on the orchestrator's machine, so a repeat pull
1725
+ // is not delivered twice.
1726
+ const has = (table: string, col: string) =>
1727
+ (
1728
+ db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[]
1729
+ ).some((c) => c.name === col);
1730
+ for (const col of ["held_for", "held_for_name"])
1731
+ if (!has("sessions", col))
1732
+ db.exec(`ALTER TABLE sessions ADD COLUMN ${col} TEXT`);
1733
+ for (const col of ["relay_state", "relay_key"])
1734
+ if (!has("bus_messages", col))
1735
+ db.exec(`ALTER TABLE bus_messages ADD COLUMN ${col} TEXT`);
1736
+ db.exec(
1737
+ `CREATE UNIQUE INDEX IF NOT EXISTS idx_bus_relay_key ON bus_messages(relay_key) WHERE relay_key IS NOT NULL;
1738
+ CREATE INDEX IF NOT EXISTS idx_bus_relay_pending ON bus_messages(relay_state) WHERE relay_state = 'pending'`
1739
+ );
1740
+ },
1741
+ },
1692
1742
  ];
1693
1743
 
1694
1744
  // `upTo`: stop after this id (tests that start from an older database).
package/lib/db/types.ts CHANGED
@@ -119,6 +119,15 @@ export interface Session {
119
119
  rebase_asked_head?: string | null;
120
120
  // When its PR's reviews and comments started being watched (pr-wake.ts).
121
121
  pr_wake_since?: string | null;
122
+ // When the task first started, kept across moves (null: created_at).
123
+ task_since?: string | null;
124
+ // The device and machine of the orchestrator that moved this task here.
125
+ held_by_device?: string | null;
126
+ held_from?: string | null;
127
+ held_for?: string | null;
128
+ held_for_name?: string | null;
129
+ // A held task's "plan written" line, for its orchestrator to read.
130
+ plan_note?: string | null;
122
131
  // Orchestration fields
123
132
  conductor_session_id: string | null;
124
133
  worker_task: string | null;
package/lib/done/bulk.ts CHANGED
@@ -54,6 +54,8 @@ function scopeSessions(scope: DoneScope): Session[] {
54
54
  AND role IS NULL AND archived_at IS NULL
55
55
  -- A linked machine's own sessions are cleaned up there.
56
56
  AND peer_mirror = 0
57
+ -- A task another machine's orchestrator moved here is its to finish.
58
+ AND held_by_device IS NULL
57
59
  AND (task_status IS NULL OR task_status = 'running')
58
60
  ORDER BY created_at`
59
61
  )
@@ -75,6 +77,12 @@ export async function idleSessions(
75
77
 
76
78
  // The caller's workspace when its project has one, else its project.
77
79
  export function scopeOf(session: Session): DoneScope {
80
+ // An orchestrator acts through its tools, which Pause holds and which are
81
+ // logged; aos would reach the same sessions past both.
82
+ if (session.role === "orchestrator")
83
+ throw new Error(
84
+ `${session.name} is an orchestrator: it moves, finishes and stops sessions with its move, done and stop tools, not aos`
85
+ );
78
86
  const project = session.project_id ? getProject(session.project_id) : null;
79
87
  if (!project || project.is_uncategorized)
80
88
  throw new Error(`${session.name} isn't in a project`);
package/lib/done/index.ts CHANGED
@@ -25,6 +25,8 @@ import { sessionMergePolicy } from "../tasks/finish";
25
25
  import { releasePorts } from "../ports";
26
26
  import { dropSessionDatabase } from "../project-config/database";
27
27
  import { doneOnPeer, peerSessionLink } from "../hosts/peer-actions";
28
+ import { hostLink } from "../hosts/remote-api";
29
+ import { doneHeldTask } from "../tasks/held-remote";
28
30
  import { captureMergedTask } from "../session-logs";
29
31
  import { pushMerged } from "../notify/push/events";
30
32
 
@@ -161,6 +163,19 @@ export async function doneSession(
161
163
  await stopAgent(s);
162
164
  // The sign-off's cleanup has already settled it; this reads the result.
163
165
  worktree = await settleWorktree(s, { prHead: m.sha });
166
+ } else if (s.started_by && s.task_prompt && hostLink(s.host_id)) {
167
+ // Moved to a linked machine by its orchestrator: that machine stops the
168
+ // agent and removes the worktree, which is where they are.
169
+ await doneHeldTask(s);
170
+ if (plan.mark) mark(s, plan.mark, plan.pr);
171
+ else
172
+ db.prepare(
173
+ `UPDATE sessions SET task_status = 'done' WHERE id = ? AND task_status = 'running'`
174
+ ).run(s.id);
175
+ worktree = {
176
+ action: "removed",
177
+ why: `on ${hostLink(s.host_id)?.hostName ?? "its machine"}`,
178
+ };
164
179
  } else {
165
180
  if (plan.mark) mark(s, plan.mark, plan.pr);
166
181
  if (
@@ -114,14 +114,15 @@ const gone = (merged: MergedAs | null, still = false) => ({
114
114
  export async function worktreeFate(
115
115
  session: Judged,
116
116
  merged: MergedAs | null,
117
- waitMs = 60_000
117
+ waitMs = 60_000,
118
+ movedAway = false
118
119
  ): Promise<FatePreview> {
119
120
  const path = session.worktree_path;
120
121
  if (!path) return { action: "none" };
121
122
  for (;;) {
122
123
  if (!(await removalSettled(path, waitMs))) return gone(merged, true);
123
124
  if (!fs.existsSync(path)) return gone(merged);
124
- const fate = await judge(session, merged);
125
+ const fate = await judge(session, merged, movedAway);
125
126
  // A removal that started while it read: read what it leaves.
126
127
  if (!removalRunning(path) && fs.existsSync(path)) return fate;
127
128
  }
@@ -133,8 +134,17 @@ async function commitsKeep(
133
134
  path: string,
134
135
  repo: string,
135
136
  session: Judged,
136
- merged: MergedAs | null
137
+ merged: MergedAs | null,
138
+ movedAway = false
137
139
  ): Promise<string | null> {
140
+ // The task went on elsewhere: only commits no remote has would be lost.
141
+ if (movedAway) {
142
+ const stray = await strayCommits(path, {}).catch(() => null);
143
+ if (stray === null) return "its commits can't be checked";
144
+ return stray > 0
145
+ ? `${stray} commit${stray === 1 ? " is" : "s are"} on it that no remote has`
146
+ : null;
147
+ }
138
148
  if (merged) {
139
149
  const stray = await strayCommits(path, merged).catch(() => null);
140
150
  if (stray === null) return "its commits can't be checked";
@@ -163,7 +173,8 @@ async function commitsKeep(
163
173
  // kept one names every reason, the commits as well as the changes.
164
174
  async function judge(
165
175
  session: Judged,
166
- merged: MergedAs | null
176
+ merged: MergedAs | null,
177
+ movedAway = false
167
178
  ): Promise<FatePreview> {
168
179
  const path = session.worktree_path!;
169
180
  const keep = (why: string) => ({ action: "kept" as const, why, path });
@@ -183,14 +194,16 @@ async function judge(
183
194
  unsaved.length
184
195
  ? `it has uncommitted changes (${describeChanges(unsaved)})`
185
196
  : null,
186
- await commitsKeep(path, repo, session, merged),
197
+ await commitsKeep(path, repo, session, merged, movedAway),
187
198
  ].filter((w) => w !== null);
188
199
  if (why.length) return keep(why.join(", and "));
189
200
  return {
190
201
  action: "remove",
191
- why: merged
192
- ? "its branch is merged"
193
- : "its branch has no commits of its own",
202
+ why: movedAway
203
+ ? "its work moved to another machine"
204
+ : merged
205
+ ? "its branch is merged"
206
+ : "its branch has no commits of its own",
194
207
  repo,
195
208
  };
196
209
  }
@@ -199,9 +212,10 @@ async function judge(
199
212
  // and says so only once it's really gone.
200
213
  export async function settleWorktree(
201
214
  session: Judged,
202
- merged: MergedAs | null
215
+ merged: MergedAs | null,
216
+ movedAway = false
203
217
  ): Promise<WorktreeFate> {
204
- const fate = await worktreeFate(session, merged);
218
+ const fate = await worktreeFate(session, merged, undefined, movedAway);
205
219
  if (fate.action !== "remove") return fate;
206
220
  const path = session.worktree_path!;
207
221
  await deleteWorktree(path, fate.repo, true).catch(() => {});
@@ -70,6 +70,18 @@ export const cleanRemoteText = (s: unknown): string =>
70
70
  .trim()
71
71
  .slice(0, 300);
72
72
 
73
+ /**
74
+ * Another machine's multi-line text (a chat's tail): control characters
75
+ * other than newline and tab become spaces, and the end is kept within `cap`.
76
+ */
77
+ export const cleanRemoteBlock = (s: unknown, cap: number): string => {
78
+ const text = String(s ?? "")
79
+ .replace(/\r\n?/g, "\n")
80
+ .replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, " ")
81
+ .replace(/\s+$/, "");
82
+ return text.length > cap ? `…${text.slice(text.length - cap + 1)}` : text;
83
+ };
84
+
73
85
  /**
74
86
  * Why a call failed. `refused`: that AgentOS answered and said no, so nothing
75
87
  * happened there. Otherwise the outcome is unknown (unreachable, timed out,
@@ -73,9 +73,12 @@ export function planSummary(text: string, max = 5): string[] {
73
73
 
74
74
  // Tells the orchestrator that started a task its plan is written, once.
75
75
  function announcePlan(s: Session, ref: string): void {
76
- if (!s.started_by) return;
76
+ if (!s.started_by && !s.held_by_device) return;
77
77
  const workspaceId = sessionWorkspace(s);
78
- if (!workspaceId || !s.worktree_path) return;
78
+ // A task held for another machine's orchestrator can't reach it: the line
79
+ // waits on the task for that orchestrator to read (orchestrator/facts).
80
+ if (!s.held_by_device && !workspaceId) return;
81
+ if (!s.worktree_path) return;
79
82
  const file = path.join(s.worktree_path, PLAN_FILE);
80
83
  let head = `Task "${s.name}": no plan file was written; implementing on ${ref} anyway.`;
81
84
  try {
@@ -85,7 +88,14 @@ function announcePlan(s: Session, ref: string): void {
85
88
  } catch {
86
89
  // No plan file: the line says so.
87
90
  }
88
- queueEvent(workspaceId, `plan-done:${s.id}`, s.id, head);
91
+ if (s.held_by_device) {
92
+ db.prepare(`UPDATE sessions SET plan_note = ? WHERE id = ?`).run(
93
+ head,
94
+ s.id
95
+ );
96
+ return;
97
+ }
98
+ queueEvent(workspaceId!, `plan-done:${s.id}`, s.id, head);
89
99
  }
90
100
 
91
101
  export const implementMessageId = (sessionId: string) =>
@@ -19,7 +19,9 @@ import {
19
19
  launchSession,
20
20
  type StartView,
21
21
  } from "../sessions/launch";
22
- import { createTask, dropTask } from "../tasks";
22
+ import { assertMovable, createTask, dropTask, moveTask } from "../tasks";
23
+ import { getHost, hostIdNamed } from "../hosts";
24
+ import { queueEvent } from "./events";
23
25
  import {
24
26
  findQueued,
25
27
  queuedLabel,
@@ -280,3 +282,41 @@ export async function done(workspaceId: string, ref: string): Promise<string> {
280
282
  export async function stop(workspaceId: string, ref: string): Promise<string> {
281
283
  return stopSession(findWorkspaceSession(workspaceId, ref));
282
284
  }
285
+
286
+ // Carries a task to a linked machine, or back ("here"): heavy test or build
287
+ // work can run on the box. The move takes minutes, so it runs in the
288
+ // background; the orchestrator is told when the task lands or doesn't.
289
+ // Everything about the task (gates, events, read/send/stop/drop) is the same
290
+ // wherever it runs; the machine it lands on holds it for this one.
291
+ export async function moveTaskTo(
292
+ workspaceId: string,
293
+ ref: string,
294
+ machine: string
295
+ ): Promise<string> {
296
+ const task = workspaceTask(workspaceId, ref);
297
+ const hostId = hostIdNamed(machine);
298
+ assertMovable(task.id, hostId);
299
+ const where = hostId === "local" ? "this machine" : machineName(hostId);
300
+ void moveTask(task.id, hostId).then(
301
+ (moved) => {
302
+ addNote(workspaceId, `Moved task ${task.name} to ${where}.`);
303
+ queueEvent(
304
+ workspaceId,
305
+ `move:${moved.id}:${Date.now()}`,
306
+ moved.id,
307
+ `Task ${task.name} now runs on ${where}.`
308
+ );
309
+ },
310
+ (error) =>
311
+ queueEvent(
312
+ workspaceId,
313
+ `move-failed:${task.id}:${Date.now()}`,
314
+ task.id,
315
+ `Moving task ${task.name} to ${where} failed: ${error instanceof Error ? error.message : String(error)}. It carries on where it was unless it says it's paused as "moving".`
316
+ )
317
+ );
318
+ return `Moving ${task.name} to ${where}. It takes a few minutes; an event tells you when it runs there.`;
319
+ }
320
+
321
+ const machineName = (hostId: string) =>
322
+ getHost(hostId)?.name ?? "the other machine";
@@ -58,7 +58,8 @@ function armedTasks(workspaceId: string): Session[] {
58
58
  .prepare(
59
59
  `SELECT s.* FROM sessions s JOIN projects p ON p.id = s.project_id
60
60
  WHERE p.workspace_id = ? AND s.merge_armed_at IS NOT NULL
61
- AND s.task_status = 'running' AND s.peer_mirror = 0`
61
+ AND s.task_status = 'running' AND s.peer_mirror = 0
62
+ AND s.held_by_device IS NULL`
62
63
  )
63
64
  .all(workspaceId) as Session[];
64
65
  }
@@ -100,6 +101,10 @@ export function armTask(task: Session): void {
100
101
  );
101
102
  if (task.peer_mirror)
102
103
  throw new Error(`${task.name} runs on a linked machine; arm it there`);
104
+ if (task.held_by_device)
105
+ throw new Error(
106
+ `${task.name} is held for ${task.held_from ?? "another machine"}'s orchestrator; it arms it there`
107
+ );
103
108
  if (!armingWorkspace(task))
104
109
  throw new Error(
105
110
  `${task.name}'s project isn't in a workspace with an orchestrator, which an armed merge reports to`
@@ -74,6 +74,15 @@ export async function runningCount(
74
74
  "running";
75
75
  if (busy) running.add(s.id);
76
76
  }
77
+ // A task mid-move is out of the list above, but it runs again within
78
+ // minutes, here or there: it holds its slot.
79
+ const moving = db
80
+ .prepare(
81
+ `SELECT s.id FROM sessions s JOIN projects p ON p.id = s.project_id
82
+ WHERE p.workspace_id = ? AND s.task_status = 'moving' AND s.peer_mirror = 0`
83
+ )
84
+ .all(workspaceId) as { id: string }[];
85
+ for (const { id } of moving) running.add(id);
77
86
  for (const { target } of startsSince(workspaceId, now - WARMUP_MS))
78
87
  if (target) running.add(target);
79
88
  return running.size;
@@ -109,7 +109,8 @@ Acting:
109
109
  - \`${TOOL_NAMES.quick_pr}\` (change, title, body): commit everything in a quick change, push its branch (never the default branch) and open its PR, or push to the PR it has; its review starts at once. Then it's gated like any task: CI, that review (also its code-review gate), scope, then sign_off or arm. It's in \`sessions\` as your quick change; drop it to throw it away, and it's cleaned up like a task when it merges or is dropped.
110
110
  - \`${TOOL_NAMES.stack}\` (target, plan_only?): run a board's open cards as stacked tasks; plan_only shows the plan without starting.
111
111
  - \`${TOOL_NAMES.land}\` (id): merge a stack bottom-up through the gates.
112
- - \`${TOOL_NAMES.drop}\` (task, reason): close a task's PR and remove its worktree.
112
+ - \`${TOOL_NAMES.move}\` (task, machine): carry a running task to a linked machine (heavy test or build work), or back with machine "here". It keeps its branch, conversation and plan, and everything you do with a task works the same there: its events come to you, read, send, stop, done and drop reach it, and the gates judge its PR at its head. The machine it lands on holds it for you and can't merge it. It runs in the background; an event says when it lands. Refused while paused, and for a task waiting on Saad.
113
+ - \`${TOOL_NAMES.drop}\` (task, reason): close a task's PR and remove its worktree (on whichever machine holds it).
113
114
  - \`${TOOL_NAMES.stop}\` (session): stop a session's agent, keeping its work.
114
115
  - \`${TOOL_NAMES.done}\` (session): finish a session whose work is complete. A task with an open PR merges through the same gates as sign_off first (refused naming the gate otherwise); then the agent stops, the worktree goes only if nothing in it would be lost, and the session is archived out of view. Not a rejection: use drop for that.
115
116
  - \`${TOOL_NAMES.add_project}\` (path, name?): make a git repository on this machine a project of this workspace, or move the project already at that path in when it's in no workspace. Use it rather than sending Saad steps. It refuses a folder that isn't a git repository or holds secrets, and a project another workspace has: moving that is Saad's call, so ask_saad.
@@ -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)