@saadnvd1/agent-os 0.3.582 → 0.3.585

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.
@@ -26,7 +26,9 @@ import {
26
26
  findQueued,
27
27
  queuedLabel,
28
28
  removeQueued,
29
+ slotState,
29
30
  startOrQueue,
31
+ startQueuedForOrchestrator,
30
32
  } from "../tasks/queue";
31
33
  import { getStack, previewStack, startStack } from "../stacks";
32
34
  import { treeOrder } from "../stacks/tree";
@@ -155,10 +157,14 @@ export async function startTask(
155
157
  planReason?: string;
156
158
  priority?: Priority;
157
159
  machine?: "mac" | "any";
160
+ now?: boolean;
161
+ nowReason?: string;
158
162
  } = {}
159
163
  ): Promise<string> {
160
164
  if (opts.planOnly && opts.plan === false)
161
165
  throw new Error("plan_only implies plan; pass plan true");
166
+ if (opts.now && !opts.nowReason)
167
+ throw new Error("now needs now_reason: say why it can't wait for a slot");
162
168
  const project = workspaceProject(workspaceId, projectRef);
163
169
  const model = parseOverride(opts.model) ?? undefined;
164
170
  const startedBy = orchestratorId(workspaceId);
@@ -168,6 +174,7 @@ export async function startTask(
168
174
  `${project.name} runs on another machine; the orchestrator starts tasks on this one only for now`
169
175
  );
170
176
  const view = opts.view ?? DEFAULT_START_VIEW;
177
+ const slots = slotState(project.id);
171
178
  // Held for a slot or its `after`, it starts by itself through the brakes;
172
179
  // you get an event when it does.
173
180
  const out = await startOrQueue(
@@ -186,6 +193,7 @@ export async function startTask(
186
193
  originWorkspaceId: workspaceId,
187
194
  priority: opts.priority,
188
195
  machine: opts.machine,
196
+ now: opts.now,
189
197
  },
190
198
  () =>
191
199
  braked(
@@ -213,6 +221,12 @@ export async function startTask(
213
221
  }
214
222
  const task = out.started;
215
223
  if (opts.priority) stampPriority(task.id, opts.priority);
224
+ const past = opts.now && slots.full && !opts.planOnly;
225
+ if (past)
226
+ addNote(
227
+ workspaceId,
228
+ `Started "${task.name}" past the running-task limit (${slots.running} of ${slots.limit} running): ${opts.nowReason}`
229
+ );
216
230
  const planOnlyNote = opts.planOnly
217
231
  ? " Plan only: it stops after the plan and waits; approve_plan continues it, drop rejects it."
218
232
  : "";
@@ -220,7 +234,39 @@ export async function startTask(
220
234
  opts.plan && !opts.planOnly && task.plan_phase !== "planning"
221
235
  ? ` Plan step skipped: ${view === "terminal" ? "a terminal task has none" : "planning and implementation use the same model"}.`
222
236
  : "";
223
- return `Started task "${task.name}" (id ${task.id.slice(0, 8)}, ${view}) in ${project.name} on ${task.branch_name} (from ${task.base_branch}). It ends in a PR; you'll get an event when it opens.${skipped}${planOnlyNote}`;
237
+ return `Started task "${task.name}" (id ${task.id.slice(0, 8)}, ${view}) in ${project.name} on ${task.branch_name} (from ${task.base_branch}). It ends in a PR; you'll get an event when it opens.${skipped}${planOnlyNote}${past ? " Started past the running-task limit." : ""}`;
238
+ }
239
+
240
+ // Starts a queued task now, past the running-task limit and its `after`.
241
+ // Pause, the brakes and a machine need still hold it.
242
+ export async function startNow(
243
+ workspaceId: string,
244
+ ref: string,
245
+ reason: string
246
+ ): Promise<string> {
247
+ const queued = findQueued(workspaceId, ref, { byName: true });
248
+ if (!queued) {
249
+ const running = workspaceTask(workspaceId, ref);
250
+ throw new Error(`${running.name} is already started, not queued`);
251
+ }
252
+ const name = queuedLabel(queued);
253
+ const out = await startQueuedForOrchestrator(workspaceId, queued.id);
254
+ if (out.outcome === "held")
255
+ return `Not started: ${out.text}. ${name} stays in the queue.`;
256
+ if (out.outcome === "failed") return `Could not start ${name}: ${out.text}.`;
257
+ const skipped = [
258
+ out.wasFull
259
+ ? `past the running-task limit (${out.running} of ${out.limit} running)`
260
+ : "",
261
+ out.skippedAfter ? "without waiting for its after" : "",
262
+ ]
263
+ .filter(Boolean)
264
+ .join(", ");
265
+ addNote(
266
+ workspaceId,
267
+ `Started queued ${name} now${skipped ? `, ${skipped}` : ""}: ${reason}`
268
+ );
269
+ return `Started ${name} (id ${queued.id.slice(0, 8)}) now${skipped ? `, ${skipped}` : ""}. You'll get an event when its PR opens.`;
224
270
  }
225
271
 
226
272
  // The orchestrator approves a plan-only task's plan; a message amends it.
@@ -119,7 +119,7 @@ Reading:
119
119
  Acting:
120
120
  - \`${TOOL_NAMES.send}\` (session, message, scope_change?): message a session; it arrives as its next prompt. scope_change records it on the task's brief for its review.
121
121
  - \`${TOOL_NAMES.message_orchestrator}\` (workspace, message): message another workspace's orchestrator, the only thing you reach outside this workspace. Use it to hand over a bug or ask for something in its projects; it decides what to do there.
122
- - \`${TOOL_NAMES.start_task}\` (project, prompt, base?, name?, view?, after?, priority?, machine?, model?, plan, plan_only?, plan_reason): a task in its own worktree that ends in a PR. priority (urgent, blocking, normal, background) sets where it stands in the queue; machine mac pins work that needs this Mac. A busy or loaded machine holds a queued start and says why; don't retry it. Write the prompt as a full brief; name it in 2-6 words, or it's named from the prompt. It runs as a chat; pass view terminal only when the job needs a TUI. Pass after (a task's id or name, or "any") to hold it until that task finishes, instead of remembering it in your notes. Over the workspace's running task limit it's queued too: the result says "Queued (position N)", it starts by itself, and you get an event when it does. Its models follow the role settings (it plans on the planning model, then implements on the implementation one); State plan and plan_reason for every task (see "Triage every job"). Pass model ("provider/model", or a map of role to one) only when Saad asks for one, or to put implementation on Opus when the work needs deep reasoning (say why in plan_reason or a note); another provider than anthropic only when Saad asks.
122
+ - \`${TOOL_NAMES.start_task}\` (project, prompt, base?, name?, view?, after?, priority?, machine?, now?, now_reason?, model?, plan, plan_only?, plan_reason): a task in its own worktree that ends in a PR. priority (urgent, blocking, normal, background) sets where it stands in the queue; machine mac pins work that needs this Mac. A busy or loaded machine holds a queued start and says why; don't retry it. Write the prompt as a full brief; name it in 2-6 words, or it's named from the prompt. It runs as a chat; pass view terminal only when the job needs a TUI. Pass after (a task's id or name, or "any") to hold it until that task finishes, instead of remembering it in your notes. Over the workspace's running task limit it's queued too: the result says "Queued (position N)", it starts by itself, and you get an event when it does. now true (with now_reason, not with after) starts it at once past the limit instead; it's logged for Saad, so use it only when it can't wait. Its models follow the role settings (it plans on the planning model, then implements on the implementation one); State plan and plan_reason for every task (see "Triage every job"). Pass model ("provider/model", or a map of role to one) only when Saad asks for one, or to put implementation on Opus when the work needs deep reasoning (say why in plan_reason or a note); another provider than anthropic only when Saad asks.
123
123
  - \`${TOOL_NAMES.plan_pieces}\` (task, merge?, drop?): when a plan split into pieces (the plan-done event names them), list them; merge or drop edits the ones still waiting. A piece starts by itself once what it waits on has merged (or deployed); you don't start it.
124
124
  - \`${TOOL_NAMES.approve_plan}\` (task, message?): continue a plan_only task whose plan is ready; it implements on the implementation model, once. message amends the plan and is recorded on the task's brief as a scope change. Reject a plan with drop.
125
125
  - \`${TOOL_NAMES.set_task_model}\` (task, model, message?): move a paused task to another model and carry on in the same conversation and worktree; it keeps that model from then on. Only when Saad asks.
@@ -135,6 +135,7 @@ Acting:
135
135
  - \`${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.
136
136
  - \`${TOOL_NAMES.drop}\` (task, reason): close a task's PR and remove its worktree (on whichever machine holds it).
137
137
  - \`${TOOL_NAMES.set_priority}\` (task, priority): re-rank a queued task (urgent, blocking, normal, background), or tag a running one. Urgent is a bug Saad hit; background starts last.
138
+ - \`${TOOL_NAMES.start_now}\` (task, reason): start a queued task now, past the running-task limit and its after. Still refused while paused or braked, and a Mac-only task stays queued. Use it sparingly, when Saad asks or it unblocks others; the reason is logged for Saad.
138
139
  - \`${TOOL_NAMES.pause_task}\` (task, for, now?, reason): pause a running task for 1h, 2h, until tomorrow 9 AM or until resumed. Its agent ends at the end of its step (now interrupts the step); its worktree and conversation stay and its slot frees for the next queued task. It resumes by itself, ahead of new arrivals, with what was held (CI and review notes, messages) in one prompt. Use it to make room for urgent work or to wait out a loaded machine. Refused for a task that is merging, moving, setting up or waiting on Saad.
139
140
  - \`${TOOL_NAMES.resume_task}\` (task): resume a pause you set now. A pause Saad set is his to lift.
140
141
  - \`${TOOL_NAMES.stop}\` (session): stop a session's agent, keeping its work.
@@ -12,6 +12,7 @@ import {
12
12
  stackStatus,
13
13
  startSession,
14
14
  startTask,
15
+ startNow,
15
16
  setTaskPriority,
16
17
  pauseTask,
17
18
  resumeTask,
@@ -67,6 +68,7 @@ const ACTING = new Set<ToolName>([
67
68
  "approve_plan",
68
69
  "plan_pieces",
69
70
  "set_priority",
71
+ "start_now",
70
72
  "pause_task",
71
73
  "resume_task",
72
74
  "stop",
@@ -139,8 +141,14 @@ export async function runTool(
139
141
  planReason: a.plan_reason,
140
142
  priority: a.priority,
141
143
  machine: a.machine,
144
+ now: a.now,
145
+ nowReason: a.now_reason,
142
146
  });
143
147
  }
148
+ case "start_now": {
149
+ const a = p(tool, raw);
150
+ return startNow(w, a.task, a.reason);
151
+ }
144
152
  case "set_priority": {
145
153
  const a = p(tool, raw);
146
154
  return setTaskPriority(w, a.task, a.priority);
@@ -30,6 +30,7 @@ export const TOOL_NAMES = {
30
30
  drop: name("drop"),
31
31
  move: name("move"),
32
32
  set_priority: name("set_priority"),
33
+ start_now: name("start_now"),
33
34
  pause_task: name("pause_task"),
34
35
  resume_task: name("resume_task"),
35
36
  stop: name("stop"),
@@ -128,6 +128,19 @@ export const TOOL_SHAPES = {
128
128
  .describe(
129
129
  'Where it may run: "mac" for work that needs this Mac (Xcode, a simulator); "any" if no machine need applies. Unset: the project\'s setting, else decided from its files'
130
130
  ),
131
+ now: z
132
+ .boolean()
133
+ .optional()
134
+ .describe(
135
+ "Start it at once, past the workspace's running-task limit, instead of queueing for a slot. Needs now_reason; not with after"
136
+ ),
137
+ now_reason: z
138
+ .string()
139
+ .trim()
140
+ .min(1)
141
+ .max(300)
142
+ .optional()
143
+ .describe("With now: why it can't wait for a slot; logged for Saad"),
131
144
  model: modelOverride,
132
145
  plan: z
133
146
  .boolean()
@@ -250,6 +263,15 @@ export const TOOL_SHAPES = {
250
263
  task: ref("A queued or running task: name, project/name or id"),
251
264
  priority: z.enum(PRIORITIES),
252
265
  },
266
+ start_now: {
267
+ task: ref("A queued task: name, project/name or id"),
268
+ reason: z
269
+ .string()
270
+ .trim()
271
+ .min(1)
272
+ .max(300)
273
+ .describe("Why it can't wait for a slot; logged for Saad"),
274
+ },
253
275
  pause_task: {
254
276
  task,
255
277
  for: z
@@ -62,7 +62,7 @@ const DESCRIPTIONS: Record<ToolName, string> = {
62
62
  message_orchestrator:
63
63
  "Message another workspace's orchestrator, by workspace name; never any other session there. It arrives as its next prompt, marked as from you and fenced as untrusted.",
64
64
  start_task:
65
- 'Start a task: an agent in its own worktree that ends in a PR. It runs as a chat unless view is terminal (only for a job that needs a TUI). priority (urgent, blocking, normal, background) says where it stands in the queue; machine "mac" pins work that needs this Mac. A busy machine holds a start and the result says why. With after (a task\'s id or name, or "any"), or over the workspace\'s running task limit, it\'s queued and starts by itself; the result says Queued (position N). Its models follow the role settings (planning, implementation, review, scope); give model only when Saad asks for one. Always state plan (true or false) and plan_reason (plan_only true plans, then stops and waits for approve_plan instead of implementing): plan true for a big, unclear or cross-cutting job, false for a small, clear one whose steps you write into the prompt. Refused while a brake holds.',
65
+ 'Start a task: an agent in its own worktree that ends in a PR. It runs as a chat unless view is terminal (only for a job that needs a TUI). priority (urgent, blocking, normal, background) says where it stands in the queue; machine "mac" pins work that needs this Mac. A busy machine holds a start and the result says why. With after (a task\'s id or name, or "any"), or over the workspace\'s running task limit, it\'s queued and starts by itself; the result says Queued (position N). Its models follow the role settings (planning, implementation, review, scope); give model only when Saad asks for one. Always state plan (true or false) and plan_reason (plan_only true plans, then stops and waits for approve_plan instead of implementing): plan true for a big, unclear or cross-cutting job, false for a small, clear one whose steps you write into the prompt. now true (with now_reason) starts it at once past the running-task limit instead of queueing for a slot, logged; not with after. Refused while a brake holds.',
66
66
  set_task_model:
67
67
  'Resume a paused task on another model ("provider/model", or a map of role to one), keeping its conversation and worktree; it\'s told to carry on (or message). Refused mid-turn.',
68
68
  start_session:
@@ -83,6 +83,8 @@ const DESCRIPTIONS: Record<ToolName, string> = {
83
83
  move: 'Carry a running task to a linked machine, or back with "here": for heavy test or build work. It keeps its branch, conversation and plan; the gates, events, read, send, stop, done and drop work the same wherever it runs, and the machine it lands on can\'t merge it. Takes a few minutes and runs in the background; an event says when it lands. Refused while paused, for a task waiting on Saad (an escalation or open ask), and for card and stacked tasks.',
84
84
  set_priority:
85
85
  "Change the priority of a queued task (it re-ranks the line at once) or a running one (it only matters if it is paused and waits to resume). urgent starts first even on a busy machine; background starts last.",
86
+ start_now:
87
+ "Start a queued task now, past the workspace's running-task limit and its after, when it can't wait for a slot (Saad asked, or it unblocks others). Still refused while paused or braked, and a task that needs a Mac stays queued. Give a reason: it goes in the decision log. Says whether it started, is still held and why, or failed.",
86
88
  pause_task:
87
89
  "Pause a running task for a set time: its agent ends at the end of its current step (now true interrupts the step), its worktree and conversation stay, and it frees its slot so the next queued task can start. It resumes by itself at the time, ahead of new arrivals of the same priority, with whatever was held (CI and review notes, messages) in one continue prompt. Refused for a task that is merging, moving, setting up or waiting on Saad. Give a reason; it is shown with the pause.",
88
90
  resume_task:
@@ -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">,
@@ -312,6 +312,26 @@ export interface QueueRequest {
312
312
  priority?: Priority;
313
313
  // "mac" pins it to a Mac; "any" says it needs nothing in particular.
314
314
  machine?: "any" | "mac";
315
+ // Start at once, past the running-task limit, instead of queueing for a
316
+ // slot. Still needs the machine it needs, and an orchestrator's start
317
+ // still passes its brakes.
318
+ now?: boolean;
319
+ }
320
+
321
+ // How full the workspace's running-task limit is for this project.
322
+ export function slotState(projectId: string): {
323
+ running: number;
324
+ limit: number | null;
325
+ full: boolean;
326
+ } {
327
+ const workspaceId = workspaceOf(projectId);
328
+ const limit = limitOf(workspaceId);
329
+ if (!workspaceId || limit === null)
330
+ return { running: 0, limit: null, full: false };
331
+ const running =
332
+ runningIn({ workspaceId, projectId }, true).length +
333
+ startingIn(workspaceId);
334
+ return { running, limit, full: running >= limit };
315
335
  }
316
336
 
317
337
  const waitingIn = (workspaceId: string) =>
@@ -349,8 +369,11 @@ export function queueIfNeeded(req: QueueRequest): QueuedTaskView | null {
349
369
  }
350
370
  } else if (after)
351
371
  afterTask = resolveAfter(after, req.originWorkspaceId ?? workspaceId).id;
372
+ if (req.now && waits)
373
+ throw new Error("now and after don't go together: now starts it at once");
352
374
  const full =
353
375
  !req.planOnly &&
376
+ !req.now &&
354
377
  limit !== null &&
355
378
  !!workspaceId &&
356
379
  (runningIn({ workspaceId, projectId: project.id }, true).length +
@@ -364,9 +387,18 @@ export function queueIfNeeded(req: QueueRequest): QueuedTaskView | null {
364
387
  throw new Error(`Machine is "any" or "mac"`);
365
388
  // The orchestrator's own starts wait out a loaded machine; a person's
366
389
  // direct start goes ahead, as "start now" always has.
390
+ if (
391
+ req.now &&
392
+ !req.planOnly &&
393
+ needsUnmetHere(project.id, req.prompt, setting)
394
+ )
395
+ throw new Error(
396
+ `Can't start now: this task needs a Mac, and this machine isn't one. Queue it without now.`
397
+ );
367
398
  const loaded =
368
399
  !waits &&
369
400
  !full &&
401
+ !req.now &&
370
402
  !req.planOnly &&
371
403
  !!req.originWorkspaceId &&
372
404
  loadHeldStart(project.id, req.prompt, setting);
@@ -426,7 +458,8 @@ export async function startOrQueue<T>(
426
458
  const queued = queueIfNeeded(req);
427
459
  if (queued) return { queued };
428
460
  const workspaceId = workspaceOf(req.projectId);
429
- if (!workspaceId) return { started: await start() };
461
+ // A plan-only start never holds a slot, as its queue row doesn't.
462
+ if (!workspaceId || req.planOnly) return { started: await start() };
430
463
  reserved.set(workspaceId, (reserved.get(workspaceId) ?? 0) + 1);
431
464
  try {
432
465
  return { started: await start() };
@@ -539,6 +572,33 @@ function autoHold(workspaceId: string | null, planOnly = false): Hold | null {
539
572
  return window ? { code: "window", text: `Waiting: ${window}` } : null;
540
573
  }
541
574
 
575
+ const requestNeeds = (
576
+ projectId: string,
577
+ workingDirectory: string,
578
+ prompt: string,
579
+ setting: Need[] | null
580
+ ): Need[] =>
581
+ setting ??
582
+ needsOf({
583
+ projectId,
584
+ projectPath: expandHome(workingDirectory),
585
+ prompt,
586
+ }).needs;
587
+
588
+ // Whether the task needs something this machine lacks (a Mac).
589
+ function needsUnmetHere(
590
+ projectId: string,
591
+ prompt: string,
592
+ setting: Need[] | null
593
+ ): boolean {
594
+ const project = getProject(projectId);
595
+ if (!project || isRemoteHost(project.host_id)) return false;
596
+ return !meetsNeeds(
597
+ requestNeeds(projectId, project.working_directory, prompt, setting),
598
+ machineCapabilities()
599
+ );
600
+ }
601
+
542
602
  // Whether this machine's load rules out starting a task for this project now.
543
603
  function loadHeldStart(
544
604
  projectId: string,
@@ -547,13 +607,12 @@ function loadHeldStart(
547
607
  ): boolean {
548
608
  const project = getProject(projectId);
549
609
  if (!project || isRemoteHost(project.host_id)) return false;
550
- const needs =
551
- setting ??
552
- needsOf({
553
- projectId,
554
- projectPath: expandHome(project.working_directory),
555
- prompt,
556
- }).needs;
610
+ const needs = requestNeeds(
611
+ projectId,
612
+ project.working_directory,
613
+ prompt,
614
+ setting
615
+ );
557
616
  const hold = machineHold(
558
617
  {
559
618
  heavy: heavyNeeds(needs),
@@ -593,6 +652,8 @@ interface StartOpts {
593
652
  // Start on this linked machine instead of the project's.
594
653
  placement?: Placement | null;
595
654
  heavy?: boolean;
655
+ // Pass this workspace's brakes even for a row no orchestrator queued.
656
+ brakeAs?: string;
596
657
  }
597
658
 
598
659
  async function startRow(
@@ -683,9 +744,10 @@ async function startRow(
683
744
  try {
684
745
  // The orchestrator's queued starts pass its brakes when they start,
685
746
  // a person's "start now" included: it skips only the limit and `after`.
686
- const session = origin
747
+ const brakeWs = origin ?? opts.brakeAs;
748
+ const session = brakeWs
687
749
  ? await braked(
688
- origin,
750
+ brakeWs,
689
751
  "task",
690
752
  () => createTask(task),
691
753
  (s) => s.id,
@@ -1090,6 +1152,67 @@ export async function startQueuedNow(id: string): Promise<StartOutcome> {
1090
1152
  return startRow(r, { force: true });
1091
1153
  }
1092
1154
 
1155
+ export interface ForcedStart {
1156
+ outcome: "started" | "held" | "failed";
1157
+ // Why it is held, or what failed; empty when it started.
1158
+ text: string;
1159
+ // The running-task limit was full when it started.
1160
+ wasFull: boolean;
1161
+ running: number;
1162
+ limit: number | null;
1163
+ // It skipped an `after`.
1164
+ skippedAfter: boolean;
1165
+ }
1166
+
1167
+ // The orchestrator's start now: past the limit and `after`, never past
1168
+ // Pause, its brakes or a machine need. A held task stays queued.
1169
+ export async function startQueuedForOrchestrator(
1170
+ workspaceId: string,
1171
+ id: string
1172
+ ): Promise<ForcedStart> {
1173
+ const r = row(id);
1174
+ const project = r ? getProject(r.project_id) : null;
1175
+ if (!r || r.status !== "queued" || project?.workspace_id !== workspaceId)
1176
+ throw new Error("That task isn't waiting in the queue");
1177
+ const slots = slotState(r.project_id);
1178
+ const base = {
1179
+ wasFull: slots.full,
1180
+ running: slots.running,
1181
+ limit: slots.limit,
1182
+ skippedAfter: !!(r.after_task || r.after_any),
1183
+ };
1184
+ const held = (text: string): ForcedStart => ({
1185
+ ...base,
1186
+ outcome: "held",
1187
+ text,
1188
+ });
1189
+ if (isPaused(workspaceId)) return held("the orchestrator is paused by Saad");
1190
+ if (isRemoteHost(r.host_id ?? project.host_id))
1191
+ return held(
1192
+ "it runs on another machine, and the orchestrator starts tasks on this one only"
1193
+ );
1194
+ if (
1195
+ !meetsNeeds(
1196
+ parseNeeds(r.machine_needs) ??
1197
+ requestNeeds(project.id, project.working_directory, r.prompt, null),
1198
+ machineCapabilities()
1199
+ )
1200
+ )
1201
+ return held("it needs a Mac, and this machine isn't one");
1202
+ try {
1203
+ const outcome = await startRow(r, { force: true, brakeAs: workspaceId });
1204
+ if (outcome === "started") return { ...base, outcome, text: "" };
1205
+ return held("it could not be claimed just now; try again");
1206
+ } catch (error) {
1207
+ if (error instanceof BrakeRefused) return held(error.reason);
1208
+ return {
1209
+ ...base,
1210
+ outcome: "failed",
1211
+ text: row(id)?.error ?? String(error),
1212
+ };
1213
+ }
1214
+ }
1215
+
1093
1216
  // A task still waiting in the line (or failed to start) by id, an id prefix
1094
1217
  // of 4+ characters or, when asked, its name; in one workspace.
1095
1218
  export function findQueued(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.582",
3
+ "version": "0.3.585",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"