@saadnvd1/agent-os 0.3.518 → 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.
package/README.md CHANGED
@@ -1587,7 +1587,10 @@ Once linked:
1587
1587
  judging the PR at its head, reads BLOCKED lines and open approvals from the
1588
1588
  task's chat there, and gets PR, CI, idle and plan events by polling that
1589
1589
  machine. Its messages arrive marked as from the orchestrator, never as
1590
- yours. Guards (write guard, shell deny list, attribution settings) run in
1590
+ yours. The task can `aos send` its orchestrator back: the machine it runs
1591
+ on only stores the message, and the orchestrator's machine pulls it and
1592
+ delivers it as that task's own. Moving it back stops its agent there and
1593
+ removes its worktree when nothing in it would be lost. Guards (write guard, shell deny list, attribution settings) run in
1591
1594
  the task's worker on either machine, and the task can't merge or push to
1592
1595
  the default branch. Moving counts toward the brakes and Pause refuses the
1593
1596
  tool.
@@ -1,10 +1,16 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
- import { listPeers, resolveTarget } from "@/lib/bus";
2
+ import { listPeers, resolveHeldOrchestrator, resolveTarget } from "@/lib/bus";
3
3
 
4
4
  // ?ref=<name>: just the session that name reaches, the way aos send would.
5
5
  export async function GET(request: NextRequest) {
6
6
  const ref = request.nextUrl.searchParams.get("ref");
7
- if (ref === null) return NextResponse.json({ peers: await listPeers() });
7
+ // from: the asking session, which a held task needs for its orchestrator.
8
+ const from = request.nextUrl.searchParams.get("from") ?? "";
9
+ if (ref === null)
10
+ return NextResponse.json({ peers: await listPeers(from || undefined) });
11
+ const held = from ? resolveHeldOrchestrator(from, ref) : null;
12
+ if (held)
13
+ return NextResponse.json({ peer: { id: held.id, name: held.name } });
8
14
  try {
9
15
  const { session, note } = resolveTarget(ref);
10
16
  return NextResponse.json({
@@ -0,0 +1,21 @@
1
+ import { NextRequest } from "next/server";
2
+ import { blockedReason } from "@/lib/tasks/state";
3
+ import { saidSinceLastMessage } from "@/lib/chat/store";
4
+ import { chatStateNow } from "@/lib/chat/runner";
5
+ import { heldRoute } from "@/lib/tasks/held-routes";
6
+
7
+ // GET /api/tasks/:id/chat-facts - what its chat says about being blocked
8
+ // and whether a question is open, for the orchestrator that moved it here.
9
+ // Cheap (no PR lookups), unlike the whole task list.
10
+ export async function GET(
11
+ request: NextRequest,
12
+ ctx: { params: Promise<{ id: string }> }
13
+ ) {
14
+ return heldRoute(request, ctx, async (task) => {
15
+ if (task.view !== "chat") return { blocked: null, turn: null };
16
+ return {
17
+ blocked: blockedReason(saidSinceLastMessage(task.id)),
18
+ turn: await chatStateNow(task.id).catch(() => "unknown"),
19
+ };
20
+ });
21
+ }
@@ -2,6 +2,8 @@ import { NextRequest, NextResponse } from "next/server";
2
2
  import { markMoved } from "@/lib/tasks/move";
3
3
  import { callingDevice } from "@/lib/security/device-me";
4
4
  import { heldRefusal } from "@/lib/tasks/held";
5
+ import { cleanUpMovedAway } from "@/lib/tasks/moved-away";
6
+ import { runInBackground } from "@/lib/async-operations";
5
7
 
6
8
  // POST /api/tasks/:id/moved - the task now runs on the machine named `to`
7
9
  export async function POST(
@@ -13,5 +15,10 @@ export async function POST(
13
15
  if (held) return NextResponse.json({ error: held }, { status: 403 });
14
16
  const { to } = await request.json().catch(() => ({}));
15
17
  markMoved(id, String(to || "another machine").slice(0, 100));
18
+ // A task its orchestrator took back: its agent and worktree here go.
19
+ if (held === null)
20
+ runInBackground(async () => {
21
+ await cleanUpMovedAway(id);
22
+ }, `moved-away-${id}`);
16
23
  return NextResponse.json({ success: true });
17
24
  }
@@ -0,0 +1,31 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { callingDevice } from "@/lib/security/device-me";
3
+ import { ackReplies, pendingReplies } from "@/lib/bus/held-reply";
4
+
5
+ // GET /api/tasks/held-replies - what the tasks this device moved here have
6
+ // said to their orchestrator, for it to deliver there. Only that device's.
7
+ export async function GET(request: NextRequest) {
8
+ const device = callingDevice(request);
9
+ if (!device)
10
+ return NextResponse.json(
11
+ { error: "Needs a paired device" },
12
+ { status: 403 }
13
+ );
14
+ return NextResponse.json({ messages: pendingReplies(device) });
15
+ }
16
+
17
+ // POST { ids } - those are delivered; they are not offered again.
18
+ export async function POST(request: NextRequest) {
19
+ const device = callingDevice(request);
20
+ if (!device)
21
+ return NextResponse.json(
22
+ { error: "Needs a paired device" },
23
+ { status: 403 }
24
+ );
25
+ const { ids } = await request.json().catch(() => ({}));
26
+ const list = (Array.isArray(ids) ? ids : []).filter((n): n is number =>
27
+ Number.isSafeInteger(n)
28
+ );
29
+ ackReplies(device, list.slice(0, 200));
30
+ return NextResponse.json({ success: true });
31
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * A task its orchestrator moved here has no orchestrator on this machine to
3
+ * message, and this machine never reaches back to the one that holds it. Its
4
+ * message to the orchestrator is only stored here (relay_state 'pending');
5
+ * the holder pulls it (lib/tasks/held-replies.ts) and delivers it there as
6
+ * that task's own. This machine never delivers or attributes it, and the
7
+ * holder, not the sender, says who receives it.
8
+ */
9
+
10
+ import { db, type Session } from "../db";
11
+ import { resolveRef } from "./resolve";
12
+
13
+ export interface HeldOrchestrator {
14
+ id: string;
15
+ name: string;
16
+ heldFrom: string;
17
+ }
18
+
19
+ /** The orchestrator a running held task messages, or null. */
20
+ export function heldOrchestrator(
21
+ s: Session | undefined
22
+ ): HeldOrchestrator | null {
23
+ if (
24
+ !s ||
25
+ !s.held_by_device ||
26
+ !s.held_for ||
27
+ !s.held_for_name ||
28
+ s.task_status !== "running"
29
+ )
30
+ return null;
31
+ return {
32
+ id: s.held_for,
33
+ name: s.held_for_name,
34
+ heldFrom: s.held_from || "another machine",
35
+ };
36
+ }
37
+
38
+ /** The orchestrator `ref` names for this held sender: its name, id prefix or "orchestrator". */
39
+ export function heldTarget(
40
+ from: Session | undefined,
41
+ ref: string
42
+ ): HeldOrchestrator | null {
43
+ const o = from && heldOrchestrator(from);
44
+ if (!o) return null;
45
+ if (ref.trim().toLowerCase() === "orchestrator") return o;
46
+ const r = resolveRef(ref, [
47
+ {
48
+ id: o.id,
49
+ name: o.name,
50
+ projectName: null,
51
+ tmuxName: "",
52
+ previousNames: [],
53
+ },
54
+ ]);
55
+ return r.ok ? o : null;
56
+ }
57
+
58
+ export interface PendingReply {
59
+ id: number;
60
+ fromId: string;
61
+ body: string;
62
+ createdAt: string;
63
+ }
64
+
65
+ /** What the calling device's held tasks have said to their orchestrators. */
66
+ export function pendingReplies(device: string, limit = 50): PendingReply[] {
67
+ return (
68
+ db
69
+ .prepare(
70
+ `SELECT m.id, m.from_id AS fromId, m.body, m.created_at AS createdAt
71
+ FROM bus_messages m JOIN sessions s ON s.id = m.from_id
72
+ WHERE m.relay_state = 'pending' AND s.held_by_device = ?
73
+ ORDER BY m.id LIMIT ?`
74
+ )
75
+ .all(device, limit) as PendingReply[]
76
+ ).map((r) => ({ ...r, id: Number(r.id) }));
77
+ }
78
+
79
+ /** Marks them pulled; only the device's own are touched. */
80
+ export function ackReplies(device: string, ids: number[]): void {
81
+ const mark = db.prepare(
82
+ `UPDATE bus_messages SET relay_state = 'relayed', delivered_at = datetime('now')
83
+ WHERE id = ? AND relay_state = 'pending'
84
+ AND from_id IN (SELECT id FROM sessions WHERE held_by_device = ?)`
85
+ );
86
+ db.transaction(() => {
87
+ for (const id of ids) mark.run(id, device);
88
+ })();
89
+ }
package/lib/bus/index.ts CHANGED
@@ -16,6 +16,7 @@ import { resolveRef, wasNames, type Candidate } from "./resolve";
16
16
  import { tmuxPane } from "./tmux-pane";
17
17
  import { hostLink } from "../hosts/remote-api";
18
18
  import { relayToHeldTask } from "../tasks/held-remote";
19
+ import { heldOrchestrator, heldTarget } from "./held-reply";
19
20
  import { HUMAN, overRateLimit, wakeLine, type BusMessageView } from "./format";
20
21
 
21
22
  export * from "./format";
@@ -54,11 +55,21 @@ function candidates(sessions: Session[]): Candidate[] {
54
55
  }));
55
56
  }
56
57
 
57
- export async function listPeers(): Promise<Peer[]> {
58
+ const sessionById = (id: string | null | undefined) =>
59
+ id
60
+ ? (db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(id) as
61
+ | Session
62
+ | undefined)
63
+ : undefined;
64
+
65
+ // `callerId`: the asking session. A task its orchestrator moved here also
66
+ // sees that orchestrator, which it can message though it runs elsewhere.
67
+ export async function listPeers(callerId?: string): Promise<Peer[]> {
58
68
  await statusDetector.refreshCache();
59
69
  const sessions = liveSessions();
60
70
  const named = candidates(sessions);
61
- return Promise.all(
71
+ const orchestrator = heldOrchestrator(sessionById(callerId));
72
+ const listed = await Promise.all(
62
73
  sessions.map(async (s, i) => {
63
74
  const status = statusDetector.sessionExists(s.tmux_name)
64
75
  ? await statusDetector.getStatus(s.tmux_name)
@@ -76,6 +87,25 @@ export async function listPeers(): Promise<Peer[]> {
76
87
  };
77
88
  })
78
89
  );
90
+ if (!orchestrator) return listed;
91
+ return [
92
+ ...listed,
93
+ {
94
+ id: orchestrator.id,
95
+ name: orchestrator.name,
96
+ was: [],
97
+ projectName: null,
98
+ tmuxName: "",
99
+ hostId: "remote",
100
+ running: true,
101
+ doing: `your orchestrator, on ${orchestrator.heldFrom}`,
102
+ },
103
+ ];
104
+ }
105
+
106
+ // The orchestrator a held caller's ref names, if it names it.
107
+ export function resolveHeldOrchestrator(callerId: string, ref: string) {
108
+ return heldTarget(sessionById(callerId), ref);
79
109
  }
80
110
 
81
111
  // A live session by name, old name, project/name, id or id prefix. The note
@@ -208,16 +238,18 @@ export async function sendMessage(opts: {
208
238
  // It's never left unread for `aos inbox` to show in another form; a
209
239
  // failed one is the sender's to retry.
210
240
  line?: string;
241
+ // A message pulled from a linked machine, by its key there: one already
242
+ // stored is not stored or delivered again.
243
+ relayKey?: string;
211
244
  }): Promise<{ message: BusMessageView; delivery: Delivery; note?: string }> {
212
245
  const body = opts.body.trim();
213
246
  if (!body) throw new Error("Message is empty");
214
- const { session: to, note } = resolveTarget(opts.to);
215
- const from = opts.fromId
216
- ? (db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(opts.fromId) as
217
- | Session
218
- | undefined)
219
- : undefined;
247
+ const from = sessionById(opts.fromId);
220
248
  if (opts.fromId && !from) throw new Error("Unknown sender session");
249
+ // A held task's message to its orchestrator is kept for the holder to pull.
250
+ const held = heldTarget(from, opts.to);
251
+ if (from && held) return storeForHolder(from, held, body);
252
+ const { session: to, note } = resolveTarget(opts.to);
221
253
  if (from && from.id === to.id)
222
254
  throw new Error("A session can't message itself");
223
255
  if (from && overRateLimit(pairTimestamps(from.id, to.id))) {
@@ -225,13 +257,32 @@ export async function sendMessage(opts: {
225
257
  `Too many messages between ${from.name} and ${to.name}; wait a few minutes`
226
258
  );
227
259
  }
260
+ if (opts.relayKey) {
261
+ const seen = db
262
+ .prepare(`SELECT id FROM bus_messages WHERE relay_key = ?`)
263
+ .get(opts.relayKey) as { id: number } | undefined;
264
+ if (seen)
265
+ return {
266
+ message: toView(
267
+ db.prepare(`${SELECT} WHERE m.id = ?`).get(seen.id) as Row
268
+ ),
269
+ delivery: { state: "delivered" },
270
+ };
271
+ }
228
272
 
229
273
  const fromName = from?.name ?? opts.fromLabel ?? HUMAN;
230
274
  const { lastInsertRowid } = db
231
275
  .prepare(
232
- `INSERT INTO bus_messages (from_id, from_name, to_id, to_name, body) VALUES (?, ?, ?, ?, ?)`
276
+ `INSERT INTO bus_messages (from_id, from_name, to_id, to_name, body, relay_key) VALUES (?, ?, ?, ?, ?, ?)`
233
277
  )
234
- .run(from?.id ?? null, fromName, to.id, to.name, body);
278
+ .run(
279
+ from?.id ?? null,
280
+ fromName,
281
+ to.id,
282
+ to.name,
283
+ body,
284
+ opts.relayKey ?? null
285
+ );
235
286
  const id = Number(lastInsertRowid);
236
287
 
237
288
  await statusDetector.refreshCache();
@@ -260,6 +311,31 @@ export async function sendMessage(opts: {
260
311
  return { message, delivery, note };
261
312
  }
262
313
 
314
+ function storeForHolder(
315
+ from: Session,
316
+ to: { id: string; name: string; heldFrom: string },
317
+ body: string
318
+ ) {
319
+ if (overRateLimit(pairTimestamps(from.id, to.id)))
320
+ throw new Error(
321
+ `Too many messages between ${from.name} and ${to.name}; wait a few minutes`
322
+ );
323
+ const { lastInsertRowid } = db
324
+ .prepare(
325
+ `INSERT INTO bus_messages (from_id, from_name, to_id, to_name, body, relay_state)
326
+ VALUES (?, ?, ?, ?, ?, 'pending')`
327
+ )
328
+ .run(from.id, from.name, to.id, to.name, body);
329
+ const message = toView(
330
+ db.prepare(`${SELECT} WHERE m.id = ?`).get(Number(lastInsertRowid)) as Row
331
+ );
332
+ return {
333
+ message,
334
+ delivery: { state: "queued" as const },
335
+ note: `${to.name} runs on ${to.heldFrom}: it gets this when that machine next checks in, within a few seconds`,
336
+ };
337
+ }
338
+
263
339
  // Unread messages for a session, marked read as they're returned.
264
340
  export function readInbox(sessionId: string): BusMessageView[] {
265
341
  const rows = db
@@ -1714,6 +1714,31 @@ const migrations: Migration[] = [
1714
1714
  add(col);
1715
1715
  },
1716
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
+ },
1717
1742
  ];
1718
1743
 
1719
1744
  // `upTo`: stop after this id (tests that start from an older database).
package/lib/db/types.ts CHANGED
@@ -124,6 +124,8 @@ export interface Session {
124
124
  // The device and machine of the orchestrator that moved this task here.
125
125
  held_by_device?: string | null;
126
126
  held_from?: string | null;
127
+ held_for?: string | null;
128
+ held_for_name?: string | null;
127
129
  // A held task's "plan written" line, for its orchestrator to read.
128
130
  plan_note?: string | null;
129
131
  // Orchestration fields
@@ -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,
@@ -9,6 +9,7 @@ import { blockedReason } from "../tasks/state";
9
9
  import { checkWaitingPatterns, statusDetector } from "../status-detector";
10
10
  import { getCheck, putCheck } from "./checks";
11
11
  import { cleanRemoteText, hostLink, type HostLink } from "../hosts/remote-api";
12
+ import { heldChatFacts } from "../tasks/held-remote";
12
13
  import { hostTasks } from "../tasks/remote";
13
14
  import { peerPane } from "../hosts/peer-sessions";
14
15
  import { untrusted } from "./untrusted";
@@ -24,21 +25,7 @@ export async function waitingState(
24
25
  // its own reading of the task is asked for, fresh.
25
26
  const link = hostLink(task.host_id);
26
27
  if (link && task.view === "chat") return peerChatWaiting(link, task);
27
- if (task.view === "chat") {
28
- const reason = blockedReason(saidSinceLastMessage(task.id));
29
- // Asked of a worker still running from before a restart, too; one
30
- // that can't be reached can't be cleared.
31
- const state = await chatStateNow(task.id).catch(() => "unknown");
32
- return {
33
- blocked: reason === null ? null : untrusted(task.name, reason),
34
- waitingOn:
35
- state === "waiting"
36
- ? "an approval or question in its chat"
37
- : state === "unknown"
38
- ? "its chat worker couldn't be reached, so an open approval or question can't be ruled out"
39
- : null,
40
- };
41
- }
28
+ if (task.view === "chat") return localChatWaiting(task);
42
29
  // A linked machine reads its own screens: asked, and an answer it can't
43
30
  // give is a screen that can't be cleared.
44
31
  let tail: string;
@@ -71,6 +58,26 @@ export async function waitingState(
71
58
  };
72
59
  }
73
60
 
61
+ // A chat task whose conversation is in this machine's store (or, for a held
62
+ // task, the one the holder asks about).
63
+ export async function localChatWaiting(
64
+ task: Pick<Session, "id" | "name">
65
+ ): Promise<{ blocked: string | null; waitingOn: string | null }> {
66
+ const reason = blockedReason(saidSinceLastMessage(task.id));
67
+ // Asked of a worker still running from before a restart, too; one
68
+ // that can't be reached can't be cleared.
69
+ const state = await chatStateNow(task.id).catch(() => "unknown");
70
+ return {
71
+ blocked: reason === null ? null : untrusted(task.name, reason),
72
+ waitingOn:
73
+ state === "waiting"
74
+ ? "an approval or question in its chat"
75
+ : state === "unknown"
76
+ ? "its chat worker couldn't be reached, so an open approval or question can't be ruled out"
77
+ : null,
78
+ };
79
+ }
80
+
74
81
  async function peerChatWaiting(
75
82
  link: HostLink,
76
83
  task: Session
@@ -79,6 +86,24 @@ async function peerChatWaiting(
79
86
  blocked: `its chat on ${link.hostName} couldn't be read, so a BLOCKED: line or open question can't be ruled out`,
80
87
  waitingOn: null,
81
88
  };
89
+ // A task its orchestrator moved is asked about directly: the whole task
90
+ // list is slow, and a miss there reads as unread.
91
+ const facts = task.started_by ? await heldChatFacts(task) : null;
92
+ if (facts) {
93
+ const { turn } = facts;
94
+ return {
95
+ blocked: facts.blocked ? untrusted(task.name, facts.blocked) : null,
96
+ waitingOn:
97
+ turn === "waiting"
98
+ ? `an approval or question in its chat on ${link.hostName}`
99
+ : turn === null ||
100
+ turn === "running" ||
101
+ turn === "idle" ||
102
+ turn === "error"
103
+ ? null
104
+ : `its chat on ${link.hostName} couldn't say whether a question is open`,
105
+ };
106
+ }
82
107
  const view = await hostTasks(link, true)
83
108
  .then(({ tasks, capabilities }) =>
84
109
  // One that can't say its chat's turn can't rule out an open question.
@@ -56,6 +56,7 @@ export const STATIC_SIBLINGS = [
56
56
  "clone",
57
57
  "detect",
58
58
  "done-idle",
59
+ "held-replies",
59
60
  "import",
60
61
  "init",
61
62
  "init-script",
@@ -537,6 +537,10 @@ class SessionStatusDetector {
537
537
  if (this.peersAsked.has(hostId)) return;
538
538
  this.peersAsked.add(hostId);
539
539
  this.hostListedAt.set(hostId, Date.now());
540
+ // What its held tasks have said to their orchestrators here.
541
+ void import("./tasks/held-replies")
542
+ .then((m) => m.pullHeldReplies(link))
543
+ .catch(() => undefined);
540
544
  void peerTmuxSessions(link)
541
545
  .then(
542
546
  (list) => {
@@ -5,7 +5,12 @@
5
5
  */
6
6
 
7
7
  import type { Session } from "../db";
8
- import { cleanRemoteText, hostApi, requireHostLink } from "../hosts/remote-api";
8
+ import {
9
+ cleanRemoteBlock,
10
+ cleanRemoteText,
11
+ hostApi,
12
+ requireHostLink,
13
+ } from "../hosts/remote-api";
9
14
  import type { Delivery } from "../bus/delivery";
10
15
 
11
16
  const path = (id: string, leaf: string) =>
@@ -54,7 +59,31 @@ export async function heldChatTail(
54
59
  `${path(task.id, "chat-tail")}?lines=${lines}`,
55
60
  { timeout: 10000 }
56
61
  ).catch(() => ({ text: undefined }));
57
- return typeof text === "string" ? cleanRemoteText(text) : null;
62
+ // Whole lines, as a local read has them (read.ts caps and trims them).
63
+ return typeof text === "string" ? cleanRemoteBlock(text, 16000) : null;
64
+ }
65
+
66
+ /**
67
+ * What its chat says about being blocked or asking, as that machine reads it
68
+ * (the same reading a local chat task gets). Null when it can't be asked,
69
+ * which callers treat as unread, never as clear.
70
+ */
71
+ export async function heldChatFacts(
72
+ task: Pick<Session, "id" | "host_id">
73
+ ): Promise<{ blocked: string | null; turn: string | null } | null> {
74
+ const link = requireHostLink(task.host_id);
75
+ const res = await hostApi<{ blocked?: unknown; turn?: unknown }>(
76
+ link,
77
+ path(task.id, "chat-facts"),
78
+ { timeout: 10000 }
79
+ ).catch(() => null);
80
+ if (!res || (res.blocked !== null && typeof res.blocked !== "string"))
81
+ return null;
82
+ if (res.turn !== null && typeof res.turn !== "string") return null;
83
+ return {
84
+ blocked: res.blocked ? cleanRemoteBlock(res.blocked, 2000) : null,
85
+ turn: res.turn,
86
+ };
58
87
  }
59
88
 
60
89
  /** Stops its agent there; its conversation and worktree are kept. */
@@ -0,0 +1,106 @@
1
+ /**
2
+ * What the tasks this machine's orchestrators moved to a linked machine say
3
+ * to those orchestrators. That machine only stores the messages (bus/held-reply);
4
+ * this machine, which polls, pulls them and delivers each as that task's own
5
+ * message to the orchestrator that started it. Who receives it is this
6
+ * machine's own record (started_by), never anything the other machine names.
7
+ */
8
+
9
+ import { db, type Session } from "../db";
10
+ import { sendMessage } from "../bus";
11
+ import { hostApi, type HostLink } from "../hosts/remote-api";
12
+
13
+ const PULL_EVERY_MS = 5000;
14
+ const MISSING_FOR_MS = 10 * 60 * 1000;
15
+ const MAX_BODY = 8000;
16
+
17
+ const g = globalThis as unknown as {
18
+ __agentosRepliesAt?: Map<string, number>;
19
+ __agentosRepliesBusy?: Set<string>;
20
+ };
21
+ const lastAsked = (g.__agentosRepliesAt ??= new Map());
22
+ const busy = (g.__agentosRepliesBusy ??= new Set());
23
+
24
+ interface Pulled {
25
+ id: number;
26
+ fromId: string;
27
+ body: string;
28
+ }
29
+
30
+ const heldMirror = (hostId: string, id: string) =>
31
+ db
32
+ .prepare(
33
+ `SELECT * FROM sessions WHERE id = ? AND host_id = ?
34
+ AND started_by IS NOT NULL AND task_status IN ('running', 'moving')`
35
+ )
36
+ .get(id, hostId) as Session | undefined;
37
+
38
+ export function hasHeldMirrors(hostId: string): boolean {
39
+ return !!db
40
+ .prepare(
41
+ `SELECT 1 FROM sessions WHERE host_id = ? AND started_by IS NOT NULL
42
+ AND task_prompt IS NOT NULL AND task_status IN ('running', 'moving')`
43
+ )
44
+ .get(hostId);
45
+ }
46
+
47
+ /** Delivers what the link's machine holds for this machine's orchestrators. */
48
+ export async function pullHeldReplies(link: HostLink): Promise<number> {
49
+ const hostId = link.hostId;
50
+ if (busy.has(hostId) || !hasHeldMirrors(hostId)) return 0;
51
+ const now = Date.now();
52
+ if (now - (lastAsked.get(hostId) ?? 0) < PULL_EVERY_MS) return 0;
53
+ lastAsked.set(hostId, now);
54
+ busy.add(hostId);
55
+ try {
56
+ let messages: Pulled[];
57
+ try {
58
+ const res = await hostApi<{ messages?: unknown }>(
59
+ link,
60
+ "/api/tasks/held-replies",
61
+ { timeout: 8000 }
62
+ );
63
+ messages = (Array.isArray(res.messages) ? res.messages : []).filter(
64
+ (m): m is Pulled =>
65
+ !!m &&
66
+ Number.isSafeInteger(m.id) &&
67
+ typeof m.fromId === "string" &&
68
+ typeof m.body === "string"
69
+ );
70
+ } catch (err) {
71
+ // A machine that predates replies: leave it alone for a while.
72
+ if ((err as { status?: number }).status === 404)
73
+ lastAsked.set(hostId, now + MISSING_FOR_MS);
74
+ return 0;
75
+ }
76
+ const handled: number[] = [];
77
+ let delivered = 0;
78
+ for (const m of messages) {
79
+ const mirror = heldMirror(hostId, m.fromId);
80
+ if (!mirror) {
81
+ handled.push(m.id); // nothing here to deliver it from
82
+ continue;
83
+ }
84
+ try {
85
+ await sendMessage({
86
+ fromId: mirror.id,
87
+ to: mirror.started_by!,
88
+ body: m.body.slice(0, MAX_BODY),
89
+ relayKey: `${hostId}:${m.id}`,
90
+ });
91
+ handled.push(m.id);
92
+ delivered++;
93
+ } catch {
94
+ // Gone, or rate limited: offered again at the next pull.
95
+ }
96
+ }
97
+ if (handled.length)
98
+ await hostApi(link, "/api/tasks/held-replies", {
99
+ body: { ids: handled },
100
+ timeout: 8000,
101
+ }).catch(() => undefined);
102
+ return delivered;
103
+ } finally {
104
+ busy.delete(hostId);
105
+ }
106
+ }
@@ -174,10 +174,13 @@ async function arrive(
174
174
  if (holder)
175
175
  db.prepare(
176
176
  `UPDATE sessions SET held_by_device = ?, held_from = ?,
177
+ held_for = ?, held_for_name = ?,
177
178
  model_provider = ?, model_overrides = ? WHERE id = ?`
178
179
  ).run(
179
180
  holder,
180
181
  orch.heldFrom,
182
+ orch.orchestrator?.id ?? null,
183
+ orch.orchestrator?.name ?? null,
181
184
  orch.modelProvider,
182
185
  orch.modelOverrides,
183
186
  id
@@ -203,9 +206,8 @@ async function arrive(
203
206
  });
204
207
  }, `setup-moved-${bundle.branch}`);
205
208
  }
206
- const prompt = claude
207
- ? arrivalNote(bundle, worktreePath)
208
- : `${bundle.prompt}\n\n${arrivalNote(bundle, worktreePath)}`;
209
+ const note = arrivalNote(bundle, worktreePath, os.hostname());
210
+ const prompt = claude ? note : `${bundle.prompt}\n\n${note}`;
209
211
  if (bundle.chat) {
210
212
  // Its worker starts on the carried conversation, takes the note
211
213
  // first, then what was queued.
@@ -71,6 +71,9 @@ export interface OrchestratedBundle {
71
71
  planPhase: "planning" | "implementing" | null;
72
72
  // .agentos/plan.md, which is never in git.
73
73
  planFile: string | null;
74
+ // The orchestrator that holds the task (its id and name on the machine
75
+ // that runs it), so the task can message it from where it runs.
76
+ orchestrator?: { id: string; name: string };
74
77
  }
75
78
 
76
79
  export interface ChatBundle {
@@ -147,6 +150,19 @@ function validateOrchestrated(o: OrchestratedBundle): OrchestratedBundle {
147
150
  modelOverrides: o.modelOverrides,
148
151
  planPhase: o.planPhase,
149
152
  planFile: o.planFile,
153
+ orchestrator:
154
+ o.orchestrator &&
155
+ isSessionId(o.orchestrator.id) &&
156
+ typeof o.orchestrator.name === "string" &&
157
+ o.orchestrator.name.trim()
158
+ ? {
159
+ id: o.orchestrator.id,
160
+ name: o.orchestrator.name
161
+ .replace(/[\u0000-\u001f]/g, " ")
162
+ .trim()
163
+ .slice(0, 200),
164
+ }
165
+ : undefined,
150
166
  };
151
167
  }
152
168
 
@@ -192,10 +208,11 @@ export function validateBundle(b: TaskBundle): TaskBundle {
192
208
 
193
209
  export function arrivalNote(
194
210
  b: { from: string; branch: string },
195
- cwd: string
211
+ cwd: string,
212
+ here: string
196
213
  ): string {
197
214
  return [
198
- `This task just moved here from ${b.from}. Your worktree is now ${cwd}, on branch ${b.branch}.`,
215
+ `This task just moved from ${b.from} to ${here}. It now runs on ${here}: every command you run executes there, not on ${b.from}, so name ${here} when you report where something ran. Your worktree is now ${cwd}, on branch ${b.branch}.`,
199
216
  `Anything that was uncommitted came over as a commit starting "wip: moving to"; fold it into your own commits as you see fit.`,
200
217
  `Continue the task where you left off.`,
201
218
  ].join(" ");
@@ -10,7 +10,7 @@ import { db, type Session } from "../db";
10
10
  import { isRemoteHost } from "../hosts";
11
11
  import { requireHostLink, type HostLink } from "../hosts/remote-api";
12
12
  import { getTaskSession } from "./session";
13
- import { exportOrResume, markMoved, resumeTask, type TaskBundle } from "./move";
13
+ import { exportOrResume, resumeTask, type TaskBundle } from "./move";
14
14
  import { isOrchestrated, moveRefusal } from "./move-guard";
15
15
  import { holderRefusal } from "./held";
16
16
  import { isPaused } from "../orchestrator/pause";
@@ -146,6 +146,13 @@ async function moveOut(session: Session, hostId: string): Promise<Session> {
146
146
  return settleMovedOut(session, hostId, link.hostName, there);
147
147
  }
148
148
 
149
+ function mirrorRunning(id: string): void {
150
+ db.prepare(
151
+ `UPDATE sessions SET task_status = 'running', moved_to = NULL
152
+ WHERE id = ? AND task_status = 'moving'`
153
+ ).run(id);
154
+ }
155
+
149
156
  async function moveIn(mirror: Session): Promise<Session> {
150
157
  const link = requireHostLink(mirror.host_id);
151
158
  if (mirror.view === "chat") await requireChatMoves(link);
@@ -154,6 +161,12 @@ async function moveIn(mirror: Session): Promise<Session> {
154
161
  // A retry after it arrived here but that machine didn't hear: tidy up.
155
162
  let here = arrivedHere(mirror.id);
156
163
  if (!here) {
164
+ // Moving, so nothing reads the mirror as a task to judge, or sign off,
165
+ // while it leaves (a refusal below puts it back).
166
+ db.prepare(
167
+ `UPDATE sessions SET task_status = 'moving', moved_to = 'this machine'
168
+ WHERE id = ? AND task_status = 'running'`
169
+ ).run(mirror.id);
157
170
  // That machine resumes the agent itself if its half fails there.
158
171
  stepProgress(mirror.id, "export");
159
172
  let bundle: TaskBundle;
@@ -165,6 +178,7 @@ async function moveIn(mirror: Session): Promise<Session> {
165
178
  300000
166
179
  ));
167
180
  } catch (err) {
181
+ mirrorRunning(mirror.id);
168
182
  if (unknownOutcome(err)) throw unconfirmed(link.hostName, err);
169
183
  throw err;
170
184
  }
@@ -176,6 +190,7 @@ async function moveIn(mirror: Session): Promise<Session> {
176
190
  `${(err as Error).message}; and ${link.hostName} didn't resume it: ${e.message}`
177
191
  );
178
192
  });
193
+ mirrorRunning(mirror.id);
179
194
  throw err;
180
195
  }
181
196
  }
@@ -190,7 +205,11 @@ async function moveIn(mirror: Session): Promise<Session> {
190
205
  `It's running here now, but ${link.hostName} didn't confirm (${(err as Error).message}). Press Move here again to tidy up.`
191
206
  );
192
207
  }
193
- markMoved(mirror.id, os.hostname());
208
+ // The mirror was only how this machine showed it: the task is the new row.
209
+ db.prepare(`DELETE FROM sessions WHERE id = ? AND host_id = ?`).run(
210
+ mirror.id,
211
+ mirror.host_id
212
+ );
194
213
  forgetHostTasks(link.hostId);
195
214
  return db
196
215
  .prepare(`SELECT * FROM sessions WHERE id = ?`)
package/lib/tasks/move.ts CHANGED
@@ -115,6 +115,20 @@ const readPlanFile = (cwd: string): string | null => {
115
115
  }
116
116
  };
117
117
 
118
+ // The orchestrator that holds the task: its own, or the one on the machine
119
+ // that moved it here.
120
+ function orchestratorOf(
121
+ session: Session
122
+ ): { id: string; name: string } | undefined {
123
+ if (session.held_for && session.held_for_name)
124
+ return { id: session.held_for, name: session.held_for_name };
125
+ if (!session.started_by) return undefined;
126
+ const o = db
127
+ .prepare(`SELECT name FROM sessions WHERE id = ?`)
128
+ .get(session.started_by) as { name: string } | undefined;
129
+ return o ? { id: session.started_by, name: o.name } : undefined;
130
+ }
131
+
118
132
  // What an orchestrator's task carries besides its work (the orchestrator's
119
133
  // own state is re-keyed here; see move-carry).
120
134
  function orchestratedBundle(session: Session): OrchestratedBundle {
@@ -125,6 +139,7 @@ function orchestratedBundle(session: Session): OrchestratedBundle {
125
139
  modelOverrides: session.model_overrides ?? null,
126
140
  planPhase: session.plan_phase ?? null,
127
141
  planFile: readPlanFile(session.working_directory),
142
+ orchestrator: orchestratorOf(session),
128
143
  };
129
144
  }
130
145
 
@@ -0,0 +1,37 @@
1
+ /**
2
+ * What a machine tidies up after a task it held went back to the machine
3
+ * that sent it: its agent and chat worker stop, its ports are released, and
4
+ * its worktree goes when nothing in it would be lost (no uncommitted change,
5
+ * no commit a remote lacks). The row stays, archived, as history.
6
+ */
7
+
8
+ import { db, type Session } from "../db";
9
+ import { endSessionAgent } from "../sessions/end";
10
+ import { releasePorts } from "../ports";
11
+ import { settleWorktree, type WorktreeFate } from "../done/worktree";
12
+ import { archiveSession } from "../done/archive";
13
+
14
+ export async function cleanUpMovedAway(id: string): Promise<WorktreeFate> {
15
+ const s = db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(id) as
16
+ | Session
17
+ | undefined;
18
+ if (!s || s.task_status !== "moved" || !s.held_by_device)
19
+ return { action: "none" };
20
+ await endSessionAgent(s).catch((err) =>
21
+ console.error(`[moved-away] ${id}: agent not stopped:`, err)
22
+ );
23
+ releasePorts(id);
24
+ const fate = await settleWorktree(s, null, true).catch(
25
+ (err): WorktreeFate => ({
26
+ action: "kept",
27
+ why: `it couldn't be checked: ${err instanceof Error ? err.message : err}`,
28
+ path: s.worktree_path ?? "",
29
+ })
30
+ );
31
+ archiveSession(id);
32
+ if (fate.action === "kept")
33
+ console.warn(
34
+ `[moved-away] ${id}: worktree kept at ${fate.path}: ${fate.why}`
35
+ );
36
+ return fate;
37
+ }
@@ -30,6 +30,9 @@ export const TASK_CAPABILITIES = [
30
30
  // Holds an orchestrator's task for the machine that sent it, and answers
31
31
  // its messages, reads, stops and done (lib/tasks/held.ts).
32
32
  "orchestrated-move",
33
+ // A held task's chat facts and its messages for its orchestrator.
34
+ "held-chat-facts",
35
+ "held-replies",
33
36
  ] as const;
34
37
 
35
38
  export interface HostTasks {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.518",
3
+ "version": "0.3.519",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"