@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
@@ -0,0 +1,271 @@
1
+ /**
2
+ * Drains the push outbox into the Connect relay. Never throws at a caller and
3
+ * never blocks one: `kick()` starts a drain and returns. Retries back off;
4
+ * a relay that says push isn't set up is left alone for ten minutes instead
5
+ * of being asked per message, and what was waiting falls back to Telegram.
6
+ */
7
+
8
+ import type { PushRequestBody } from "@/lib/connect/control-api";
9
+ import {
10
+ getDevicePush,
11
+ pruneDevicePush,
12
+ setDevicePushError,
13
+ } from "@/lib/security/devices";
14
+ import * as outbox from "./outbox";
15
+ import { relaySend, type RelayAnswer } from "./relay";
16
+ import { isTestKey, type OutboxRow } from "./outbox";
17
+
18
+ export const MAX_ATTEMPTS = 3;
19
+ const BACKOFF_MS = [30_000, 120_000];
20
+ const MAX_BACKOFF_MS = 600_000;
21
+ export const UNAVAILABLE_MS = 600_000;
22
+ const BATCH = 10;
23
+ /** Dead tokens are hex; anything else would make the relay refuse the batch. */
24
+ const APNS_TOKEN = /^[0-9a-fA-F]{64,200}$/;
25
+
26
+ export interface SenderDeps {
27
+ now: () => number;
28
+ relay: (body: PushRequestBody) => Promise<RelayAnswer>;
29
+ /** Sends a held message by the other channel. */
30
+ fallback: (source: string, text: string) => Promise<void>;
31
+ log: (line: string) => void;
32
+ }
33
+
34
+ type Sink = SenderDeps["fallback"];
35
+ // On globalThis like the sender: the app's bundles each have their own copy
36
+ // of this module, and the one that sets the sink may not be the one that built
37
+ // the sender.
38
+ const sinks = globalThis as unknown as { __agentosPushFallback?: Sink };
39
+
40
+ /** Where held text goes when push can't deliver it (Telegram). */
41
+ export function setFallbackSink(fn: Sink): void {
42
+ sinks.__agentosPushFallback = fn;
43
+ }
44
+
45
+ const defaults = (): SenderDeps => ({
46
+ now: () => Date.now(),
47
+ relay: relaySend,
48
+ fallback: async (source, text) => sinks.__agentosPushFallback?.(source, text),
49
+ log: (line) => console.error(`[push] ${line}`),
50
+ });
51
+
52
+ export class PushSender {
53
+ private deps: SenderDeps;
54
+ private running = false;
55
+ private again = false;
56
+ private current: Promise<void> = Promise.resolve();
57
+ private timer: ReturnType<typeof setTimeout> | null = null;
58
+ private down: { until: number; reason: string } | null = null;
59
+
60
+ constructor(deps: Partial<SenderDeps> = {}) {
61
+ this.deps = { ...defaults(), ...deps };
62
+ }
63
+
64
+ /** Why pushes aren't being tried right now, or null. */
65
+ unavailable(): string | null {
66
+ if (this.down && this.deps.now() >= this.down.until) this.down = null;
67
+ return this.down?.reason ?? null;
68
+ }
69
+
70
+ clearUnavailable(): void {
71
+ this.down = null;
72
+ }
73
+
74
+ /** Stops the retry timer (a test discarding its sender). */
75
+ dispose(): void {
76
+ if (this.timer) clearTimeout(this.timer);
77
+ this.timer = null;
78
+ }
79
+
80
+ /** Starts a drain if none is running; resolves when it has caught up. */
81
+ kick(): Promise<void> {
82
+ if (this.running) {
83
+ this.again = true;
84
+ return this.current;
85
+ }
86
+ this.running = true;
87
+ this.current = this.drain().finally(() => {
88
+ this.running = false;
89
+ this.arm();
90
+ });
91
+ return this.current;
92
+ }
93
+
94
+ /** Re-arms after a restart. */
95
+ resume(): void {
96
+ outbox.resume(this.deps.now());
97
+ void this.kick();
98
+ }
99
+
100
+ private async drain(): Promise<void> {
101
+ try {
102
+ do {
103
+ this.again = false;
104
+ for (const key of outbox.expire(this.deps.now()))
105
+ await this.settle(key);
106
+ for (;;) {
107
+ const rows = outbox.claimDue(this.deps.now());
108
+ if (!rows.length) break;
109
+ const byEvent = new Map<string, OutboxRow[]>();
110
+ for (const r of rows)
111
+ byEvent.set(r.event_key, [...(byEvent.get(r.event_key) ?? []), r]);
112
+ for (const group of byEvent.values())
113
+ for (let i = 0; i < group.length; i += BATCH)
114
+ await this.sendGroup(group.slice(i, i + BATCH));
115
+ }
116
+ } while (this.again);
117
+ } catch (error) {
118
+ this.deps.log(
119
+ `drain failed: ${error instanceof Error ? error.message : error}`
120
+ );
121
+ }
122
+ }
123
+
124
+ private arm(): void {
125
+ if (this.timer) clearTimeout(this.timer);
126
+ this.timer = null;
127
+ const at = outbox.nextDueAt();
128
+ if (at === null) return;
129
+ this.timer = setTimeout(
130
+ () => void this.kick(),
131
+ Math.max(1000, at - this.deps.now())
132
+ );
133
+ this.timer.unref?.();
134
+ }
135
+
136
+ private async sendGroup(rows: OutboxRow[]): Promise<void> {
137
+ // An earlier group in this drain found the relay unusable.
138
+ const down = this.unavailable();
139
+ if (down) {
140
+ for (const r of rows) outbox.markFailed(r.id, down);
141
+ for (const key of new Set(rows.map((r) => r.event_key)))
142
+ await this.settle(key);
143
+ return;
144
+ }
145
+ const sendable: {
146
+ row: OutboxRow;
147
+ token: string;
148
+ env: "production" | "sandbox";
149
+ }[] = [];
150
+ for (const row of rows) {
151
+ const push = getDevicePush(row.device_id);
152
+ if (!push) outbox.markFailed(row.id, "no push token", true);
153
+ else if (!APNS_TOKEN.test(push.token)) {
154
+ outbox.markFailed(row.id, "not an APNs token");
155
+ setDevicePushError(
156
+ row.device_id,
157
+ "the registered token isn't an APNs token"
158
+ );
159
+ } else sendable.push({ row, token: push.token, env: push.environment });
160
+ }
161
+ // The relay folds a repeated token into one result, so send each once.
162
+ const seen = new Set<string>();
163
+ const unique = sendable.filter((s) => {
164
+ const k = `${s.token.toLowerCase()}:${s.env}`;
165
+ if (seen.has(k)) {
166
+ outbox.markFailed(s.row.id, "duplicate token");
167
+ return false;
168
+ }
169
+ seen.add(k);
170
+ return true;
171
+ });
172
+ if (unique.length) {
173
+ const first = unique[0].row;
174
+ const answer = await this.deps.relay({
175
+ v: 1,
176
+ devices: unique.map((s) => ({ token: s.token, env: s.env })),
177
+ push_type: "alert",
178
+ priority: first.level === "passive" ? 5 : 10,
179
+ level: first.level,
180
+ ...(first.collapse_id && { collapse_id: first.collapse_id }),
181
+ thread_id: first.category,
182
+ payload: JSON.parse(first.payload),
183
+ });
184
+ await this.apply(
185
+ unique.map((s) => s.row),
186
+ answer
187
+ );
188
+ }
189
+ for (const key of new Set(rows.map((r) => r.event_key)))
190
+ await this.settle(key);
191
+ }
192
+
193
+ private async apply(rows: OutboxRow[], answer: RelayAnswer): Promise<void> {
194
+ const now = this.deps.now();
195
+ if (answer.kind === "unavailable") {
196
+ this.down = { until: now + UNAVAILABLE_MS, reason: answer.reason };
197
+ this.deps.log(`push unavailable: ${answer.reason}`);
198
+ for (const r of rows) {
199
+ outbox.markFailed(r.id, answer.reason);
200
+ setDevicePushError(r.device_id, answer.reason);
201
+ }
202
+ for (const key of outbox.failPending(answer.reason))
203
+ await this.settle(key);
204
+ return;
205
+ }
206
+ if (answer.kind === "invalid") {
207
+ for (const r of rows) {
208
+ outbox.markFailed(r.id, answer.reason);
209
+ setDevicePushError(r.device_id, answer.reason);
210
+ }
211
+ return;
212
+ }
213
+ rows.forEach((r, i) => {
214
+ if (answer.kind === "retry")
215
+ return this.retry(r, now, answer.reason, answer.afterMs);
216
+ const res = answer.results[i];
217
+ if (!res) return this.retry(r, now, "no answer for this device");
218
+ if (res.status === "sent") {
219
+ outbox.markSent(r.id, now);
220
+ setDevicePushError(r.device_id, null);
221
+ } else if (res.status === "gone" || res.prune) {
222
+ const why = `token no longer valid${res.reason ? ` (${res.reason})` : ""}`;
223
+ pruneDevicePush(r.device_id, why);
224
+ outbox.markFailed(r.id, why, true);
225
+ } else if (res.status === "retry") {
226
+ this.retry(
227
+ r,
228
+ now,
229
+ res.reason ?? "try again",
230
+ res.retry_after ? res.retry_after * 1000 : undefined
231
+ );
232
+ } else {
233
+ const why = res.reason ?? "rejected";
234
+ outbox.markFailed(r.id, why);
235
+ setDevicePushError(r.device_id, why);
236
+ }
237
+ });
238
+ }
239
+
240
+ private retry(
241
+ r: OutboxRow,
242
+ now: number,
243
+ why: string,
244
+ afterMs?: number
245
+ ): void {
246
+ const max = isTestKey(r.event_key) ? 1 : MAX_ATTEMPTS;
247
+ if (r.attempts >= max) {
248
+ outbox.markFailed(r.id, why);
249
+ setDevicePushError(r.device_id, why);
250
+ return;
251
+ }
252
+ const wait = Math.min(
253
+ Math.max(BACKOFF_MS[r.attempts - 1] ?? BACKOFF_MS.at(-1)!, afterMs ?? 0),
254
+ MAX_BACKOFF_MS
255
+ );
256
+ outbox.markRetry(r.id, now + wait, why);
257
+ }
258
+
259
+ /** Once an event has ended with no phone reached, the held text goes by Telegram. */
260
+ private async settle(eventKey: string): Promise<void> {
261
+ const held = outbox.claimFallback(eventKey);
262
+ if (!held) return;
263
+ try {
264
+ await this.deps.fallback(held.source, held.text);
265
+ } catch (error) {
266
+ this.deps.log(
267
+ `fallback for ${held.source} didn't send: ${error instanceof Error ? error.message : error}`
268
+ );
269
+ }
270
+ }
271
+ }
@@ -0,0 +1,30 @@
1
+ // Test fixtures for the push sender: paired phones with push tokens.
2
+
3
+ import crypto from "crypto";
4
+ import { db } from "../../db";
5
+ import { mintDevice, setDevicePush } from "@/lib/security/devices";
6
+ import type { PushCategory } from "../push-types";
7
+
8
+ export function pairPhone(categories?: PushCategory[]): string {
9
+ const { device } = mintDevice("phone");
10
+ setDevicePush(device.id, {
11
+ token: crypto.randomBytes(32).toString("hex"),
12
+ platform: "ios",
13
+ environment: "production",
14
+ categories,
15
+ });
16
+ return device.id;
17
+ }
18
+
19
+ export function resetPush(): void {
20
+ db.prepare(`DELETE FROM push_outbox`).run();
21
+ db.prepare(`DELETE FROM devices`).run();
22
+ const g = globalThis as { __agentosPush?: { dispose(): void } };
23
+ g.__agentosPush?.dispose();
24
+ g.__agentosPush = undefined;
25
+ }
26
+
27
+ export const outboxRows = (where = "1=1") =>
28
+ db
29
+ .prepare(`SELECT * FROM push_outbox WHERE ${where} ORDER BY id`)
30
+ .all() as import("./outbox").OutboxRow[];
@@ -0,0 +1,29 @@
1
+ /**
2
+ * "Needs you" is a session that has waited on Saad for a minute. A timer
3
+ * starts when it begins waiting and is cancelled by any change, so a quick
4
+ * permission prompt that he answers never buzzes.
5
+ */
6
+
7
+ import { pushNeedsYou } from "./events";
8
+ import { WAIT_MS } from "./policy";
9
+
10
+ const timers = new Map<string, ReturnType<typeof setTimeout>>();
11
+
12
+ export function noteWaiting(sessionId: string, waiting: boolean): void {
13
+ const armed = timers.get(sessionId);
14
+ if (!waiting) {
15
+ if (armed) {
16
+ clearTimeout(armed);
17
+ timers.delete(sessionId);
18
+ }
19
+ return;
20
+ }
21
+ if (armed) return;
22
+ const armedAt = Date.now();
23
+ const timer = setTimeout(() => {
24
+ timers.delete(sessionId);
25
+ pushNeedsYou(sessionId, armedAt);
26
+ }, WAIT_MS);
27
+ timer.unref?.();
28
+ timers.set(sessionId, timer);
29
+ }
@@ -74,4 +74,6 @@ export interface PushEvent {
74
74
  id?: string | null;
75
75
  /** Dedupe key, e.g. `ask:<id>` or `review:<task>:<sha>`. */
76
76
  key?: string | null;
77
+ /** Events sharing this replace each other on the phone. Defaults to key. */
78
+ collapse?: string | null;
77
79
  }
@@ -16,6 +16,17 @@ const SECRET_PATTERNS: RegExp[] = [
16
16
  /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]*/g,
17
17
  ];
18
18
 
19
+ /**
20
+ * An id or key made by the app: no known token shape, and no single
21
+ * segment (split on - _ : .) that is a long hex or base64 run. A uuid or a
22
+ * timestamped id passes; a pasted key or a 40-character sha does not.
23
+ */
24
+ function plainIdent(text: string): boolean {
25
+ if (SECRET_PATTERNS.some((re) => ((re.lastIndex = 0), re.test(text))))
26
+ return false;
27
+ return !text.split(/[-_:.]/).some((seg) => seg.length >= 24);
28
+ }
29
+
19
30
  /** True when the string looks like a token, key or JWT. */
20
31
  export function looksSecret(text: string): boolean {
21
32
  return [...SECRET_PATTERNS, LONG_RUN].some((re) => {
@@ -42,7 +53,7 @@ export function safeId(id: unknown): string | null {
42
53
  if (typeof id !== "string" || id.length === 0 || id.length > MAX_ID)
43
54
  return null;
44
55
  if (!SAFE_ID.test(id)) return null;
45
- return looksSecret(id) ? null : id;
56
+ return plainIdent(id) ? id : null;
46
57
  }
47
58
 
48
59
  /**
@@ -53,5 +64,5 @@ export function safeKey(key: unknown): string | null {
53
64
  if (typeof key !== "string" || key.length === 0 || key.length > 128)
54
65
  return null;
55
66
  if (!/^[A-Za-z0-9_:.-]+$/.test(key)) return null;
56
- return looksSecret(key) ? null : key;
67
+ return plainIdent(key) ? key : null;
57
68
  }
@@ -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`
@@ -6,6 +6,7 @@
6
6
  * gate or brake ask is spent on that one item: never standing permission.
7
7
  */
8
8
 
9
+ import { pushAskRaised } from "../notify/push/events";
9
10
  import { db } from "../db";
10
11
  import { answerKey, queueEvent } from "./events";
11
12
  import { addNote } from "./notes";
@@ -167,7 +168,7 @@ export function raiseAsk(input: {
167
168
  const sha = input.sha ?? null;
168
169
  const brakeKey = input.brakeKey ?? null;
169
170
  const raisedBy = input.raisedBy ?? null;
170
- return db.transaction(() => {
171
+ const result = db.transaction(() => {
171
172
  const open = openBySubject(input.workspaceId, input.subject);
172
173
  if (open) {
173
174
  db.prepare(
@@ -209,6 +210,9 @@ export function raiseAsk(input: {
209
210
  created: true,
210
211
  };
211
212
  })();
213
+ // A new ask buzzes the phone; a refresh of an open one doesn't.
214
+ if (result.created) pushAskRaised(result.ask.id, input.workspaceId);
215
+ return result;
212
216
  }
213
217
 
214
218
  export function openAsks(workspaceId: string): AskRow[] {
@@ -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: