@saadnvd1/agent-os 0.3.584 → 0.3.587

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 -0
  2. package/app/api/bookmarks/[id]/route.ts +35 -0
  3. package/app/api/bookmarks/route.test.ts +103 -0
  4. package/app/api/bookmarks/route.ts +18 -0
  5. package/app/api/sessions/[id]/bookmarks/route.ts +38 -0
  6. package/app/api/tasks/[id]/merge/route.ts +5 -1
  7. package/app/globals.css +1 -0
  8. package/app/page.tsx +16 -0
  9. package/components/Bookmarks/BookmarkButton.tsx +149 -0
  10. package/components/Bookmarks/BookmarkRow.tsx +161 -0
  11. package/components/Bookmarks/BookmarksDialog.tsx +186 -0
  12. package/components/Bookmarks/ChatBookmarks.tsx +58 -0
  13. package/components/Bookmarks/ChatBookmarksButton.tsx +38 -0
  14. package/components/Bookmarks/EmptyBookmarks.tsx +20 -0
  15. package/components/Bookmarks/context.ts +41 -0
  16. package/components/Bookmarks/helpers.test.ts +69 -0
  17. package/components/Bookmarks/helpers.ts +65 -0
  18. package/components/Bookmarks/index.ts +4 -0
  19. package/components/Bookmarks/useBookmarkToggle.ts +51 -0
  20. package/components/Chat/ChatPanel.tsx +73 -32
  21. package/components/Chat/Messages.test.ts +14 -9
  22. package/components/Chat/Messages.tsx +59 -43
  23. package/components/Chat/Tools.tsx +3 -1
  24. package/components/Chat/useLongPress.ts +43 -0
  25. package/components/CommandPalette/useAppCommands.ts +10 -0
  26. package/components/Pane/DesktopTabBar.tsx +10 -0
  27. package/components/Pane/MobileTabBar.tsx +10 -0
  28. package/components/Pane/index.tsx +8 -0
  29. package/components/SessionList/SidebarHeader.tsx +5 -0
  30. package/data/bookmarks/index.ts +2 -0
  31. package/data/bookmarks/keys.ts +7 -0
  32. package/data/bookmarks/queries.ts +146 -0
  33. package/data/chat/useChat.ts +4 -0
  34. package/data/push/topics.ts +3 -0
  35. package/lib/bookmarks/body.ts +23 -0
  36. package/lib/bookmarks/snapshot.ts +33 -0
  37. package/lib/bookmarks/store.ts +333 -0
  38. package/lib/bookmarks/types.ts +38 -0
  39. package/lib/db/changes.ts +3 -0
  40. package/lib/db/migrations.ts +28 -0
  41. package/lib/orchestrator/act.ts +47 -1
  42. package/lib/orchestrator/brief.ts +2 -1
  43. package/lib/orchestrator/serve.ts +8 -0
  44. package/lib/orchestrator/tool-names.ts +1 -0
  45. package/lib/orchestrator/tool-schemas.ts +22 -0
  46. package/lib/orchestrator/tools.ts +3 -1
  47. package/lib/tasks/finish.ts +26 -4
  48. package/lib/tasks/move-bundle.ts +28 -0
  49. package/lib/tasks/move-chat.ts +10 -1
  50. package/lib/tasks/queue.ts +133 -10
  51. package/lib/tasks/remote.ts +21 -3
  52. package/package.json +1 -1
  53. package/stores/bookmarksUi.ts +50 -0
  54. package/styles/themes.css +3 -0
@@ -0,0 +1,333 @@
1
+ /**
2
+ * Bookmarks: a person's "come back to this" marks on single chat items,
3
+ * keyed by the item's stable id. Each keeps a redacted snapshot of what it
4
+ * marks, so it can be searched and still reads after the chat is gone.
5
+ */
6
+
7
+ import { randomUUID } from "crypto";
8
+ import { db, type Session } from "../db";
9
+ import type { ChatItem } from "../chat/events";
10
+ import { getItem } from "../chat/store";
11
+ import { getProject } from "../projects";
12
+ import { snapshotText } from "./snapshot";
13
+ import {
14
+ BOOKMARKABLE_KINDS,
15
+ MAX_NOTE_CHARS,
16
+ type Bookmark,
17
+ type BookmarkKind,
18
+ type BookmarkScope,
19
+ type BookmarksPage,
20
+ } from "./types";
21
+
22
+ // An orchestrator's own workspace, or its project's (as sessionWorkspace in
23
+ // lib/schedules/store, without pulling the scheduler into a move).
24
+ const sessionWorkspace = (s: Session): string | null =>
25
+ s.workspace_id ??
26
+ (s.project_id ? (getProject(s.project_id)?.workspace_id ?? null) : null);
27
+
28
+ /** A refusal the routes turn into its status. */
29
+ export class BookmarkError extends Error {
30
+ constructor(
31
+ message: string,
32
+ readonly status: 400 | 404
33
+ ) {
34
+ super(message);
35
+ }
36
+ }
37
+
38
+ interface Row {
39
+ id: string;
40
+ session_id: string;
41
+ item_id: string;
42
+ scope: BookmarkScope;
43
+ workspace_id: string | null;
44
+ kind: BookmarkKind;
45
+ text: string;
46
+ session_name: string;
47
+ note: string | null;
48
+ created_at: string;
49
+ }
50
+
51
+ type Joined = Row & {
52
+ s_id: string | null;
53
+ task_status: string | null;
54
+ archived_at: string | null;
55
+ };
56
+
57
+ // The bookmark with its chat's state as of now.
58
+ const SELECT = `
59
+ SELECT b.*, s.id AS s_id, s.task_status, s.archived_at
60
+ FROM chat_bookmarks b LEFT JOIN sessions s ON s.id = b.session_id`;
61
+
62
+ function toBookmark(r: Joined): Bookmark {
63
+ return {
64
+ id: r.id,
65
+ sessionId: r.session_id,
66
+ itemId: r.item_id,
67
+ scope: r.scope,
68
+ kind: r.kind,
69
+ text: r.text,
70
+ note: r.note,
71
+ sessionName: r.session_name,
72
+ workspaceId: r.workspace_id,
73
+ createdAt: r.created_at,
74
+ state: !r.s_id
75
+ ? "deleted"
76
+ : r.task_status === "moved"
77
+ ? "moved"
78
+ : r.archived_at
79
+ ? "archived"
80
+ : "live",
81
+ };
82
+ }
83
+
84
+ export function getBookmark(id: string): Bookmark | null {
85
+ const r = db.prepare(`${SELECT} WHERE b.id = ?`).get(id) as
86
+ | Joined
87
+ | undefined;
88
+ return r ? toBookmark(r) : null;
89
+ }
90
+
91
+ const cleanNote = (note: unknown): string | null => {
92
+ if (note === null || note === undefined) return null;
93
+ if (typeof note !== "string") throw new BookmarkError("Bad note", 400);
94
+ const t = note.replace(/\s+/g, " ").trim().slice(0, MAX_NOTE_CHARS);
95
+ return t || null;
96
+ };
97
+
98
+ // The first reply in the turn a user item starts, for its excerpt.
99
+ function firstReply(
100
+ sessionId: string,
101
+ userItemId: string
102
+ ): ChatItem | undefined {
103
+ const rows = db
104
+ .prepare(
105
+ `SELECT data FROM chat_items
106
+ WHERE session_id = ? AND seq > (
107
+ SELECT seq FROM chat_items WHERE session_id = ? AND item_id = ?)
108
+ ORDER BY seq LIMIT 200`
109
+ )
110
+ .all(sessionId, sessionId, userItemId) as { data: string }[];
111
+ for (const r of rows) {
112
+ let item: ChatItem | null;
113
+ try {
114
+ item = JSON.parse(r.data) as ChatItem | null;
115
+ } catch {
116
+ item = null;
117
+ }
118
+ // A row that isn't an item just isn't the reply.
119
+ if (!item || typeof item !== "object") continue;
120
+ if (item.kind === "user" || item.kind === "turn_end") return undefined;
121
+ if (item.kind === "assistant" && item.text.trim()) return item;
122
+ }
123
+ return undefined;
124
+ }
125
+
126
+ /**
127
+ * Marks an item. Idempotent: the same item and scope again returns the
128
+ * bookmark it already has, with `created: false`.
129
+ */
130
+ export function createBookmark(input: {
131
+ sessionId: string;
132
+ itemId?: unknown;
133
+ scope?: unknown;
134
+ note?: unknown;
135
+ }): { bookmark: Bookmark; created: boolean } {
136
+ const { sessionId } = input;
137
+ if (typeof input.itemId !== "string" || !input.itemId)
138
+ throw new BookmarkError("itemId is required", 400);
139
+ const scope = input.scope ?? "item";
140
+ if (scope !== "item" && scope !== "turn")
141
+ throw new BookmarkError("scope must be 'item' or 'turn'", 400);
142
+ const note = cleanNote(input.note);
143
+ const session = db
144
+ .prepare(`SELECT * FROM sessions WHERE id = ?`)
145
+ .get(sessionId) as Session | undefined;
146
+ if (!session) throw new BookmarkError("Session not found", 404);
147
+ const existing = db
148
+ .prepare(
149
+ `SELECT id FROM chat_bookmarks WHERE session_id = ? AND item_id = ? AND scope = ?`
150
+ )
151
+ .get(sessionId, input.itemId, scope) as { id: string } | undefined;
152
+ if (existing) return { bookmark: getBookmark(existing.id)!, created: false };
153
+ const item = getItem(sessionId, input.itemId);
154
+ if (!item) throw new BookmarkError("Message not found", 404);
155
+ if (!(BOOKMARKABLE_KINDS as readonly string[]).includes(item.kind))
156
+ throw new BookmarkError("That can't be bookmarked", 400);
157
+ if (scope === "turn" && item.kind !== "user")
158
+ throw new BookmarkError("A turn is bookmarked by its first message", 400);
159
+ const kind = (scope === "turn" ? "turn" : item.kind) as BookmarkKind;
160
+ const text = snapshotText(
161
+ item,
162
+ scope === "turn" ? firstReply(sessionId, item.id) : undefined
163
+ );
164
+ const id = randomUUID();
165
+ db.prepare(
166
+ `INSERT INTO chat_bookmarks
167
+ (id, session_id, item_id, scope, workspace_id, kind, text, session_name, note)
168
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
169
+ ON CONFLICT (session_id, item_id, scope) DO NOTHING`
170
+ ).run(
171
+ id,
172
+ sessionId,
173
+ item.id,
174
+ scope,
175
+ sessionWorkspace(session),
176
+ kind,
177
+ text,
178
+ session.name,
179
+ note
180
+ );
181
+ // A request that raced this one may have made it first.
182
+ const made = db
183
+ .prepare(
184
+ `SELECT id FROM chat_bookmarks WHERE session_id = ? AND item_id = ? AND scope = ?`
185
+ )
186
+ .get(sessionId, item.id, scope) as { id: string };
187
+ return { bookmark: getBookmark(made.id)!, created: made.id === id };
188
+ }
189
+
190
+ /** One chat's bookmarks, in the order they appear in it. */
191
+ export function listSessionBookmarks(sessionId: string): Bookmark[] {
192
+ return (
193
+ db
194
+ .prepare(
195
+ `${SELECT}
196
+ LEFT JOIN chat_items c ON c.session_id = b.session_id AND c.item_id = b.item_id
197
+ WHERE b.session_id = ?
198
+ ORDER BY c.seq IS NULL, c.seq, b.created_at, b.id`
199
+ )
200
+ .all(sessionId) as Joined[]
201
+ ).map(toBookmark);
202
+ }
203
+
204
+ const escapeLike = (s: string) => s.replace(/[\\%_]/g, (c) => `\\${c}`);
205
+
206
+ const cursorOf = (b: Bookmark) => `${b.createdAt}|${b.id}`;
207
+
208
+ /**
209
+ * Bookmarks across chats, newest first: one workspace's (null: those with
210
+ * none) or every one's, matching `q` in the text, the note or the chat's
211
+ * name. The copy a move left behind isn't listed: its live twin is.
212
+ */
213
+ export function listBookmarks(opts: {
214
+ workspace: string | null | "all";
215
+ q?: string;
216
+ before?: string | null;
217
+ limit?: number;
218
+ }): BookmarksPage {
219
+ const limit = Math.min(Math.max(opts.limit ?? 50, 1), 200);
220
+ const where = [`(s.task_status IS NULL OR s.task_status != 'moved')`];
221
+ const args: (string | number)[] = [];
222
+ if (opts.workspace === null) where.push(`b.workspace_id IS NULL`);
223
+ else if (opts.workspace !== "all") {
224
+ where.push(`b.workspace_id = ?`);
225
+ args.push(opts.workspace);
226
+ }
227
+ const q = opts.q?.trim();
228
+ if (q) {
229
+ const like = `%${escapeLike(q)}%`;
230
+ where.push(
231
+ `(b.text LIKE ? ESCAPE '\\' OR b.note LIKE ? ESCAPE '\\' OR b.session_name LIKE ? ESCAPE '\\')`
232
+ );
233
+ args.push(like, like, like);
234
+ }
235
+ if (opts.before) {
236
+ const at = opts.before.lastIndexOf("|");
237
+ if (at > 0) {
238
+ where.push(`(b.created_at < ? OR (b.created_at = ? AND b.id < ?))`);
239
+ const created = opts.before.slice(0, at);
240
+ args.push(created, created, opts.before.slice(at + 1));
241
+ }
242
+ }
243
+ const rows = (
244
+ db
245
+ .prepare(
246
+ `${SELECT} WHERE ${where.join(" AND ")}
247
+ ORDER BY b.created_at DESC, b.id DESC LIMIT ?`
248
+ )
249
+ .all(...args, limit + 1) as Joined[]
250
+ ).map(toBookmark);
251
+ const more = rows.length > limit;
252
+ const bookmarks = more ? rows.slice(0, limit) : rows;
253
+ return {
254
+ bookmarks,
255
+ next: more ? cursorOf(bookmarks[bookmarks.length - 1]) : null,
256
+ };
257
+ }
258
+
259
+ export function setBookmarkNote(id: string, note: unknown): Bookmark | null {
260
+ const clean = cleanNote(note);
261
+ const changed = db
262
+ .prepare(`UPDATE chat_bookmarks SET note = ? WHERE id = ?`)
263
+ .run(clean, id).changes;
264
+ return changed ? getBookmark(id) : null;
265
+ }
266
+
267
+ export function removeBookmark(id: string): boolean {
268
+ return (
269
+ db.prepare(`DELETE FROM chat_bookmarks WHERE id = ?`).run(id).changes > 0
270
+ );
271
+ }
272
+
273
+ /** What a move carries: the chat's bookmarks, as stored. */
274
+ export interface MovedBookmark {
275
+ itemId: string;
276
+ scope: BookmarkScope;
277
+ kind: BookmarkKind;
278
+ text: string;
279
+ note: string | null;
280
+ createdAt: string;
281
+ }
282
+
283
+ export function bookmarksForMove(sessionId: string): MovedBookmark[] {
284
+ return (
285
+ db
286
+ .prepare(
287
+ `SELECT item_id, scope, kind, text, note, created_at
288
+ FROM chat_bookmarks WHERE session_id = ? ORDER BY created_at, id`
289
+ )
290
+ .all(sessionId) as Row[]
291
+ ).map((r) => ({
292
+ itemId: r.item_id,
293
+ scope: r.scope,
294
+ kind: r.kind,
295
+ text: r.text,
296
+ note: r.note,
297
+ createdAt: r.created_at,
298
+ }));
299
+ }
300
+
301
+ /**
302
+ * Writes a moved chat's bookmarks under the row it arrived as, scoped to
303
+ * that row's workspace here. Run inside the transaction that makes the row.
304
+ */
305
+ export function insertMovedBookmarks(
306
+ sessionId: string,
307
+ rows: MovedBookmark[]
308
+ ): void {
309
+ if (!rows.length) return;
310
+ const session = db
311
+ .prepare(`SELECT * FROM sessions WHERE id = ?`)
312
+ .get(sessionId) as Session | undefined;
313
+ if (!session) return;
314
+ const workspace = sessionWorkspace(session);
315
+ const insert = db.prepare(
316
+ `INSERT OR IGNORE INTO chat_bookmarks
317
+ (id, session_id, item_id, scope, workspace_id, kind, text, session_name, note, created_at)
318
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
319
+ );
320
+ for (const r of rows)
321
+ insert.run(
322
+ randomUUID(),
323
+ sessionId,
324
+ r.itemId,
325
+ r.scope,
326
+ workspace,
327
+ r.kind,
328
+ r.text,
329
+ session.name,
330
+ r.note,
331
+ r.createdAt
332
+ );
333
+ }
@@ -0,0 +1,38 @@
1
+ // A person's mark on one chat item, shared by the routes, the web app and
2
+ // the native app.
3
+
4
+ export type BookmarkScope = "item" | "turn";
5
+ export type BookmarkKind = "user" | "assistant" | "tool" | "turn";
6
+ // live: the chat is there; archived: it is, archived; moved: this row is the
7
+ // copy left behind by a move; deleted: the chat is gone, the snapshot stays.
8
+ export type BookmarkState = "live" | "archived" | "moved" | "deleted";
9
+
10
+ export interface Bookmark {
11
+ id: string;
12
+ sessionId: string;
13
+ itemId: string;
14
+ scope: BookmarkScope;
15
+ kind: BookmarkKind;
16
+ text: string;
17
+ note: string | null;
18
+ sessionName: string;
19
+ workspaceId: string | null;
20
+ createdAt: string;
21
+ state: BookmarkState;
22
+ }
23
+
24
+ export interface BookmarksPage {
25
+ bookmarks: Bookmark[];
26
+ // Pass as `before` for the next page; null on the last one.
27
+ next: string | null;
28
+ }
29
+
30
+ export const BOOKMARKABLE_KINDS = ["user", "assistant", "tool"] as const;
31
+
32
+ export const MAX_NOTE_CHARS = 500;
33
+ // A snapshot is clipped to this, plus the ellipsis that marks the cut.
34
+ export const MAX_SNAPSHOT_CHARS = 4_000;
35
+
36
+ // The key the stars look a bookmark up by.
37
+ export const bookmarkKey = (itemId: string, scope: BookmarkScope) =>
38
+ `${itemId}:${scope}`;
package/lib/db/changes.ts CHANGED
@@ -41,6 +41,8 @@ export const TABLES_WATCHED_80 = [
41
41
  "feature_pieces",
42
42
  ] as const;
43
43
 
44
+ export const TABLES_WATCHED_CHAT_BOOKMARKS = ["chat_bookmarks"] as const;
45
+
44
46
  // Every table any migration watches, for the browser's topic map to cover.
45
47
  export const WATCHED_TABLES: readonly string[] = [
46
48
  ...TABLES_WATCHED_45,
@@ -49,6 +51,7 @@ export const WATCHED_TABLES: readonly string[] = [
49
51
  ...TABLES_WATCHED_58,
50
52
  ...TABLES_WATCHED_78,
51
53
  ...TABLES_WATCHED_80,
54
+ ...TABLES_WATCHED_CHAT_BOOKMARKS,
52
55
  ];
53
56
 
54
57
  export function installChangeTriggers(
@@ -6,6 +6,7 @@ import {
6
6
  TABLES_WATCHED_58,
7
7
  TABLES_WATCHED_78,
8
8
  TABLES_WATCHED_80,
9
+ TABLES_WATCHED_CHAT_BOOKMARKS,
9
10
  } from "./changes";
10
11
  import type Database from "better-sqlite3";
11
12
  import { moveSchedulesToRecipes } from "./schedules-move";
@@ -2297,6 +2298,33 @@ export const migrations: Migration[] = [
2297
2298
  `);
2298
2299
  },
2299
2300
  },
2301
+ {
2302
+ key: "20261014T2000_chat_bookmarks",
2303
+ up: (db) => {
2304
+ // A person's marks on single chat items. No FK to sessions: a
2305
+ // bookmark outlives a deleted chat with its own snapshot. Additive.
2306
+ db.exec(`
2307
+ CREATE TABLE IF NOT EXISTS chat_bookmarks (
2308
+ id TEXT PRIMARY KEY,
2309
+ session_id TEXT NOT NULL,
2310
+ item_id TEXT NOT NULL,
2311
+ scope TEXT NOT NULL DEFAULT 'item',
2312
+ workspace_id TEXT,
2313
+ kind TEXT NOT NULL,
2314
+ text TEXT NOT NULL,
2315
+ session_name TEXT NOT NULL,
2316
+ note TEXT,
2317
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
2318
+ UNIQUE (session_id, item_id, scope)
2319
+ )
2320
+ `);
2321
+ db.exec(
2322
+ `CREATE INDEX IF NOT EXISTS idx_chat_bookmarks_ws
2323
+ ON chat_bookmarks(workspace_id, created_at DESC)`
2324
+ );
2325
+ installChangeTriggers(db, TABLES_WATCHED_CHAT_BOOKMARKS);
2326
+ },
2327
+ },
2300
2328
  ];
2301
2329
 
2302
2330
  // `upTo`: stop after this legacy id or timestamp key, inclusive (tests that
@@ -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: