@saadnvd1/agent-os 0.3.597 → 0.3.600

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 (45) hide show
  1. package/app/api/bus/spawn/route.ts +35 -16
  2. package/app/api/lumifyhub/cards/[cardId]/run/route.ts +3 -0
  3. package/app/api/tasks/route.ts +3 -0
  4. package/app/page.tsx +2 -1
  5. package/components/SessionList/SessionRow.tsx +2 -1
  6. package/lib/agents/spawn.ts +16 -4
  7. package/lib/chat/refresh.ts +2 -1
  8. package/lib/chat/runner.ts +29 -0
  9. package/lib/chat/store.ts +53 -0
  10. package/lib/db/migrations/20261014T2110_task_start_record.ts +21 -0
  11. package/lib/db/migrations/20261014T2200_end_orphaned_tasks.ts +23 -0
  12. package/lib/db/types.ts +2 -0
  13. package/lib/learning-cards/apply.ts +7 -1
  14. package/lib/models/plan-step.ts +25 -8
  15. package/lib/models/resolve.ts +1 -1
  16. package/lib/models/resume.ts +1 -1
  17. package/lib/models/roles.ts +0 -16
  18. package/lib/models/session-role.ts +19 -0
  19. package/lib/orchestrator/act.ts +22 -7
  20. package/lib/orchestrator/amendments.ts +10 -3
  21. package/lib/orchestrator/serve.ts +1 -0
  22. package/lib/orchestrator/testing.ts +8 -0
  23. package/lib/orchestrator/tool-schemas.ts +9 -0
  24. package/lib/orchestrator/tools.ts +1 -1
  25. package/lib/plans/claim.ts +131 -0
  26. package/lib/plans/release.ts +5 -2
  27. package/lib/recipes/run.ts +6 -0
  28. package/lib/schedules/start.ts +1 -0
  29. package/lib/sessions/fork.ts +10 -0
  30. package/lib/sessions/launch.ts +5 -1
  31. package/lib/stacks/start.ts +1 -0
  32. package/lib/tasks/held-remote.ts +4 -2
  33. package/lib/tasks/held-replies.ts +5 -2
  34. package/lib/tasks/held.ts +2 -1
  35. package/lib/tasks/index.ts +85 -46
  36. package/lib/tasks/move-carry.ts +12 -1
  37. package/lib/tasks/move-guard.ts +2 -1
  38. package/lib/tasks/move.ts +7 -5
  39. package/lib/tasks/pause.ts +1 -1
  40. package/lib/tasks/queue.ts +54 -31
  41. package/lib/tasks/remote.ts +3 -1
  42. package/lib/tasks/starter.ts +124 -0
  43. package/lib/usage/calls.ts +2 -1
  44. package/lib/usage/turns.ts +2 -1
  45. package/package.json +1 -1
@@ -5,17 +5,14 @@ import { startOrQueue, stampPriority } from "@/lib/tasks/queue";
5
5
  import { isPriority } from "@/lib/tasks/schedule-types";
6
6
  import { hostIdNamed } from "@/lib/hosts";
7
7
  import { startView } from "@/lib/sessions/launch";
8
- import { db } from "@/lib/db";
9
-
10
- // The calling session, when it's an orchestrator: what it starts runs on the
11
- // implementation model. A made-up `from` can only pick that cheaper role.
12
- function orchestratorFrom(from: unknown): string | null {
13
- if (typeof from !== "string" || !from) return null;
14
- const row = db
15
- .prepare(`SELECT 1 FROM sessions WHERE id = ? AND role = 'orchestrator'`)
16
- .get(from);
17
- return row ? from : null;
18
- }
8
+ import { callerSession, starterOf } from "@/lib/tasks/starter";
9
+ import { callingDevice } from "@/lib/security/device-me";
10
+ import {
11
+ findPiece,
12
+ pieceLabel,
13
+ pieceRefusal,
14
+ waitingPiecesFor,
15
+ } from "@/lib/plans/claim";
19
16
 
20
17
  // mode "session" starts an interactive agent; "task" starts one that ends in
21
18
  // a PR, or queues it (over the workspace's limit, or with `after`).
@@ -34,8 +31,15 @@ export async function POST(request: NextRequest) {
34
31
  priority,
35
32
  machine,
36
33
  from,
34
+ cwd,
35
+ piece,
37
36
  } = await request.json();
38
- const startedBy = orchestratorFrom(from);
37
+ // Whoever called: a session (by `from`, else the worktree it runs in) or a person.
38
+ const starter = starterOf(
39
+ callerSession({ from, cwd }),
40
+ callingDevice(request)
41
+ );
42
+ const { startedBy } = starter;
39
43
  const given = typeof name === "string" ? name : undefined;
40
44
  if (after !== undefined && mode !== "task")
41
45
  throw new Error("--after works for tasks only");
@@ -47,16 +51,29 @@ export async function POST(request: NextRequest) {
47
51
  throw new Error("--priority is urgent, blocking, normal or background");
48
52
  if (machine !== undefined && machine !== "mac" && machine !== "any")
49
53
  throw new Error('machine is "mac" or "any"');
54
+ if (piece !== undefined && mode !== "task")
55
+ throw new Error("--piece works for tasks only");
50
56
  if (mode === "task") {
57
+ const projectId = findProject(String(project ?? "")).id;
58
+ // A task whose plan has pieces waiting here must say which it builds.
59
+ const waiting = startedBy ? waitingPiecesFor(startedBy, projectId) : [];
60
+ let pieceId: number | undefined;
61
+ if (piece !== undefined) {
62
+ if (typeof piece !== "string" || !piece.trim())
63
+ throw new Error("--piece needs a piece name");
64
+ pieceId = findPiece(waiting, piece).id;
65
+ } else if (waiting.length)
66
+ throw new Error(pieceRefusal(waiting, projectId));
51
67
  const task = {
52
- projectId: findProject(String(project ?? "")).id,
68
+ projectId,
69
+ ...starter,
70
+ pieceId,
53
71
  prompt: String(prompt ?? ""),
54
72
  name: given,
55
73
  model,
56
74
  hostId: on ? hostIdNamed(String(on)) : undefined,
57
75
  view: startView(view),
58
76
  planOnly: planOnly === true ? true : undefined,
59
- startedBy,
60
77
  };
61
78
  const out = await startOrQueue(
62
79
  {
@@ -71,9 +88,10 @@ export async function POST(request: NextRequest) {
71
88
  return session;
72
89
  }
73
90
  );
91
+ const builds = pieceId ? pieceLabel(pieceId) : undefined;
74
92
  return "queued" in out
75
- ? NextResponse.json({ queued: out.queued }, { status: 202 })
76
- : NextResponse.json({ session: out.started }, { status: 201 });
93
+ ? NextResponse.json({ queued: out.queued, builds }, { status: 202 })
94
+ : NextResponse.json({ session: out.started, builds }, { status: 201 });
77
95
  }
78
96
  const session = await spawnSession({
79
97
  project: String(project ?? ""),
@@ -81,6 +99,7 @@ export async function POST(request: NextRequest) {
81
99
  name: given,
82
100
  model,
83
101
  startedBy,
102
+ parentSessionId: starter.parentSessionId,
84
103
  });
85
104
  return NextResponse.json({ session }, { status: 201 });
86
105
  } catch (error) {
@@ -3,6 +3,7 @@ import { body, respond } from "@/lib/lumifyhub/http";
3
3
  import { lumifyHubId } from "@/lib/lumifyhub/ids";
4
4
  import { cardForTask, promptFromCard } from "@/lib/lumifyhub/task-cards";
5
5
  import { createTask } from "@/lib/tasks";
6
+ import { callingDevice } from "@/lib/security/device-me";
6
7
 
7
8
  type RouteParams = { params: Promise<{ cardId: string }> };
8
9
 
@@ -20,6 +21,8 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
20
21
  prompt: promptFromCard(card),
21
22
  name: card.title,
22
23
  cardId: card.id,
24
+ startedVia: "person",
25
+ startedByDevice: callingDevice(request) ?? undefined,
23
26
  });
24
27
  return { session };
25
28
  });
@@ -85,6 +85,8 @@ export async function POST(request: NextRequest) {
85
85
  hostId: typeof hostId === "string" && hostId ? hostId : undefined,
86
86
  baseBranch: typeof baseBranch === "string" ? baseBranch : undefined,
87
87
  view: startView(view),
88
+ startedVia: "person" as const,
89
+ startedByDevice: callingDevice(request) ?? undefined,
88
90
  };
89
91
  if (id !== undefined) {
90
92
  // An orchestrator's task is held for the paired device that sent it,
@@ -105,6 +107,7 @@ export async function POST(request: NextRequest) {
105
107
  id,
106
108
  ...opts,
107
109
  held,
110
+ startedVia: "remote",
108
111
  startedByDevice: device ?? undefined,
109
112
  });
110
113
  return NextResponse.json({ session }, { status: 201 });
package/app/page.tsx CHANGED
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
 
3
+ import { isFork } from "@/lib/sessions/fork";
3
4
  import { toast } from "sonner";
4
5
 
5
6
  import { subscribe } from "valtio";
@@ -299,7 +300,7 @@ function HomeContent() {
299
300
 
300
301
  // Get parent session ID for forking
301
302
  let parentSessionId: string | null = null;
302
- if (!session.claude_session_id && session.parent_session_id) {
303
+ if (!session.claude_session_id && isFork(session)) {
303
304
  const parentSession = sessions.find(
304
305
  (s) => s.id === session.parent_session_id
305
306
  );
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
 
3
+ import { isFork } from "@/lib/sessions/fork";
3
4
  import { memo, useState } from "react";
4
5
  import { useSnapshot } from "valtio";
5
6
  import {
@@ -99,7 +100,7 @@ export const SessionRow = memo(function SessionRow({
99
100
  />
100
101
  </span>
101
102
  )}
102
- {session.parent_session_id && (
103
+ {isFork(session) && (
103
104
  <GitFork className="text-muted-foreground/60 h-3 w-3 shrink-0" />
104
105
  )}
105
106
  </>
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from "crypto";
2
+ import { orchestratorOf, recordStart } from "../tasks/starter";
2
3
  import os from "os";
3
4
  import { db, queries, type Session } from "../db";
4
5
  import { getProject, getAllProjects } from "../projects";
@@ -35,6 +36,7 @@ export async function spawnSession(opts: {
35
36
  // The orchestrator session that starts it: the implementation role's
36
37
  // model unless `model` is given.
37
38
  startedBy?: string | null;
39
+ parentSessionId?: string | null;
38
40
  }): Promise<Session> {
39
41
  const prompt = opts.prompt.trim();
40
42
  if (!prompt) throw new Error("Give the new session a prompt");
@@ -46,7 +48,7 @@ export async function spawnSession(opts: {
46
48
 
47
49
  const id = randomUUID();
48
50
  const tmuxName = `claude-${id}`;
49
- const routed = opts.startedBy
51
+ const routed = orchestratorOf(opts.startedBy)
50
52
  ? resolveRole("implement", {
51
53
  workspaceId: project.workspace_id,
52
54
  override: parseOverride(opts.model),
@@ -76,9 +78,19 @@ export async function spawnSession(opts: {
76
78
  project.id,
77
79
  "local"
78
80
  );
79
- db.prepare(
80
- `UPDATE sessions SET name_source = ?, started_by = ? WHERE id = ?`
81
- ).run(naming.source, opts.startedBy ?? null, id);
81
+ db.prepare(`UPDATE sessions SET name_source = ? WHERE id = ?`).run(
82
+ naming.source,
83
+ id
84
+ );
85
+ recordStart(id, {
86
+ startedBy: opts.startedBy ?? null,
87
+ parentSessionId: opts.parentSessionId ?? opts.startedBy ?? null,
88
+ startedVia: orchestratorOf(opts.startedBy)
89
+ ? "orchestrator"
90
+ : opts.startedBy
91
+ ? "session"
92
+ : "person",
93
+ });
82
94
  if (routed) setSessionModel(id, routed);
83
95
  void naming.refine?.(id);
84
96
  await launchClaude({ sessionId: id, tmuxName, cwd, model, prompt });
@@ -13,7 +13,8 @@
13
13
 
14
14
  import { db } from "../db";
15
15
  import { effectiveModels } from "../models/config";
16
- import { sessionRole, type ModelRole } from "../models/roles";
16
+ import { type ModelRole } from "../models/roles";
17
+ import { sessionRole } from "../models/session-role";
17
18
  import { MAX_REFRESH_TOKENS, MIN_REFRESH_TOKENS } from "./refresh-limits";
18
19
 
19
20
  export const ORCHESTRATOR_KEY = "orchestrator.refresh_tokens";
@@ -41,10 +41,13 @@ import {
41
41
  setChatPlan,
42
42
  } from "./settings";
43
43
  import {
44
+ endOrphanedTasks,
45
+ endRunningTasks,
44
46
  getItem,
45
47
  hasItem,
46
48
  itemsOfKind,
47
49
  recentTasks,
50
+ sessionsWithRunningTasks,
48
51
  saveItem,
49
52
  settle,
50
53
  } from "./store";
@@ -167,8 +170,26 @@ function planEnv(sessionId: string): PlanHandoffEnv {
167
170
  };
168
171
  }
169
172
 
173
+ // Tell watchers of tasks that just ended, and drop them from the live count.
174
+ function emitEnded(
175
+ sessionId: string,
176
+ live: Live | undefined,
177
+ ended: ReturnType<typeof endRunningTasks>
178
+ ): void {
179
+ for (const item of ended) {
180
+ live?.activity.tasks.delete(item.taskId);
181
+ emit(sessionId, { type: "item", item });
182
+ }
183
+ }
184
+
170
185
  function onWorkerEvent(sessionId: string, live: Live, e: WorkerEvent): void {
171
186
  track(live, e);
187
+ if (
188
+ e.type === "item" &&
189
+ e.item.kind === "tool" &&
190
+ (e.item.status === "stopped" || e.item.status === "error")
191
+ )
192
+ emitEnded(sessionId, live, endOrphanedTasks(sessionId));
172
193
  if (e.type === "item" && e.item.kind === "task")
173
194
  retireIfStale(sessionId, live);
174
195
  if (e.type === "item") {
@@ -297,7 +318,11 @@ async function ensureLive(sessionId: string, spawn = true): Promise<Live> {
297
318
  );
298
319
  },
299
320
  };
321
+ // No worker running: its agent ended, and with it every job it ran.
322
+ const hadWorker = runningWorkers().includes(sessionId);
300
323
  let { client, hello } = await connectWorker(sessionId, spawn, handlers);
324
+ if (!hadWorker) emitEnded(sessionId, undefined, endRunningTasks(sessionId));
325
+ emitEnded(sessionId, undefined, endOrphanedTasks(sessionId));
301
326
  let activity = restoreActivity(sessionId, hello.state);
302
327
  // An idle worker left over from before a redeploy: retire it, and start
303
328
  // a fresh one only when a message needs it. One still running background
@@ -316,6 +341,7 @@ async function ensureLive(sessionId: string, spawn = true): Promise<Live> {
316
341
  { code: RETIRED }
317
342
  );
318
343
  ({ client, hello } = await connectWorker(sessionId, true, handlers));
344
+ emitEnded(sessionId, undefined, endRunningTasks(sessionId));
319
345
  activity = restoreActivity(sessionId, hello.state);
320
346
  }
321
347
  live = {
@@ -965,6 +991,9 @@ export async function reattachChats(): Promise<void> {
965
991
  })
966
992
  )
967
993
  );
994
+ // A task saved as running with no worker behind it has no job either.
995
+ for (const id of sessionsWithRunningTasks())
996
+ if (!live.has(id)) endRunningTasks(id);
968
997
  // Turns cut off while no worker was left to finish them, then queues.
969
998
  // One at a time, and only what was queued recently: a backlog from long
970
999
  // ago isn't sent unasked by a restart (it stays on screen to send).
package/lib/chat/store.ts CHANGED
@@ -235,6 +235,59 @@ export function runningItems<K extends "tool" | "task">(
235
235
  ).map((r) => JSON.parse(r.data));
236
236
  }
237
237
 
238
+ const LOST = "Its agent ended before this finished";
239
+
240
+ function endTasks(
241
+ sessionId: string,
242
+ which: (task: Extract<ChatItem, { kind: "task" }>) => boolean
243
+ ): Extract<ChatItem, { kind: "task" }>[] {
244
+ const ended: Extract<ChatItem, { kind: "task" }>[] = [];
245
+ for (const task of runningItems(sessionId, "task")) {
246
+ if (!which(task)) continue;
247
+ const item = {
248
+ ...task,
249
+ status: "stopped" as const,
250
+ endedAt: Date.now(),
251
+ summary: task.summary ?? LOST,
252
+ };
253
+ saveItem(sessionId, item);
254
+ ended.push(item);
255
+ }
256
+ return ended;
257
+ }
258
+
259
+ // Its agent is gone (a fresh worker, no worker at restart): nothing it ran
260
+ // in the background is still going. Returns what it ended.
261
+ export const endRunningTasks = (sessionId: string) =>
262
+ endTasks(sessionId, () => true);
263
+
264
+ // A task whose tool call was stopped or errored has no job behind it.
265
+ export function endOrphanedTasks(sessionId: string) {
266
+ const dead = new Set(
267
+ runningItems(sessionId, "task").flatMap((t) => {
268
+ const tool = t.toolUseId ? getItem(sessionId, t.toolUseId) : undefined;
269
+ return tool?.kind === "tool" &&
270
+ (tool.status === "stopped" || tool.status === "error")
271
+ ? [t.id]
272
+ : [];
273
+ })
274
+ );
275
+ return endTasks(sessionId, (t) => dead.has(t.id));
276
+ }
277
+
278
+ // Chats with a task saved as running.
279
+ export function sessionsWithRunningTasks(): string[] {
280
+ flushItems();
281
+ return (
282
+ db
283
+ .prepare(
284
+ `SELECT DISTINCT session_id FROM chat_items WHERE kind = 'task'
285
+ AND json_extract(data, '$.status') = 'running'`
286
+ )
287
+ .all() as { session_id: string }[]
288
+ ).map((r) => r.session_id);
289
+ }
290
+
238
291
  // Tool calls saved as still running.
239
292
  export const runningTools = (sessionId: string) =>
240
293
  runningItems(sessionId, "tool");
@@ -0,0 +1,21 @@
1
+ import type { Migration } from "../migration-types";
2
+
3
+ export default {
4
+ key: "20261014T2110_task_start_record",
5
+ up: (db) => {
6
+ // Who started a task and how, on the session and its queue row; an
7
+ // approval kind on brief entries. Additive.
8
+ const add = (table: string, col: string, type: string) => {
9
+ const columns = db.prepare(`PRAGMA table_info(${table})`).all() as {
10
+ name: string;
11
+ }[];
12
+ if (!columns.some((c) => c.name === col))
13
+ db.exec(`ALTER TABLE ${table} ADD COLUMN ${col} ${type}`);
14
+ };
15
+ add("sessions", "started_via", "TEXT");
16
+ add("task_queue", "parent_session_id", "TEXT");
17
+ add("task_queue", "started_by_device", "TEXT");
18
+ add("task_queue", "started_via", "TEXT");
19
+ add("brief_amendments", "kind", "TEXT NOT NULL DEFAULT 'scope'");
20
+ },
21
+ } satisfies Migration;
@@ -0,0 +1,23 @@
1
+ import type { Migration } from "../migration-types";
2
+
3
+ export default {
4
+ key: "20261014T2200_end_orphaned_tasks",
5
+ up: (db) => {
6
+ // A background task still "running" whose tool call was stopped or
7
+ // errored has no job behind it. Settles the rows already stuck.
8
+ db.exec(`
9
+ UPDATE chat_items
10
+ SET data = json_set(data, '$.status', 'stopped',
11
+ '$.endedAt', CAST(strftime('%s','now') AS INTEGER) * 1000,
12
+ '$.summary', COALESCE(json_extract(data, '$.summary'),
13
+ 'Its agent ended before this finished'))
14
+ WHERE kind = 'task' AND json_extract(data, '$.status') = 'running'
15
+ AND EXISTS (
16
+ SELECT 1 FROM chat_items t
17
+ WHERE t.session_id = chat_items.session_id
18
+ AND t.item_id = json_extract(chat_items.data, '$.toolUseId')
19
+ AND t.kind = 'tool'
20
+ AND json_extract(t.data, '$.status') IN ('stopped', 'error'))
21
+ `);
22
+ },
23
+ } satisfies Migration;
package/lib/db/types.ts CHANGED
@@ -24,6 +24,8 @@ export interface Session {
24
24
  plan_only?: number | null;
25
25
  // The orchestrator session that started this one; null for a person.
26
26
  started_by?: string | null;
27
+ // How it started: session, orchestrator, person, plan, schedule, recipe, stack or remote.
28
+ started_via?: string | null;
27
29
  // 1 planned, 0 no plan, null not stated; why, in the starter's words.
28
30
  plan_decided?: number | null;
29
31
  // JSON: the pieces Saad merged or dropped before approving the plan.
@@ -80,6 +80,7 @@ async function toClaudeMd(card: Card, p: LessonPayload, line: string) {
80
80
  projectId: project.id,
81
81
  prompt: `Add exactly this line under a "${HEADING}" heading in CLAUDE.md at the repository root, creating the file and heading if missing, and change nothing else: ${line}`,
82
82
  startedBy: null,
83
+ startedVia: "person",
83
84
  });
84
85
  return task.id;
85
86
  }
@@ -105,7 +106,12 @@ async function applyLesson(card: Card, p: LessonPayload, edits: Edits) {
105
106
  if (!prompt || !p.projectId)
106
107
  throw new CardRefused("There's no task to start");
107
108
  return (
108
- await createTask({ projectId: p.projectId, prompt, startedBy: null })
109
+ await createTask({
110
+ projectId: p.projectId,
111
+ prompt,
112
+ startedBy: null,
113
+ startedVia: "person",
114
+ })
109
115
  ).id;
110
116
  }
111
117
  case "rule":
@@ -21,8 +21,9 @@ import { ANTHROPIC } from "./roles";
21
21
  import { queueEvent } from "../orchestrator/events";
22
22
  import { noApprovalsOn, recordAutoApproval } from "../approvals/no-approvals";
23
23
  import { addNote } from "../orchestrator/notes";
24
- import { amendBrief, amendmentsOf } from "../orchestrator/amendments";
24
+ import { amendBrief, scopeChangesOf } from "../orchestrator/amendments";
25
25
  import { PIECES_PROMPT, foldedBrief, pieceBrief } from "../plans/pieces";
26
+ import { orchestratorOf } from "../tasks/starter";
26
27
  import {
27
28
  createFeature,
28
29
  ownPieceOf,
@@ -119,7 +120,7 @@ function piecesLine(r: NonNullable<ReturnType<typeof planFor>>): string {
119
120
 
120
121
  // Tells the orchestrator that started a task its plan is written, once.
121
122
  function announcePlan(s: Session, ref: string | null, retried = false): void {
122
- if (!s.started_by && !s.held_by_device) return;
123
+ if (!orchestratorOf(s.started_by) && !s.held_by_device) return;
123
124
  const workspaceId = sessionWorkspace(s);
124
125
  // A task held for another machine's orchestrator can't reach it: the line
125
126
  // waits on the task for that orchestrator to read (orchestrator/facts).
@@ -507,12 +508,21 @@ export async function recheckPlanning(
507
508
  );
508
509
  }
509
510
 
511
+ export type ApprovedBy = "saad" | "orchestrator" | "no-approvals";
512
+
513
+ const APPROVAL_TEXT: Record<ApprovedBy, string> = {
514
+ saad: "Saad approved the plan; implement it as planned.",
515
+ orchestrator: "The orchestrator approved the plan; implement it as planned.",
516
+ "no-approvals":
517
+ "The plan was approved automatically (no-approvals mode); implement it as planned.",
518
+ };
519
+
510
520
  // approvePlan, sending through the chat runner (imported when needed: it
511
521
  // imports this file).
512
522
  export async function approvePlanNow(
513
523
  sessionId: string,
514
524
  message?: string,
515
- by: "saad" | "no-approvals" = "saad"
525
+ by: ApprovedBy = "saad"
516
526
  ) {
517
527
  const { sendQueuedNow } = await import("../chat/runner");
518
528
  return approvePlan(sessionId, message, (id, m) => sendQueuedNow(id, m), by);
@@ -526,7 +536,7 @@ export async function approvePlan(
526
536
  sessionId: string,
527
537
  message: string | undefined,
528
538
  send: Send,
529
- by: "saad" | "no-approvals" = "saad"
539
+ by: ApprovedBy = "saad"
530
540
  ): Promise<{ ref: string; amendments: number }> {
531
541
  const s = getSession(sessionId);
532
542
  if (!s || s.plan_phase !== "awaiting" || s.task_status !== "running")
@@ -535,9 +545,16 @@ export async function approvePlan(
535
545
  );
536
546
  const text = message?.trim() || undefined;
537
547
  const workspaceId = sessionWorkspace(s);
538
- if (text && workspaceId) {
539
- amendBrief(workspaceId, s.id, text);
540
- addNote(workspaceId, `Plan approved with a change for ${s.name}: ${text}`);
548
+ // Every approval is on the brief, so the review judges the PR as the
549
+ // implementation of the plan, not as plan-only work.
550
+ if (workspaceId) {
551
+ if (text) {
552
+ amendBrief(workspaceId, s.id, text);
553
+ addNote(
554
+ workspaceId,
555
+ `Plan approved with a change for ${s.name}: ${text}`
556
+ );
557
+ } else amendBrief(workspaceId, s.id, APPROVAL_TEXT[by], "approval");
541
558
  }
542
559
  const ref = await startImplementing(s, "awaiting", send, text);
543
560
  if (!ref) throw new Error(`${s.name} isn't waiting for plan approval`);
@@ -549,5 +566,5 @@ export async function approvePlan(
549
566
  subject: `plan for ${s.name}`,
550
567
  detail: "already waiting when the mode was turned on",
551
568
  });
552
- return { ref, amendments: amendmentsOf(s.id).length };
569
+ return { ref, amendments: scopeChangesOf(s.id).length };
553
570
  }
@@ -24,9 +24,9 @@ import {
24
24
  formatModelRef,
25
25
  isModelRole,
26
26
  parseModelRef,
27
- sessionRole,
28
27
  type ModelRole,
29
28
  } from "./roles";
29
+ import { sessionRole } from "./session-role";
30
30
  import {
31
31
  resolveSecret,
32
32
  secretShellExpansion,
@@ -18,7 +18,7 @@ import {
18
18
  taskOverride,
19
19
  type ModelOverride,
20
20
  } from "./resolve";
21
- import { sessionRole } from "./roles";
21
+ import { sessionRole } from "./session-role";
22
22
 
23
23
  export const RESUME_MESSAGE =
24
24
  "You were paused. Carry on where you left off: check the worktree and the conversation above for where things stand, then finish the task.";
@@ -85,19 +85,3 @@ export function parseModelRef(ref: string): ModelRef {
85
85
  export const formatModelRef = (r: ModelRef) => `${r.provider}/${r.model}`;
86
86
 
87
87
  export const REF_PATTERN = /^[a-z0-9][a-z0-9_-]*\/[\w.:@/-]+$/i;
88
-
89
- // The role a session's next turn runs as.
90
- export function sessionRole(session: {
91
- role?: string | null;
92
- task_status?: string | null;
93
- plan_phase?: string | null;
94
- started_by?: string | null;
95
- }): ModelRole {
96
- if (session.role === "orchestrator") return "orchestrator";
97
- // Work an orchestrator started is implementation, not a person's session.
98
- if (!session.task_status)
99
- return session.started_by ? "implement" : "interactive";
100
- return session.plan_phase === "planning" || session.plan_phase === "awaiting"
101
- ? "plan"
102
- : "implement";
103
- }
@@ -0,0 +1,19 @@
1
+ import { orchestratorOf } from "../tasks/starter";
2
+ import type { ModelRole } from "./roles";
3
+
4
+ // Server-only: it reads the database, so the client-safe roles module can't hold it.
5
+ // The role a session's next turn runs as.
6
+ export function sessionRole(session: {
7
+ role?: string | null;
8
+ task_status?: string | null;
9
+ plan_phase?: string | null;
10
+ started_by?: string | null;
11
+ }): ModelRole {
12
+ if (session.role === "orchestrator") return "orchestrator";
13
+ // Work an orchestrator started is implementation, not a person's session.
14
+ if (!session.task_status)
15
+ return orchestratorOf(session.started_by) ? "implement" : "interactive";
16
+ return session.plan_phase === "planning" || session.plan_phase === "awaiting"
17
+ ? "plan"
18
+ : "implement";
19
+ }