@saadnvd1/agent-os 0.3.581 → 0.3.584

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.
@@ -13,7 +13,7 @@ import {
13
13
  X,
14
14
  } from "lucide-react";
15
15
  import type { ChatItem } from "@/lib/chat/events";
16
- import { outputTail } from "@/lib/chat/background";
16
+ import { outputLines, taskLife, type TaskOutput } from "@/lib/chat/background";
17
17
  import { formatElapsed } from "@/lib/chat/elapsed";
18
18
  import { cn } from "@/lib/utils";
19
19
  import { useNow } from "./useNow";
@@ -59,7 +59,7 @@ function TaskRow({
59
59
  }: {
60
60
  task: Task;
61
61
  now: number;
62
- output: string | null | undefined;
62
+ output: TaskOutput | undefined;
63
63
  // Follows its output for the line under its name.
64
64
  tail: boolean;
65
65
  // Brought here from the call it came from.
@@ -77,7 +77,7 @@ function TaskRow({
77
77
  useEffect(() => {
78
78
  const el = outputRef.current;
79
79
  if (el) el.scrollTop = el.scrollHeight;
80
- }, [output]);
80
+ }, [output?.text]);
81
81
 
82
82
  useEffect(() => {
83
83
  if (!highlight) return;
@@ -95,8 +95,12 @@ function TaskRow({
95
95
  }, [open, tail, running, taskId, onLoadOutput]);
96
96
 
97
97
  const took = formatElapsed((task.endedAt ?? now) - task.createdAt);
98
- const line =
99
- running && !open ? (outputTail(output) ?? task.lastToolName) : null;
98
+ const life = taskLife(task, output, now);
99
+ const text = output?.text;
100
+ const lines = outputLines(text);
101
+ const idle = formatElapsed(
102
+ Math.max(0, now - (output?.updatedAt ?? task.createdAt))
103
+ );
100
104
  return (
101
105
  <div
102
106
  ref={rowRef}
@@ -137,10 +141,52 @@ function TaskRow({
137
141
  </button>
138
142
  )}
139
143
  </div>
140
- {line && (
141
- <p className="text-muted-foreground truncate pr-2 pb-1.5 pl-9 font-mono text-[11px]">
142
- {line}
143
- </p>
144
+ {!open && (
145
+ <div className="space-y-0.5 pr-2 pb-1.5 pl-9">
146
+ {life.state === "exited" && (
147
+ <p
148
+ className={cn(
149
+ "font-mono text-[11px]",
150
+ life.failed ? "text-destructive" : "text-muted-foreground"
151
+ )}
152
+ >
153
+ {life.code === null ? task.status : `exited ${life.code}`}
154
+ </p>
155
+ )}
156
+ {lines.length > 0 ? (
157
+ <pre className="text-muted-foreground max-h-[4.5rem] overflow-hidden font-mono text-[11px] leading-[1.15rem]">
158
+ {lines.map((l, i) => (
159
+ <span key={i} className="block truncate">
160
+ {l}
161
+ </span>
162
+ ))}
163
+ </pre>
164
+ ) : (
165
+ running && (
166
+ <p className="text-muted-foreground truncate font-mono text-[11px]">
167
+ {task.taskType && /monitor|watch/.test(task.taskType)
168
+ ? "watching"
169
+ : "running"}
170
+ : {task.summary || task.description}
171
+ </p>
172
+ )
173
+ )}
174
+ {running && (
175
+ <p
176
+ className={cn(
177
+ "text-[11px] tabular-nums",
178
+ life.state === "quiet"
179
+ ? "text-amber-600 dark:text-amber-400"
180
+ : "text-muted-foreground"
181
+ )}
182
+ >
183
+ {life.state === "quiet" ? "Quiet · " : ""}
184
+ {output?.updatedAt
185
+ ? `last output ${idle} ago`
186
+ : `no output in ${idle}`}
187
+ </p>
188
+ )}
189
+ </div>
144
190
  )}
145
191
  {open && (
146
192
  <div className="space-y-1 pb-2 pl-7">
@@ -149,12 +195,12 @@ function TaskRow({
149
195
  )}
150
196
  {output === undefined ? (
151
197
  <p className="text-muted-foreground text-xs">Loading output…</p>
152
- ) : output ? (
198
+ ) : text ? (
153
199
  <pre
154
200
  ref={outputRef}
155
201
  className="bg-foreground/[0.04] max-h-56 overflow-auto rounded-lg px-2 py-1.5 font-mono text-[11px] whitespace-pre-wrap"
156
202
  >
157
- {output}
203
+ {text}
158
204
  </pre>
159
205
  ) : (
160
206
  <p className="text-muted-foreground text-xs">
@@ -179,7 +225,7 @@ export function BackgroundTasks({
179
225
  items: ChatItem[];
180
226
  // A task to open the tray on, from the call that became it.
181
227
  reveal?: { taskId: string; at: number } | null;
182
- outputs: Record<string, string | null>;
228
+ outputs: Record<string, TaskOutput>;
183
229
  onStop: (taskId: string) => void;
184
230
  onLoadOutput: (taskId: string) => void;
185
231
  }) {
@@ -14,6 +14,8 @@ import { NotificationsMenu } from "@/components/NotificationsMenu";
14
14
  import type { ViewProps } from "./types";
15
15
  import { newDraft } from "@/stores/drafts";
16
16
  import { paletteActions } from "@/stores/palette";
17
+ import { useClock } from "@/hooks/useMinuteTick";
18
+ import { inBell } from "@/lib/sidebar/shelves";
17
19
  import { DRAFT_KEYS } from "@/lib/drafts";
18
20
 
19
21
  // Labels collapse to icons when the pane bar carrying these is narrow
@@ -70,6 +72,7 @@ export function DesktopAppActions({
70
72
  notificationSettings,
71
73
  attachToSession,
72
74
  }: AppActionsProps) {
75
+ const now = useClock();
73
76
  return (
74
77
  <div
75
78
  className="flex shrink-0 items-center gap-0.5"
@@ -95,7 +98,7 @@ export function DesktopAppActions({
95
98
  <NotificationsMenu
96
99
  settings={notificationSettings}
97
100
  waitingSessions={sessions
98
- .filter((s) => sessionStatuses[s.id]?.status === "waiting")
101
+ .filter((s) => inBell(s, sessionStatuses[s.id], now))
99
102
  .map((s) => ({ id: s.id, name: s.name }))}
100
103
  onSelectSession={(id) => {
101
104
  const session = sessions.find((s) => s.id === id);
@@ -22,7 +22,7 @@ import type {
22
22
  } from "@/lib/chat/events";
23
23
  import type { SearchResult } from "@/lib/chat/find";
24
24
  import { addBody, touchBody } from "@/lib/chat/bodyCache";
25
- import { backgroundFailure } from "@/lib/chat/background";
25
+ import { backgroundFailure, type TaskOutput } from "@/lib/chat/background";
26
26
 
27
27
  // The index of the item with `id`, searched from the end: what changes is
28
28
  // almost always the latest.
@@ -164,7 +164,7 @@ export function useChat(
164
164
  const searchRequests = useRef(
165
165
  new Map<string, (result: SearchResult) => void>()
166
166
  );
167
- const [taskOutputs, setTaskOutputs] = useState<Record<string, string | null>>(
167
+ const [taskOutputs, setTaskOutputs] = useState<Record<string, TaskOutput>>(
168
168
  {}
169
169
  );
170
170
  const [canBackground, setCanBackground] = useState(false);
@@ -278,7 +278,10 @@ export function useChat(
278
278
  );
279
279
  if (message) toast(message, { duration: 2500 });
280
280
  } else if (m.type === "task_output") {
281
- setTaskOutputs((prev) => ({ ...prev, [m.taskId]: m.text }));
281
+ setTaskOutputs((prev) => ({
282
+ ...prev,
283
+ [m.taskId]: { text: m.text, updatedAt: m.updatedAt },
284
+ }));
282
285
  } else if (m.type === "undo_preview") {
283
286
  setUndoPreview({ from: m.from, preview: m.preview });
284
287
  } else if (m.type === "undone") {
@@ -75,3 +75,57 @@ export function outputTail(text: string | null | undefined): string | null {
75
75
  }
76
76
  return null;
77
77
  }
78
+
79
+ // A running task that has printed nothing for this long is marked quiet.
80
+ export const QUIET_AFTER_MS = 5 * 60 * 1000;
81
+ const TAIL_LINES = 4;
82
+
83
+ // What the server reads of a task's output file: the end of it, and when it
84
+ // last changed.
85
+ export interface TaskOutput {
86
+ text: string | null;
87
+ updatedAt: number | null;
88
+ }
89
+
90
+ // The last few lines that say anything, oldest first.
91
+ export function outputLines(
92
+ text: string | null | undefined,
93
+ max = TAIL_LINES
94
+ ): string[] {
95
+ const lines = (text?.split("\n") ?? []).map((l) => l.trimEnd());
96
+ return lines.filter((l) => l.trim()).slice(-max);
97
+ }
98
+
99
+ // "... failed with exit code 65" from the agent's note on a finished task.
100
+ export function exitCode(summary: string | undefined): number | null {
101
+ const m =
102
+ summary &&
103
+ /exit(?:ed)?(?: with)?(?: status| code)?\s*:?\s*(-?\d+)/i.exec(summary);
104
+ return m ? Number(m[1]) : null;
105
+ }
106
+
107
+ export type TaskLife =
108
+ | { state: "active" }
109
+ | { state: "quiet"; idleMs: number }
110
+ | { state: "exited"; code: number | null; failed: boolean };
111
+
112
+ // Whether a task is progressing, has gone quiet, or has ended. Idle time
113
+ // counts from its last output, or from its start when it has printed nothing.
114
+ export function taskLife(
115
+ task: Pick<Task, "status" | "createdAt" | "summary">,
116
+ output: TaskOutput | null | undefined,
117
+ now: number
118
+ ): TaskLife {
119
+ if (task.status !== "running") {
120
+ const code = exitCode(task.summary);
121
+ return {
122
+ state: "exited",
123
+ code: code ?? (task.status === "completed" ? 0 : null),
124
+ failed: task.status === "failed" || (code !== null && code !== 0),
125
+ };
126
+ }
127
+ const idleMs = Math.max(0, now - (output?.updatedAt ?? task.createdAt));
128
+ return idleMs >= QUIET_AFTER_MS
129
+ ? { state: "quiet", idleMs }
130
+ : { state: "active" };
131
+ }
@@ -369,7 +369,12 @@ export type ChatServerMessage =
369
369
  }
370
370
  | { type: "context"; context: ChatContext | null }
371
371
  | { type: "undo_preview"; from: string; preview: UndoPreview }
372
- | { type: "task_output"; taskId: string; text: string | null }
372
+ | {
373
+ type: "task_output";
374
+ taskId: string;
375
+ text: string | null;
376
+ updatedAt: number | null;
377
+ }
373
378
  | {
374
379
  type: "background_result";
375
380
  toolUseId?: string;
@@ -63,6 +63,7 @@ import { holdsQueue, settingUp } from "../sessions/setup-progress";
63
63
  import { firstMessageId, launchPending } from "../tasks/launch-gate";
64
64
  import type { ChatItem, FileSuggestion } from "./events";
65
65
  import { taskOutputTail } from "./task-output";
66
+ import type { TaskOutput } from "./background";
66
67
  import { cutOffSessions, cutOffTurn, RESUME_NOTE } from "./interrupted";
67
68
  import { queueEvent } from "../orchestrator/events";
68
69
  import { restoreActivity, track } from "./activity";
@@ -881,7 +882,7 @@ export async function backgroundChatTask(
881
882
  export function chatTaskOutput(
882
883
  sessionId: string,
883
884
  taskId: string
884
- ): string | null {
885
+ ): TaskOutput | null {
885
886
  const task = itemsOfKind(sessionId, "task").find((i) => i.taskId === taskId);
886
887
  if (task?.kind !== "task") return null;
887
888
  return taskOutputTail(taskId, task.outputFile);
@@ -196,7 +196,10 @@ function onChatMessage(
196
196
  reply({
197
197
  type: "task_output",
198
198
  taskId: msg.taskId,
199
- text: chatTaskOutput(sessionId, msg.taskId),
199
+ ...(chatTaskOutput(sessionId, msg.taskId) ?? {
200
+ text: null,
201
+ updatedAt: null,
202
+ }),
200
203
  });
201
204
  else if (msg.type === "history" && Number.isFinite(msg.before))
202
205
  reply({
@@ -2,6 +2,8 @@ import fs from "fs";
2
2
  import os from "os";
3
3
  import path from "path";
4
4
 
5
+ import type { TaskOutput } from "./background";
6
+
5
7
  const TAIL_BYTES = 6000;
6
8
  const found = new Map<string, string>();
7
9
 
@@ -48,19 +50,22 @@ function walk(dir: string, name: string, depth: number): string | null {
48
50
  export function taskOutputTail(
49
51
  taskId: string,
50
52
  knownFile?: string
51
- ): string | null {
53
+ ): TaskOutput | null {
52
54
  const file =
53
55
  knownFile && fs.existsSync(knownFile) ? knownFile : findOutputFile(taskId);
54
56
  if (!file) return null;
55
57
  try {
56
- const { size } = fs.statSync(file);
58
+ const { size, mtimeMs } = fs.statSync(file);
57
59
  const fd = fs.openSync(file, "r");
58
60
  try {
59
61
  const start = Math.max(0, size - TAIL_BYTES);
60
62
  const buf = Buffer.alloc(size - start);
61
63
  fs.readSync(fd, buf, 0, buf.length, start);
62
64
  const text = buf.toString("utf8");
63
- return start > 0 ? text.slice(text.indexOf("\n") + 1) : text;
65
+ return {
66
+ text: start > 0 ? text.slice(text.indexOf("\n") + 1) : text,
67
+ updatedAt: mtimeMs,
68
+ };
64
69
  } finally {
65
70
  fs.closeSync(fd);
66
71
  }
package/lib/needs-you.ts CHANGED
@@ -33,7 +33,12 @@ function latestChatItem(sessionId: string): ChatItem | null {
33
33
  // is routine for it.
34
34
  export function needsYou(
35
35
  session: Pick<Session, "id" | "view" | "updated_at" | "last_seen_at"> &
36
- Partial<Pick<Session, "role" | "workspace_id" | "task_status">>,
36
+ Partial<
37
+ Pick<
38
+ Session,
39
+ "role" | "workspace_id" | "task_status" | "settled_at" | "archived_at"
40
+ >
41
+ >,
37
42
  chatState: ChatState | null
38
43
  ): boolean {
39
44
  const seen = sqliteMs(session.last_seen_at);
@@ -43,7 +48,16 @@ export function needsYou(
43
48
  if (chatState === "waiting") return true;
44
49
  if (session.role === "orchestrator")
45
50
  return openAskCount(session.workspace_id) > 0;
51
+ if (session.archived_at) return false;
46
52
  const latest = latestChatItem(session.id);
53
+ // Put on the Done shelf after its last turn: that turn is history, not
54
+ // news. (settled_at has second precision.)
55
+ if (
56
+ latest &&
57
+ session.settled_at &&
58
+ latest.createdAt <= sqliteMs(session.settled_at) + 1000
59
+ )
60
+ return false;
47
61
  return latest?.kind === "turn_end" && latest.createdAt > seen;
48
62
  }
49
63
  // A terminal session's updated_at moves when it starts waiting.
@@ -7,6 +7,7 @@ import { db, queries, type Session } from "../db";
7
7
  import { finalMessage, usageRows } from "../session-logs/gather";
8
8
  import { chatStateNow } from "../chat/runner";
9
9
  import { doneSession } from "../done";
10
+ import { notifyStatusChanged } from "../status/hub";
10
11
  import { blockedReason } from "../tasks/state";
11
12
  import { runClaude, type ClaudeRunner } from "../orchestrator/claude-cli";
12
13
  import { releasePieces } from "../plans/release";
@@ -406,23 +407,35 @@ async function settleOne(
406
407
  } else idleSince.delete(run.id);
407
408
  }
408
409
 
409
- // After a run settles: a finished session run is marked Done so the sidebar
410
- // doesn't pile up; a task run is left to its gates. Idle alerts for it are
411
- // silenced because the settle event already told the orchestrator.
410
+ // After a run settles: a session run that reported its result (ok or failed)
411
+ // is marked Done so the sidebar and the bell don't pile up; a task run is left
412
+ // to its gates. A run that ended without a result line, was blocked or timed
413
+ // out may be a real question, so it stays open. The settle event already told
414
+ // the orchestrator.
415
+ export function finishesSession(
416
+ row: { status: RunStatus; summary_source: string | null },
417
+ recipe: Pick<Recipe, "kind" | "auto_done">
418
+ ): boolean {
419
+ if (recipe.kind !== "session" || !recipe.auto_done) return false;
420
+ if (row.status === "succeeded") return true;
421
+ return row.status === "failed" && row.summary_source === "line";
422
+ }
423
+
412
424
  async function afterSettle(run: Live): Promise<void> {
413
425
  const row = db
414
- .prepare(`SELECT status FROM recipe_runs WHERE id = ?`)
415
- .get(run.id) as { status: RunStatus } | undefined;
416
- if (!row || run.recipe.kind !== "session" || !run.session_id) return;
417
- if (row.status === "succeeded" && run.recipe.auto_done) {
418
- try {
419
- await doneSession(run.session_id, { by: "direct", noMerge: true });
420
- } catch (error) {
421
- console.warn(
422
- "[recipes] couldn't mark done:",
423
- error instanceof Error ? error.message : error
424
- );
425
- }
426
+ .prepare(`SELECT status, summary_source FROM recipe_runs WHERE id = ?`)
427
+ .get(run.id) as
428
+ | { status: RunStatus; summary_source: string | null }
429
+ | undefined;
430
+ if (!row || !run.session_id || !finishesSession(row, run.recipe)) return;
431
+ try {
432
+ await doneSession(run.session_id, { by: "direct", noMerge: true });
433
+ notifyStatusChanged();
434
+ } catch (error) {
435
+ console.warn(
436
+ `[recipes] couldn't mark run #${run.seq} (${run.session_id}) done:`,
437
+ error instanceof Error ? error.message : error
438
+ );
426
439
  }
427
440
  }
428
441
 
@@ -198,6 +198,17 @@ export function settledHow(
198
198
  return now - active >= SETTLE_AFTER_MS ? "auto" : null;
199
199
  }
200
200
 
201
+ // Whether the bell lists a session: waiting on you, and not put away. Like
202
+ // the shelves, a live need wins over Done.
203
+ export function inBell(
204
+ session: Parameters<typeof settledHow>[0] & Pick<Session, "archived_at">,
205
+ status: RowStatus | undefined,
206
+ now: number
207
+ ): boolean {
208
+ if (status?.status !== "waiting" || session.archived_at) return false;
209
+ return !!status.need || !settledHow(session, now);
210
+ }
211
+
201
212
  // Woken by its timer and not opened since.
202
213
  function wokeUnread(
203
214
  session: Parameters<typeof activeMs>[0] & Pick<Session, "last_seen_at">,
@@ -189,7 +189,10 @@ const workspaceOf = (projectId: string) =>
189
189
  // gives its slot back until it resumes. A quick change
190
190
  // runs no agent, so it holds none. Without a
191
191
  // workspace, the project is the scope.
192
- function runningIn(scope: { workspaceId: string | null; projectId: string }) {
192
+ function runningIn(
193
+ scope: { workspaceId: string | null; projectId: string },
194
+ forSlots = false
195
+ ) {
193
196
  const where = scope.workspaceId
194
197
  ? `p.workspace_id = @ws`
195
198
  : `p.id = @project AND p.workspace_id IS NULL`;
@@ -199,6 +202,7 @@ function runningIn(scope: { workspaceId: string | null; projectId: string }) {
199
202
  `SELECT s.id FROM sessions s JOIN projects p ON p.id = s.project_id
200
203
  WHERE ${where} AND s.archived_at IS NULL AND s.quick_of IS NULL
201
204
  AND (s.pause_state IS NULL OR s.pause_state != 'paused')
205
+ ${forSlots ? `AND NOT (COALESCE(s.plan_only, 0) = 1 AND COALESCE(s.plan_phase, '') != 'implementing')` : ""}
202
206
  AND s.task_status IN (${IN_FLIGHT.map((s) => `'${s}'`).join(", ")})`
203
207
  )
204
208
  .all({ ws: scope.workspaceId, project: scope.projectId }) as {
@@ -214,7 +218,8 @@ function startingIn(workspaceId: string): number {
214
218
  db
215
219
  .prepare(
216
220
  `SELECT COUNT(*) AS n FROM task_queue q JOIN projects p ON p.id = q.project_id
217
- WHERE q.status = 'starting' AND p.workspace_id = ?`
221
+ WHERE q.status = 'starting' AND p.workspace_id = ?
222
+ AND q.plan_only IS NULL`
218
223
  )
219
224
  .get(workspaceId) as { n: number }
220
225
  ).n
@@ -315,7 +320,8 @@ const waitingIn = (workspaceId: string) =>
315
320
  .prepare(
316
321
  `SELECT COUNT(*) AS n FROM task_queue q JOIN projects p ON p.id = q.project_id
317
322
  WHERE q.status = 'queued' AND p.workspace_id = ?
318
- AND q.after_task IS NULL AND q.after_any IS NULL`
323
+ AND q.after_task IS NULL AND q.after_any IS NULL
324
+ AND q.plan_only IS NULL`
319
325
  )
320
326
  .get(workspaceId) as { n: number }
321
327
  ).n;
@@ -344,9 +350,10 @@ export function queueIfNeeded(req: QueueRequest): QueuedTaskView | null {
344
350
  } else if (after)
345
351
  afterTask = resolveAfter(after, req.originWorkspaceId ?? workspaceId).id;
346
352
  const full =
353
+ !req.planOnly &&
347
354
  limit !== null &&
348
355
  !!workspaceId &&
349
- (runningIn({ workspaceId, projectId: project.id }).length +
356
+ (runningIn({ workspaceId, projectId: project.id }, true).length +
350
357
  startingIn(workspaceId) >=
351
358
  limit ||
352
359
  waitingIn(workspaceId) > 0);
@@ -360,6 +367,7 @@ export function queueIfNeeded(req: QueueRequest): QueuedTaskView | null {
360
367
  const loaded =
361
368
  !waits &&
362
369
  !full &&
370
+ !req.planOnly &&
363
371
  !!req.originWorkspaceId &&
364
372
  loadHeldStart(project.id, req.prompt, setting);
365
373
  if (!waits && !full && !loaded) return null;
@@ -523,9 +531,10 @@ export const queuedView = (id: string) =>
523
531
 
524
532
  // Why an automatic start has to wait, or null. A start now by a person
525
533
  // isn't held.
526
- function autoHold(workspaceId: string | null): Hold | null {
534
+ function autoHold(workspaceId: string | null, planOnly = false): Hold | null {
527
535
  if (workspaceId && isPaused(workspaceId))
528
536
  return { code: "orch_paused", text: "Waiting: the orchestrator is paused" };
537
+ if (planOnly) return null;
529
538
  const window = brakesEnabled() ? windowRefusal(readUsage()) : null;
530
539
  return window ? { code: "window", text: `Waiting: ${window}` } : null;
531
540
  }
@@ -609,7 +618,7 @@ async function startRow(
609
618
  return "failed";
610
619
  }
611
620
  if (!opts.force) {
612
- const held = autoHold(workspaceId);
621
+ const held = autoHold(workspaceId, !!r.plan_only);
613
622
  if (held) {
614
623
  if (r.note !== held.text)
615
624
  patch(r.id, { note: held.text, wait_code: held.code });
@@ -621,7 +630,8 @@ async function startRow(
621
630
  if (
622
631
  workspaceId &&
623
632
  limit !== null &&
624
- runningIn({ workspaceId, projectId: r.project_id }).length +
633
+ !r.plan_only &&
634
+ runningIn({ workspaceId, projectId: r.project_id }, true).length +
625
635
  startingIn(workspaceId) >=
626
636
  limit
627
637
  )
@@ -643,7 +653,7 @@ async function startRow(
643
653
  }
644
654
  if (!claimed) return "held";
645
655
  const placed = opts.placement ?? null;
646
- if (!opts.force)
656
+ if (!opts.force && !r.plan_only)
647
657
  recordAutoStart(placed?.hostId ?? LOCAL, !!opts.heavy, Date.now());
648
658
  // A linked machine's start never sees this machine's held messages: they
649
659
  // go with the brief it is sent.
@@ -747,7 +757,7 @@ function groupFor(
747
757
  const scope = { workspaceId, projectId };
748
758
  return {
749
759
  inFlight:
750
- runningIn(scope).length +
760
+ runningIn(scope, true).length +
751
761
  (workspaceId ? (reserved.get(workspaceId) ?? 0) : 0) +
752
762
  startingRows,
753
763
  limit: limitOf(workspaceId) ?? Number.POSITIVE_INFINITY,
@@ -814,8 +824,9 @@ function candidateOf(
814
824
  heavy: heavyNeeds(needs),
815
825
  needsUnmet: !remote && !meetsNeeds(needs, machineCapabilities()),
816
826
  remote,
827
+ planOnly: !!r.plan_only,
817
828
  before,
818
- auto: autoHold(r.workspace_id),
829
+ auto: autoHold(r.workspace_id, !!r.plan_only),
819
830
  any,
820
831
  },
821
832
  };
@@ -82,6 +82,9 @@ export interface Candidate {
82
82
  needsUnmet: boolean;
83
83
  // Starts on another machine: this one's load doesn't matter to it.
84
84
  remote: boolean;
85
+ // A plan-only task before approval: it reads code and writes one file, so
86
+ // load, spacing and the slot limit don't apply. Pause and `after` still do.
87
+ planOnly?: boolean;
85
88
  // `after X` hold, from the caller (it needs the database).
86
89
  before: Hold | null;
87
90
  // Orchestrator Pause and the usage window, which hold after `after`.
@@ -127,11 +130,12 @@ const seconds = (ms: number) => Math.max(1, Math.ceil(ms / 1000));
127
130
 
128
131
  // The machine holds, in order. Null: it may start here now.
129
132
  export function machineHold(
130
- c: Pick<Candidate, "heavy" | "needsUnmet" | "remote" | "priority">,
133
+ c: Pick<Candidate, "heavy" | "needsUnmet" | "remote" | "priority"> &
134
+ Partial<Pick<Candidate, "planOnly">>,
131
135
  m: MachineView,
132
136
  now: number
133
137
  ): Hold | null {
134
- if (c.remote) return null;
138
+ if (c.remote || c.planOnly) return null;
135
139
  if (c.needsUnmet) return { code: "needs", text: `Waiting: needs a Mac` };
136
140
  if (!m.enabled) return null;
137
141
  if (m.reading)
@@ -226,7 +230,7 @@ export function planStarts(
226
230
  priority: c.priority,
227
231
  });
228
232
  if (hold) return void plan.explain.set(c.id, view(hold));
229
- if (inFlight >= group.limit)
233
+ if (!c.planOnly && inFlight >= group.limit)
230
234
  return void plan.explain.set(
231
235
  c.id,
232
236
  view({
@@ -240,8 +244,8 @@ export function planStarts(
240
244
  if (free) used.add(free);
241
245
  plan.consume.set(c.id, free);
242
246
  }
243
- inFlight += 1;
244
- if (!c.remote) {
247
+ if (!c.planOnly) inFlight += 1;
248
+ if (!c.remote && !c.planOnly) {
245
249
  m.lastAutoStartAt = now;
246
250
  if (c.heavy) {
247
251
  m.lastHeavyStartAt = now;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.581",
3
+ "version": "0.3.584",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"