@saadnvd1/agent-os 0.3.571 → 0.3.572

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 (54) hide show
  1. package/README.md +1 -1
  2. package/app/api/bus/spawn/route.ts +21 -3
  3. package/app/api/load/route.test.ts +32 -0
  4. package/app/api/load/route.ts +20 -0
  5. package/app/api/sessions/[id]/pause/route.ts +97 -0
  6. package/app/api/tasks/[id]/priority/route.ts +29 -0
  7. package/app/api/tasks/route.ts +17 -2
  8. package/components/Chat/ChatPanel.tsx +2 -0
  9. package/components/Chat/PausedBanner.tsx +54 -0
  10. package/components/Draft/DraftChips.tsx +25 -0
  11. package/components/Draft/DraftPanel.tsx +1 -0
  12. package/components/SessionList/QueuedRow.tsx +46 -2
  13. package/components/SessionList/RowSubtitle.tsx +21 -3
  14. package/components/SessionList/SessionRowMenu.tsx +49 -1
  15. package/components/SessionList/useShelfActions.ts +39 -2
  16. package/data/sessions/shelf.ts +50 -0
  17. package/data/tasks/index.ts +2 -0
  18. package/data/tasks/queue.ts +38 -1
  19. package/lib/bus/index.ts +21 -2
  20. package/lib/chat/hold.ts +11 -2
  21. package/lib/db/migrations.ts +56 -1
  22. package/lib/db/types.ts +30 -0
  23. package/lib/done/index.ts +2 -0
  24. package/lib/drafts.ts +3 -0
  25. package/lib/hosts/peer-load.ts +120 -0
  26. package/lib/load/machines.ts +124 -0
  27. package/lib/load/monitor.ts +17 -2
  28. package/lib/load/start-gate.ts +95 -0
  29. package/lib/orchestrator/act.ts +78 -0
  30. package/lib/orchestrator/brakes.ts +2 -0
  31. package/lib/orchestrator/brief.ts +4 -1
  32. package/lib/orchestrator/digest.ts +15 -4
  33. package/lib/orchestrator/serve.ts +18 -0
  34. package/lib/orchestrator/tool-names.ts +3 -0
  35. package/lib/orchestrator/tool-schemas.ts +33 -0
  36. package/lib/orchestrator/tools.ts +7 -1
  37. package/lib/project-config/agentos.schema.json +13 -0
  38. package/lib/project-config/schema.ts +9 -0
  39. package/lib/sidebar/paused.ts +13 -0
  40. package/lib/sidebar/queued.ts +34 -1
  41. package/lib/sidebar/shelves.ts +21 -1
  42. package/lib/sidebar/snooze.ts +26 -0
  43. package/lib/tasks/finish.ts +7 -0
  44. package/lib/tasks/move-carry.ts +1 -0
  45. package/lib/tasks/move-flow.ts +6 -0
  46. package/lib/tasks/pause-hold.ts +70 -0
  47. package/lib/tasks/pause-policy.ts +130 -0
  48. package/lib/tasks/pause.ts +718 -0
  49. package/lib/tasks/placement.ts +219 -0
  50. package/lib/tasks/queue.ts +522 -64
  51. package/lib/tasks/schedule-types.ts +64 -0
  52. package/lib/tasks/schedule.ts +271 -0
  53. package/package.json +1 -1
  54. package/server.ts +2 -1
@@ -0,0 +1,718 @@
1
+ /**
2
+ * Timed pause of a running session, with auto-resume.
3
+ *
4
+ * running -> pausing -> paused -> resuming -> running. The agent's process is
5
+ * ended, never frozen: by default at the end of its current turn, or at once
6
+ * for "pause now". The worktree, the branch and the conversation stay. While
7
+ * pausing and paused its chat is held (lib/chat/hold.ts), so whatever would
8
+ * wake it waits in the queue and arrives, with the continue prompt, in one
9
+ * turn when it resumes. A paused task gives its slot back; a resume that came
10
+ * due competes for a slot as a scored candidate (lib/tasks/queue.ts).
11
+ *
12
+ * Everything is in SQLite (task_pauses and the session's pause columns), and
13
+ * the sweep re-drives each state after a restart.
14
+ */
15
+
16
+ import { db, type Session, type TaskPause } from "../db";
17
+ import { toSqliteTime } from "../session-meta";
18
+ import { endPause, endPauseOnFinish, formatUntil } from "./pause-hold";
19
+ import { getProject } from "../projects";
20
+ import { isRemoteHost } from "../hosts";
21
+ import { chatState, chatStateNow, releaseChat, stopChat } from "../chat/runner";
22
+ import { stopChatAtTurnEnd, stopChatTurn } from "../chat/stop";
23
+ import { waitForExit } from "../chat/worker/client";
24
+ import { isHeld } from "../chat/hold";
25
+ import { settingUp } from "../sessions/setup-progress";
26
+ import { statusDetector } from "../status-detector";
27
+ import { launchPending } from "./launch-gate";
28
+ import { killTmux } from "./start";
29
+ import { launchClaude } from "../agents/launch";
30
+ import { resolveRole, sessionWorkspace, taskOverride } from "../models/resolve";
31
+ import { sessionRole } from "../models/roles";
32
+ import { holdingEscalations } from "../orchestrator/gates";
33
+ import { openBySubject, workSubject } from "../orchestrator/asks";
34
+ import { queueEventOnce } from "../orchestrator/events";
35
+ import { isPaused } from "../orchestrator/pause";
36
+ import { isMerging } from "./finish";
37
+ import {
38
+ continuePrompt,
39
+ durationText,
40
+ pauseRefusal,
41
+ resumeRefusal,
42
+ type HeldMessage,
43
+ type PauseFacts,
44
+ } from "./pause-policy";
45
+ import {
46
+ DEFAULT_PRIORITY,
47
+ isPriority,
48
+ type PauseBy,
49
+ type PauseView,
50
+ type Priority,
51
+ } from "./schedule-types";
52
+
53
+ export class PauseRefused extends Error {}
54
+
55
+ // A pausing session whose turn hasn't ended after this is let go again.
56
+ export const PAUSE_WAIT_MS = 2 * 60 * 60_000;
57
+ // "Pause now" waits this long for a push, merge or rebase already under way.
58
+ const CRITICAL_WAIT_MS = 5 * 60_000;
59
+ const SWEEP_MS = 15_000;
60
+ const MAX_PAUSE_MS = 60 * 24 * 3600_000;
61
+ const CRITICAL =
62
+ /\bgit\s+(push|rebase|commit|merge)\b|\bgh\s+pr\s+(create|merge)\b/;
63
+
64
+ const g = globalThis as unknown as {
65
+ __agentosPauseDriving?: Map<number, symbol>;
66
+ __agentosPauseTimer?: ReturnType<typeof setInterval>;
67
+ };
68
+ const driving = (g.__agentosPauseDriving ??= new Map<number, symbol>());
69
+
70
+ const nudge = () =>
71
+ void import("./queue").then((q) => q.tickQueueSoon()).catch(() => undefined);
72
+
73
+ const sessionOf = (id: string) =>
74
+ db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(id) as
75
+ | Session
76
+ | undefined;
77
+
78
+ const pauseRow = (id: number) =>
79
+ db.prepare(`SELECT * FROM task_pauses WHERE id = ?`).get(id) as
80
+ | TaskPause
81
+ | undefined;
82
+
83
+ // SQLite's datetime('now') is UTC without a zone.
84
+ const ms = (v: string | null | undefined) =>
85
+ v ? Date.parse(v.includes("T") ? v : `${v.replace(" ", "T")}Z`) || 0 : 0;
86
+
87
+ export const activePause = (sessionId: string): TaskPause | null =>
88
+ (db
89
+ .prepare(
90
+ `SELECT * FROM task_pauses WHERE session_id = ? AND state != 'ended'
91
+ ORDER BY id DESC LIMIT 1`
92
+ )
93
+ .get(sessionId) as TaskPause | undefined) ?? null;
94
+
95
+ function factsOf(s: Session): PauseFacts {
96
+ const project = s.project_id ? getProject(s.project_id) : null;
97
+ const workspaceId = project?.workspace_id ?? s.workspace_id ?? null;
98
+ const question = db
99
+ .prepare(
100
+ `SELECT 1 FROM orchestrator_questions WHERE status = 'open' AND task = ?`
101
+ )
102
+ .get(s.id);
103
+ return {
104
+ isTask: !!s.task_status,
105
+ isOrchestrator: s.role === "orchestrator",
106
+ isQuickChange: !!s.quick_of,
107
+ isRemote: isRemoteHost(s.host_id) || !!s.peer_mirror,
108
+ taskStatus: s.task_status ?? null,
109
+ pauseState: s.pause_state ?? null,
110
+ held: isHeld(s.id),
111
+ merging: isMerging(s.id),
112
+ settingUp: settingUp(s.id) || launchPending(s.id),
113
+ chatWaiting: s.view === "chat" && chatState(s.id) === "waiting",
114
+ terminalWaiting: s.view !== "chat" && s.status === "waiting",
115
+ openAsk: !!workspaceId && !!openBySubject(workspaceId, workSubject(s.id)),
116
+ openQuestion: !!question,
117
+ escalated: holdingEscalations(s.id).length > 0,
118
+ planAwaiting: s.plan_phase === "awaiting",
119
+ resumable:
120
+ s.view === "chat" ||
121
+ (s.agent_type === "claude" && !!s.claude_session_id && !!s.worktree_path),
122
+ };
123
+ }
124
+
125
+ export function pauseRefusalFor(sessionId: string): string | null {
126
+ const s = sessionOf(sessionId);
127
+ if (!s || s.archived_at) return "No such session";
128
+ return pauseRefusal(factsOf(s));
129
+ }
130
+
131
+ function say(s: Session, key: string, line: string): void {
132
+ const workspaceId =
133
+ (s.project_id && getProject(s.project_id)?.workspace_id) || null;
134
+ if (workspaceId && s.task_status)
135
+ queueEventOnce(workspaceId, key, s.id, line);
136
+ }
137
+
138
+ export interface PauseRequest {
139
+ // When it resumes by itself; null: until resumed.
140
+ until: Date | null;
141
+ mode?: "turn" | "now";
142
+ by?: PauseBy;
143
+ byId?: string | null;
144
+ reason?: string | null;
145
+ }
146
+
147
+ // Starts a pause. Throws PauseRefused with the reason it can't.
148
+ export function requestPause(sessionId: string, req: PauseRequest): TaskPause {
149
+ const s = sessionOf(sessionId);
150
+ if (!s || s.archived_at) throw new PauseRefused("No such session");
151
+ const mode = req.mode ?? "turn";
152
+ const until = req.until;
153
+ if (until && until.getTime() - Date.now() > MAX_PAUSE_MS)
154
+ throw new PauseRefused("A pause can be at most 60 days");
155
+ if (until && until.getTime() <= Date.now())
156
+ throw new PauseRefused("That time has passed");
157
+ const existing = activePause(sessionId);
158
+ if (existing?.state === "pausing") {
159
+ // Already on its way: "pause now" speeds it up, a new time replaces the old.
160
+ db.prepare(
161
+ `UPDATE task_pauses SET mode = ?, until_at = ? WHERE id = ?`
162
+ ).run(
163
+ mode === "now" ? "now" : existing.mode,
164
+ until ? toSqliteTime(until) : null,
165
+ existing.id
166
+ );
167
+ db.prepare(`UPDATE sessions SET paused_until = ? WHERE id = ?`).run(
168
+ until ? toSqliteTime(until) : null,
169
+ sessionId
170
+ );
171
+ // "Pause now" takes over: the first driver is superseded, not raced.
172
+ drive(sessionId, existing.id, mode === "now");
173
+ return pauseRow(existing.id)!;
174
+ }
175
+ const refusal = pauseRefusal(factsOf(s));
176
+ if (refusal) throw new PauseRefused(refusal);
177
+ const id = Number(
178
+ db
179
+ .prepare(
180
+ `INSERT INTO task_pauses (session_id, mode, by_kind, by_id, reason, until_at, state)
181
+ VALUES (?, ?, ?, ?, ?, ?, 'pausing')`
182
+ )
183
+ .run(
184
+ sessionId,
185
+ mode,
186
+ req.by ?? "person",
187
+ req.byId ?? null,
188
+ req.reason?.slice(0, 300) ?? null,
189
+ until ? toSqliteTime(until) : null
190
+ ).lastInsertRowid
191
+ );
192
+ db.prepare(
193
+ `UPDATE sessions SET pause_state = 'pausing', paused_until = ?, pause_id = ? WHERE id = ?`
194
+ ).run(until ? toSqliteTime(until) : null, id, sessionId);
195
+ drive(sessionId, id);
196
+ return pauseRow(id)!;
197
+ }
198
+
199
+ // A running critical command (a push, merge, rebase or commit) in a chat.
200
+ function criticalInFlight(sessionId: string): boolean {
201
+ const rows = db
202
+ .prepare(
203
+ `SELECT data FROM chat_items WHERE session_id = ? AND kind = 'tool'
204
+ ORDER BY seq DESC LIMIT 5`
205
+ )
206
+ .all(sessionId) as { data: string }[];
207
+ return rows.some((r) => {
208
+ let item: {
209
+ name?: string;
210
+ status?: string;
211
+ input?: { command?: unknown };
212
+ };
213
+ try {
214
+ item = JSON.parse(r.data);
215
+ } catch {
216
+ return false;
217
+ }
218
+ return (
219
+ item.status === "running" &&
220
+ item.name === "Bash" &&
221
+ typeof item.input?.command === "string" &&
222
+ CRITICAL.test(item.input.command)
223
+ );
224
+ });
225
+ }
226
+
227
+ const sleep = (n: number) => new Promise<void>((r) => setTimeout(r, n));
228
+
229
+ function stillPausing(pauseId: number): boolean {
230
+ return pauseRow(pauseId)?.state === "pausing";
231
+ }
232
+
233
+ // Ends the agent at the turn boundary (or now), then marks it paused. Each
234
+ // driver holds a token; a newer one (pause now) replaces it, and the old one
235
+ // then does nothing with its outcome.
236
+ function drive(sessionId: string, pauseId: number, takeOver = false): void {
237
+ if (driving.has(pauseId) && !takeOver) return;
238
+ const token = Symbol(String(pauseId));
239
+ driving.set(pauseId, token);
240
+ const mine = () => driving.get(pauseId) === token;
241
+ void (async () => {
242
+ try {
243
+ const outcome = await stopAgent(sessionId, pauseId, mine);
244
+ if (!mine() || !stillPausing(pauseId)) return;
245
+ if (outcome !== true) return void cancelPause(sessionId, outcome);
246
+ markPaused(sessionId, pauseId);
247
+ } catch (error) {
248
+ console.error("[pause] stop:", error);
249
+ } finally {
250
+ if (mine()) driving.delete(pauseId);
251
+ }
252
+ })();
253
+ }
254
+
255
+ // True when the agent is stopped; otherwise why it couldn't be.
256
+ async function stopAgent(
257
+ sessionId: string,
258
+ pauseId: number,
259
+ mine: () => boolean
260
+ ): Promise<true | string> {
261
+ const s = sessionOf(sessionId);
262
+ if (!s) return "No such session";
263
+ if (s.view === "chat") {
264
+ const row = pauseRow(pauseId)!;
265
+ if (row.mode === "now") {
266
+ for (let waited = 0; criticalInFlight(sessionId); waited += 1000) {
267
+ if (!mine() || !stillPausing(pauseId)) return true;
268
+ if (waited >= CRITICAL_WAIT_MS)
269
+ return "a push or merge was still running; try again once it ends";
270
+ await sleep(1000);
271
+ }
272
+ const interrupted = chatState(sessionId) === "running";
273
+ const stop = await stopChatTurn(sessionId);
274
+ if (!stop.stopped) return stop.reason;
275
+ if (interrupted)
276
+ db.prepare(`UPDATE task_pauses SET interrupted = 1 WHERE id = ?`).run(
277
+ pauseId
278
+ );
279
+ stopChat(sessionId);
280
+ await waitForExit(sessionId);
281
+ return true;
282
+ }
283
+ const stop = await stopChatAtTurnEnd(sessionId, { waitMs: PAUSE_WAIT_MS });
284
+ return stop.stopped ? true : stop.reason;
285
+ }
286
+ // A terminal: at the boundary, when its status reads idle.
287
+ for (let waited = 0; ; waited += 2000) {
288
+ if (!mine() || !stillPausing(pauseId)) return true;
289
+ const now = sessionOf(sessionId);
290
+ if (!now) return "No such session";
291
+ if (now.status === "waiting") return "it's waiting on you";
292
+ if (now.status !== "running") break;
293
+ if (waited >= PAUSE_WAIT_MS)
294
+ return "its agent's turn was still running after two hours";
295
+ await sleep(2000);
296
+ }
297
+ await killTmux(s.tmux_name);
298
+ return true;
299
+ }
300
+
301
+ function markPaused(sessionId: string, pauseId: number): void {
302
+ const changed = db.transaction(() => {
303
+ const n = db
304
+ .prepare(
305
+ `UPDATE task_pauses SET state = 'paused', paused_at = datetime('now')
306
+ WHERE id = ? AND state = 'pausing'`
307
+ )
308
+ .run(pauseId).changes;
309
+ if (n)
310
+ db.prepare(`UPDATE sessions SET pause_state = 'paused' WHERE id = ?`).run(
311
+ sessionId
312
+ );
313
+ return n;
314
+ })();
315
+ if (!changed) return;
316
+ const s = sessionOf(sessionId);
317
+ const row = pauseRow(pauseId);
318
+ if (s && row)
319
+ say(
320
+ s,
321
+ `paused:${pauseId}`,
322
+ `Task "${s.name}" was paused ${row.until_at ? `until ${formatUntil(row.until_at)}` : "until resumed"}${row.reason ? ` (${row.reason})` : ""}. Its slot is free.`
323
+ );
324
+ nudge();
325
+ }
326
+
327
+ // A pausing session goes back to running (the person said so, or the turn
328
+ // couldn't be ended). What was held is let through.
329
+ export function cancelPause(
330
+ sessionId: string,
331
+ why: string | null = null
332
+ ): void {
333
+ const row = activePause(sessionId);
334
+ if (!row || row.state !== "pausing") return;
335
+ endPause(row, "cancelled");
336
+ void releaseChat(sessionId).catch(() => undefined);
337
+ const s = sessionOf(sessionId);
338
+ if (s && why)
339
+ say(
340
+ s,
341
+ `pause-cancelled:${row.id}`,
342
+ `Pausing "${s.name}" was stopped: ${why}.`
343
+ );
344
+ }
345
+
346
+ export { endPauseOnFinish };
347
+
348
+ // Changes when a paused (or pausing) session resumes by itself.
349
+ export function setResumeTime(sessionId: string, until: Date | null): void {
350
+ const row = activePause(sessionId);
351
+ if (!row) throw new PauseRefused("It isn't paused");
352
+ if (until && until.getTime() <= Date.now())
353
+ throw new PauseRefused("That time has passed");
354
+ if (until && until.getTime() - Date.now() > MAX_PAUSE_MS)
355
+ throw new PauseRefused("A pause can be at most 60 days");
356
+ const value = until ? toSqliteTime(until) : null;
357
+ db.transaction(() => {
358
+ db.prepare(
359
+ `UPDATE task_pauses SET until_at = ?, resume_requested_at = NULL WHERE id = ? AND state IN ('pausing','paused')`
360
+ ).run(value, row.id);
361
+ db.prepare(`UPDATE sessions SET paused_until = ? WHERE id = ?`).run(
362
+ value,
363
+ sessionId
364
+ );
365
+ })();
366
+ }
367
+
368
+ // Why this actor may not lift the pause, or null.
369
+ export function resumeBlock(sessionId: string, actor: PauseBy): string | null {
370
+ const row = activePause(sessionId);
371
+ if (!row) return "It isn't paused";
372
+ return resumeRefusal(actor, row.by_kind, row.until_at);
373
+ }
374
+
375
+ // Marks a paused session's resume as due: a task then competes for a slot
376
+ // (queue tick), anything else resumes at once.
377
+ export function requestResume(sessionId: string, actor: PauseBy): void {
378
+ const refusal = resumeBlock(sessionId, actor);
379
+ if (refusal) throw new PauseRefused(refusal);
380
+ const row = activePause(sessionId)!;
381
+ if (row.state === "pausing") return cancelPause(sessionId);
382
+ if (row.state !== "paused") return;
383
+ db.prepare(
384
+ `UPDATE task_pauses SET resume_requested_at = COALESCE(resume_requested_at, datetime('now')), resume_by_kind = ? WHERE id = ?`
385
+ ).run(actor, row.id);
386
+ const s = sessionOf(sessionId);
387
+ if (s?.task_status) nudge();
388
+ else
389
+ void claimResume(sessionId, { reason: actor }).catch((error) =>
390
+ console.error("[pause] resume:", error)
391
+ );
392
+ }
393
+
394
+ // A person's Resume now: past the slot limit, like Start now.
395
+ export async function resumeNow(sessionId: string, actor: PauseBy = "person") {
396
+ const refusal = resumeBlock(sessionId, actor);
397
+ if (refusal) throw new PauseRefused(refusal);
398
+ const row = activePause(sessionId)!;
399
+ if (row.state === "pausing") return cancelPause(sessionId);
400
+ await claimResume(sessionId, { reason: actor, force: true });
401
+ }
402
+
403
+ export interface DueResume {
404
+ sessionId: string;
405
+ pauseId: number;
406
+ projectId: string;
407
+ priority: Priority;
408
+ dueAtMs: number;
409
+ byKind: PauseBy;
410
+ }
411
+
412
+ // Paused tasks whose time came (or that someone asked to resume), for the
413
+ // queue to score against new arrivals. Their age counts from when due.
414
+ export function dueResumes(): DueResume[] {
415
+ const rows = db
416
+ .prepare(
417
+ `SELECT p.id AS pause_id, p.session_id, p.by_kind, p.until_at, p.resume_requested_at,
418
+ s.project_id, s.priority
419
+ FROM task_pauses p JOIN sessions s ON s.id = p.session_id
420
+ WHERE p.state = 'paused' AND p.resume_requested_at IS NOT NULL
421
+ AND s.task_status IN ('running') AND s.archived_at IS NULL`
422
+ )
423
+ .all() as {
424
+ pause_id: number;
425
+ session_id: string;
426
+ by_kind: PauseBy;
427
+ until_at: string | null;
428
+ resume_requested_at: string;
429
+ project_id: string | null;
430
+ priority: string | null;
431
+ }[];
432
+ return rows
433
+ .filter((r) => !!r.project_id)
434
+ .map((r) => ({
435
+ sessionId: r.session_id,
436
+ pauseId: r.pause_id,
437
+ projectId: r.project_id!,
438
+ priority: isPriority(r.priority) ? r.priority : DEFAULT_PRIORITY,
439
+ dueAtMs: r.until_at ? ms(r.until_at) : ms(r.resume_requested_at),
440
+ byKind: r.by_kind,
441
+ }));
442
+ }
443
+
444
+ const SENT = /"from":"([^"]*)"/;
445
+
446
+ // The messages held for a chat while it was paused, as one prompt's lines.
447
+ // Messages with images stay queued, to follow the continue turn.
448
+ function takeHeldChat(sessionId: string, pauseId: number): HeldMessage[] {
449
+ const rows = db
450
+ .prepare(
451
+ `SELECT id, text, sent_by FROM chat_queue
452
+ WHERE session_id = ? AND images IS NULL AND id NOT LIKE 'pause-resume:%'
453
+ ORDER BY position, created_at`
454
+ )
455
+ .all(sessionId) as { id: string; text: string; sent_by: string | null }[];
456
+ if (rows.length)
457
+ db.prepare(
458
+ `DELETE FROM chat_queue WHERE session_id = ? AND id IN (${rows.map(() => "?").join(",")})`
459
+ ).run(sessionId, ...rows.map((r) => r.id));
460
+ db.prepare(`UPDATE task_pauses SET held_count = ? WHERE id = ?`).run(
461
+ rows.length,
462
+ pauseId
463
+ );
464
+ return rows.map((r) => ({
465
+ from: (r.sent_by && SENT.exec(r.sent_by)?.[1]) || "the user",
466
+ text: r.text,
467
+ }));
468
+ }
469
+
470
+ function takeHeldBus(sessionId: string, pauseId: number): HeldMessage[] {
471
+ const rows = db
472
+ .prepare(
473
+ `SELECT from_name, body FROM bus_messages
474
+ WHERE to_id = ? AND held_pause_id = ? AND delivered_at IS NULL ORDER BY id`
475
+ )
476
+ .all(sessionId, pauseId) as { from_name: string; body: string }[];
477
+ db.prepare(`UPDATE task_pauses SET held_count = ? WHERE id = ?`).run(
478
+ rows.length,
479
+ pauseId
480
+ );
481
+ return rows.map((r) => ({ from: r.from_name, text: r.body }));
482
+ }
483
+
484
+ const clock = (t: string | null) =>
485
+ new Date(ms(t) || Date.now()).toLocaleTimeString("en-US", {
486
+ hour: "numeric",
487
+ minute: "2-digit",
488
+ });
489
+
490
+ export const resumeItemId = (pauseId: number) => `pause-resume:${pauseId}`;
491
+
492
+ // The prompt a resuming session gets, with what was held folded in.
493
+ function buildPrompt(row: TaskPause, held: HeldMessage[]): string {
494
+ return continuePrompt({
495
+ pausedAt: clock(row.paused_at),
496
+ by: row.by_kind,
497
+ duration: durationText(
498
+ ms(row.requested_at),
499
+ row.until_at ? ms(row.until_at) : null
500
+ ),
501
+ interrupted: !!row.interrupted,
502
+ held,
503
+ });
504
+ }
505
+
506
+ // A paused session resumes: one prompt with everything held folded in.
507
+ export async function claimResume(
508
+ sessionId: string,
509
+ opts: { reason?: string; force?: boolean } = {}
510
+ ): Promise<"resumed" | "gone"> {
511
+ const row = activePause(sessionId);
512
+ const s = sessionOf(sessionId);
513
+ if (!row || row.state !== "paused" || !s) return "gone";
514
+ const chat = s.view === "chat";
515
+ let held: HeldMessage[] = [];
516
+ let prompt = "";
517
+ // One transaction: the claim, the held messages taken, and (for a chat)
518
+ // the continue prompt queued first, so a restart in between finds it.
519
+ const claimed = db.transaction(() => {
520
+ const n = db
521
+ .prepare(
522
+ `UPDATE task_pauses SET state = 'resuming', resumed_at = datetime('now'),
523
+ resume_by_kind = COALESCE(resume_by_kind, ?)
524
+ WHERE id = ? AND state = 'paused'`
525
+ )
526
+ .run(opts.reason ?? "person", row.id).changes;
527
+ if (!n) return false;
528
+ held = chat
529
+ ? takeHeldChat(sessionId, row.id)
530
+ : takeHeldBus(sessionId, row.id);
531
+ prompt = buildPrompt(row, held);
532
+ if (chat)
533
+ db.prepare(
534
+ `INSERT OR IGNORE INTO chat_queue (id, session_id, position, text, images, image_count, sent_by, created_at)
535
+ VALUES (?, ?, (SELECT COALESCE(MIN(position), 0) - 1 FROM chat_queue WHERE session_id = ?), ?, NULL, 0, ?, ?)`
536
+ ).run(
537
+ resumeItemId(row.id),
538
+ sessionId,
539
+ sessionId,
540
+ prompt,
541
+ JSON.stringify({
542
+ from: "AgentOS",
543
+ origin: { kind: "event", label: "Resumed" },
544
+ }),
545
+ Date.now()
546
+ );
547
+ db.prepare(`UPDATE sessions SET pause_state = 'resuming' WHERE id = ?`).run(
548
+ sessionId
549
+ );
550
+ return true;
551
+ })();
552
+ if (!claimed) return "gone";
553
+ if (chat) {
554
+ await releaseChat(sessionId).catch((error) =>
555
+ console.error("[pause] release:", error)
556
+ );
557
+ settleResume(sessionId);
558
+ } else {
559
+ try {
560
+ await finishTerminalResume(s, row.id, prompt);
561
+ } catch (error) {
562
+ failResume(s, row.id, error);
563
+ return "gone";
564
+ }
565
+ }
566
+ say(
567
+ s,
568
+ `resumed:${row.id}`,
569
+ `Task "${s.name}" resumed${held.length ? `; ${held.length} held message${held.length === 1 ? "" : "s"} went with it` : ""}.`
570
+ );
571
+ return "resumed";
572
+ }
573
+
574
+ // A relaunch that can't happen ends the pause with the reason shown, instead
575
+ // of leaving it stuck in resuming for the sweep to retry forever.
576
+ function failResume(s: Session, pauseId: number, error: unknown): void {
577
+ console.error("[pause] relaunch:", error);
578
+ const row = pauseRow(pauseId);
579
+ if (row) endPause(row, "failed");
580
+ db.prepare(
581
+ `UPDATE bus_messages SET read_at = COALESCE(read_at, datetime('now'))
582
+ WHERE held_pause_id = ? AND delivered_at IS NULL`
583
+ ).run(pauseId);
584
+ say(
585
+ s,
586
+ `resume-failed:${pauseId}`,
587
+ `Task "${s.name}" could not be resumed: ${error instanceof Error ? error.message : String(error)}. Its held messages were not delivered; reopen it by hand.`
588
+ );
589
+ }
590
+
591
+ async function finishTerminalResume(
592
+ s: Session,
593
+ pauseId: number,
594
+ prompt: string
595
+ ): Promise<void> {
596
+ await relaunchTerminal(s, prompt);
597
+ db.prepare(
598
+ `UPDATE bus_messages SET delivered_at = COALESCE(delivered_at, datetime('now')) WHERE held_pause_id = ?`
599
+ ).run(pauseId);
600
+ endPause(pauseRow(pauseId)!, "resumed");
601
+ }
602
+
603
+ async function relaunchTerminal(s: Session, prompt: string): Promise<void> {
604
+ if (!s.claude_session_id || !s.worktree_path)
605
+ throw new Error("Its terminal conversation can't be resumed");
606
+ const routed = resolveRole(sessionRole(s), {
607
+ workspaceId: sessionWorkspace(s),
608
+ override: taskOverride(s),
609
+ });
610
+ await killTmux(s.tmux_name).catch(() => undefined);
611
+ await launchClaude({
612
+ sessionId: s.id,
613
+ tmuxName: s.tmux_name,
614
+ cwd: s.worktree_path,
615
+ model: routed.model,
616
+ provider: routed.provider.id === "anthropic" ? null : routed.provider.id,
617
+ prompt,
618
+ brief: s.task_brief ?? undefined,
619
+ resume: s.claude_session_id,
620
+ });
621
+ }
622
+
623
+ // A chat's resume is done once its worker has taken the continue prompt.
624
+ function settleResume(sessionId: string): boolean {
625
+ const row = activePause(sessionId);
626
+ if (!row || row.state !== "resuming") return false;
627
+ const queued = db
628
+ .prepare(`SELECT 1 FROM chat_queue WHERE session_id = ? AND id = ?`)
629
+ .get(sessionId, resumeItemId(row.id));
630
+ if (queued) return false;
631
+ endPause(row, "resumed");
632
+ return true;
633
+ }
634
+
635
+ // What the sidebar, the API and the orchestrator show of a pause.
636
+ export function pauseViewOf(sessionId: string): PauseView | null {
637
+ const row = activePause(sessionId);
638
+ if (!row) return null;
639
+ return {
640
+ state: row.state === "ended" ? "paused" : row.state,
641
+ until: row.until_at,
642
+ by: row.by_kind,
643
+ reason: row.reason,
644
+ };
645
+ }
646
+
647
+ // One pass over every unfinished pause: drive the stops a restart cut off,
648
+ // mark what came due, finish the resumes whose prompt was taken, and end the
649
+ // pauses of sessions that are gone.
650
+ export async function sweepPauses(now = Date.now()): Promise<void> {
651
+ const open = db
652
+ .prepare(`SELECT * FROM task_pauses WHERE state != 'ended'`)
653
+ .all() as TaskPause[];
654
+ for (const row of open) {
655
+ const s = sessionOf(row.session_id);
656
+ if (!s || s.archived_at || (s.task_status && s.task_status !== "running")) {
657
+ endPause(row, "finished");
658
+ continue;
659
+ }
660
+ if (row.state === "pausing") {
661
+ // A chat already stopped at its boundary needs no wait.
662
+ if (!driving.has(row.id)) drive(row.session_id, row.id);
663
+ } else if (row.state === "paused") {
664
+ if (!row.resume_requested_at && row.until_at && ms(row.until_at) <= now) {
665
+ if (row.by_kind !== "person" && s.project_id) {
666
+ const ws = getProject(s.project_id)?.workspace_id;
667
+ // An orchestrator's or the scheduler's pause waits while the
668
+ // orchestrator is paused; a person's pause ends on time.
669
+ if (ws && isPaused(ws)) continue;
670
+ }
671
+ db.prepare(
672
+ `UPDATE task_pauses SET resume_requested_at = datetime('now') WHERE id = ?`
673
+ ).run(row.id);
674
+ if (s.task_status) nudge();
675
+ else await claimResume(row.session_id, { reason: "timer" });
676
+ } else if (row.resume_requested_at && !s.task_status) {
677
+ await claimResume(row.session_id, { reason: "timer" });
678
+ }
679
+ } else if (row.state === "resuming") {
680
+ if (s.view === "chat") {
681
+ if (!settleResume(row.session_id))
682
+ await releaseChat(row.session_id).catch(() => undefined);
683
+ } else if (statusDetector.sessionExists(s.tmux_name)) {
684
+ endPause(row, "resumed");
685
+ } else {
686
+ // Cut off before the relaunch: held messages are still undelivered.
687
+ await finishTerminalResume(
688
+ s,
689
+ row.id,
690
+ buildPrompt(row, takeHeldBus(s.id, row.id))
691
+ ).catch((error) => failResume(s, row.id, error));
692
+ }
693
+ }
694
+ }
695
+ }
696
+
697
+ export function startPauseWatcher(): void {
698
+ if (g.__agentosPauseTimer) return;
699
+ let busy = false;
700
+ const run = async () => {
701
+ if (busy) return;
702
+ busy = true;
703
+ try {
704
+ await sweepPauses();
705
+ } catch (error) {
706
+ console.error("[pause] sweep:", error);
707
+ } finally {
708
+ busy = false;
709
+ }
710
+ };
711
+ g.__agentosPauseTimer = setInterval(() => void run(), SWEEP_MS);
712
+ g.__agentosPauseTimer.unref?.();
713
+ void run();
714
+ }
715
+
716
+ // For tests: forget which pauses this process is driving.
717
+ export const forgetDriving = () => driving.clear();
718
+ export { chatStateNow };