@saadnvd1/agent-os 0.3.596 → 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 (150) 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/migration-types.ts +15 -0
  11. package/lib/db/migrations/0001_add_group_path_to_sessions.ts +11 -0
  12. package/lib/db/migrations/0002_add_agent_type_to_sessions.ts +11 -0
  13. package/lib/db/migrations/0003_add_worktree_columns_to_sessions.ts +12 -0
  14. package/lib/db/migrations/0004_add_pr_tracking_to_sessions.ts +11 -0
  15. package/lib/db/migrations/0005_add_group_path_index.ts +11 -0
  16. package/lib/db/migrations/0006_add_orchestration_columns_to_sessions.ts +16 -0
  17. package/lib/db/migrations/0007_add_auto_approve_to_sessions.ts +11 -0
  18. package/lib/db/migrations/0008_add_dev_server_columns.ts +19 -0
  19. package/lib/db/migrations/0009_add_project_id_to_sessions.ts +17 -0
  20. package/lib/db/migrations/0010_add_project_id_to_dev_servers.ts +36 -0
  21. package/lib/db/migrations/0011_add_tmux_name_to_sessions.ts +13 -0
  22. package/lib/db/migrations/0012_add_initial_prompt_to_projects.ts +9 -0
  23. package/lib/db/migrations/0013_add_project_repositories_table.ts +22 -0
  24. package/lib/db/migrations/0014_add_hosts.ts +23 -0
  25. package/lib/db/migrations/0015_add_workspaces.ts +18 -0
  26. package/lib/db/migrations/0016_add_task_columns_to_sessions.ts +13 -0
  27. package/lib/db/migrations/0017_add_bus_messages.ts +24 -0
  28. package/lib/db/migrations/0018_add_chat.ts +23 -0
  29. package/lib/db/migrations/0019_add_lumifyhub.ts +36 -0
  30. package/lib/db/migrations/0020_add_lumifyhub_published_docs.ts +18 -0
  31. package/lib/db/migrations/0021_add_chat_access.ts +14 -0
  32. package/lib/db/migrations/0022_add_last_seen.ts +10 -0
  33. package/lib/db/migrations/0023_mark_existing_sessions_seen.ts +12 -0
  34. package/lib/db/migrations/0024_add_stacks.ts +49 -0
  35. package/lib/db/migrations/0025_add_stack_item_attempts.ts +16 -0
  36. package/lib/db/migrations/0026_add_workspace_orchestrator.ts +47 -0
  37. package/lib/db/migrations/0027_add_devices_and_settings.ts +28 -0
  38. package/lib/db/migrations/0028_add_orchestrator_acting.ts +70 -0
  39. package/lib/db/migrations/0029_add_orchestrator_asks_and_pause.ts +81 -0
  40. package/lib/db/migrations/0030_add_sessions_archived_at.ts +14 -0
  41. package/lib/db/migrations/0031_add_stack_item_restacked_heads.ts +22 -0
  42. package/lib/db/migrations/0032_add_sessions_pinned.ts +12 -0
  43. package/lib/db/migrations/0033_add_program_status.ts +18 -0
  44. package/lib/db/migrations/0034_add_session_names.ts +21 -0
  45. package/lib/db/migrations/0035_add_artifacts.ts +23 -0
  46. package/lib/db/migrations/0036_add_chat_queue.ts +26 -0
  47. package/lib/db/migrations/0037_add_chat_plan_and_turns.ts +39 -0
  48. package/lib/db/migrations/0038_add_schedules.ts +56 -0
  49. package/lib/db/migrations/0039_add_session_name_source.ts +21 -0
  50. package/lib/db/migrations/0040_add_schedule_targets_and_phone_notify.ts +41 -0
  51. package/lib/db/migrations/0041_add_task_setup.ts +17 -0
  52. package/lib/db/migrations/0042_add_host_links_and_moved_tasks.ts +29 -0
  53. package/lib/db/migrations/0043_scratch_project.ts +14 -0
  54. package/lib/db/migrations/0044_chat_item_kind.ts +21 -0
  55. package/lib/db/migrations/0045_change_versions.ts +13 -0
  56. package/lib/db/migrations/0046_session_port_slots.ts +21 -0
  57. package/lib/db/migrations/0047_pin_orchestrators.ts +12 -0
  58. package/lib/db/migrations/0048_session_database.ts +15 -0
  59. package/lib/db/migrations/0049_peer_mirror.ts +14 -0
  60. package/lib/db/migrations/0050_task_queue.ts +44 -0
  61. package/lib/db/migrations/0051_chat_queue_sent_by.ts +16 -0
  62. package/lib/db/migrations/0052_sessions_chat_restarted.ts +17 -0
  63. package/lib/db/migrations/0053_brief_amendments_failure_sha_ask_raised_by.ts +37 -0
  64. package/lib/db/migrations/0054_sessions_chat_tools_digest.ts +15 -0
  65. package/lib/db/migrations/0055_sessions_merge_armed_and_rebase.ts +25 -0
  66. package/lib/db/migrations/0056_orchestrator_rules.ts +39 -0
  67. package/lib/db/migrations/0057_task_pr_wakes.ts +34 -0
  68. package/lib/db/migrations/0058_away_mode.ts +71 -0
  69. package/lib/db/migrations/0059_gate_failure_commits.ts +33 -0
  70. package/lib/db/migrations/0060_check_runs_and_compactions.ts +44 -0
  71. package/lib/db/migrations/0061_model_routing.ts +30 -0
  72. package/lib/db/migrations/0062_sessions_quick_of.ts +19 -0
  73. package/lib/db/migrations/0063_one_off_schedules.ts +16 -0
  74. package/lib/db/migrations/0064_chat_compactions_phase.ts +15 -0
  75. package/lib/db/migrations/0065_orchestrator_triage.ts +23 -0
  76. package/lib/db/migrations/0066_workspace_orch_mcp_servers.ts +16 -0
  77. package/lib/db/migrations/0067_orchestrator_event_log_suppressed.ts +14 -0
  78. package/lib/db/migrations/0068_usage_calls_and_rollups.ts +74 -0
  79. package/lib/db/migrations/0069_session_logs.ts +26 -0
  80. package/lib/db/migrations/0070_chat_items_running.ts +15 -0
  81. package/lib/db/migrations/0071_devices_push.ts +29 -0
  82. package/lib/db/migrations/0072_push_outbox.ts +33 -0
  83. package/lib/db/migrations/0073_orchestrated_task_moves.ts +27 -0
  84. package/lib/db/migrations/0074_held_task_replies.ts +27 -0
  85. package/lib/db/migrations/0075_plan_only_tasks.ts +17 -0
  86. package/lib/db/migrations/0076_welcome_backfill.ts +16 -0
  87. package/lib/db/migrations/0077_simulator_leases.ts +21 -0
  88. package/lib/db/migrations/0078_orchestrator_questions.ts +34 -0
  89. package/lib/db/migrations/0079_task_queue_agent.ts +13 -0
  90. package/lib/db/migrations/0080_recipes.ts +129 -0
  91. package/lib/db/migrations/0081_schedules_into_recipes.ts +18 -0
  92. package/lib/db/migrations/0082_session_shelf_times.ts +23 -0
  93. package/lib/db/migrations/0083_native_devices_can_approve.ts +18 -0
  94. package/lib/db/migrations/0084_approval_audit.ts +20 -0
  95. package/lib/db/migrations/0085_experiment_usage.ts +35 -0
  96. package/lib/db/migrations/0086_learning_cards.ts +28 -0
  97. package/lib/db/migrations/0087_plan_pieces_reconcile.ts +29 -0
  98. package/lib/db/migrations/0088_shared_links.ts +25 -0
  99. package/lib/db/migrations/0089_passkeys_device_keys.ts +23 -0
  100. package/lib/db/migrations/20261014T0900_task_scheduling.ts +57 -0
  101. package/lib/db/migrations/20261014T1235_placed_mirror_started_by.ts +19 -0
  102. package/lib/db/migrations/20261014T1600_queued_task_messages.ts +21 -0
  103. package/lib/db/migrations/20261014T1800_task_starter_device.ts +20 -0
  104. package/lib/db/migrations/20261014T1900_pause_note.ts +13 -0
  105. package/lib/db/migrations/20261014T2000_chat_bookmarks.ts +33 -0
  106. package/lib/db/migrations/20261014T2100_gate_failure_ask_held.ts +18 -0
  107. package/lib/db/migrations/20261014T2110_task_start_record.ts +21 -0
  108. package/lib/db/migrations/20261014T2200_end_orphaned_tasks.ts +23 -0
  109. package/lib/db/migrations/README.md +35 -0
  110. package/lib/db/migrations-legacy-checksums.json +9 -8
  111. package/lib/db/migrations-split-snapshot.json +1759 -0
  112. package/lib/db/migrations.ts +76 -2337
  113. package/lib/db/types.ts +2 -0
  114. package/lib/learning-cards/apply.ts +7 -1
  115. package/lib/models/plan-step.ts +25 -8
  116. package/lib/models/resolve.ts +1 -1
  117. package/lib/models/resume.ts +1 -1
  118. package/lib/models/roles.ts +0 -16
  119. package/lib/models/session-role.ts +19 -0
  120. package/lib/orchestrator/act.ts +22 -7
  121. package/lib/orchestrator/amendments.ts +10 -3
  122. package/lib/orchestrator/serve.ts +1 -0
  123. package/lib/orchestrator/testing.ts +8 -0
  124. package/lib/orchestrator/tool-schemas.ts +9 -0
  125. package/lib/orchestrator/tools.ts +1 -1
  126. package/lib/plans/claim.ts +131 -0
  127. package/lib/plans/release.ts +5 -2
  128. package/lib/recipes/run.ts +6 -0
  129. package/lib/schedules/start.ts +1 -0
  130. package/lib/sessions/fork.ts +10 -0
  131. package/lib/sessions/launch.ts +5 -1
  132. package/lib/stacks/start.ts +1 -0
  133. package/lib/tasks/held-remote.ts +4 -2
  134. package/lib/tasks/held-replies.ts +5 -2
  135. package/lib/tasks/held.ts +2 -1
  136. package/lib/tasks/index.ts +85 -46
  137. package/lib/tasks/move-carry.ts +12 -1
  138. package/lib/tasks/move-guard.ts +2 -1
  139. package/lib/tasks/move.ts +7 -5
  140. package/lib/tasks/pause.ts +1 -1
  141. package/lib/tasks/queue.ts +54 -31
  142. package/lib/tasks/remote.ts +3 -1
  143. package/lib/tasks/starter.ts +124 -0
  144. package/lib/usage/calls.ts +2 -1
  145. package/lib/usage/turns.ts +2 -1
  146. package/package.json +6 -3
  147. package/scripts/check +1 -0
  148. package/scripts/gen-migrations-index.mjs +44 -0
  149. package/scripts/screenshots/demo.ts +5 -1
  150. package/scripts/screenshots/run.ts +5 -1
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
+ }
@@ -35,10 +35,11 @@ import { treeOrder } from "../stacks/tree";
35
35
  import type { StackItemView } from "../stacks/types";
36
36
  import { doneSession } from "../done";
37
37
  import { archiveChecked } from "../done/archive";
38
- import { amendBrief, amendmentsOf } from "./amendments";
38
+ import { amendBrief, scopeChangesOf } from "./amendments";
39
39
  import { db, type Session } from "../db";
40
40
  import { resurrectSession } from "../resurrect";
41
41
  import { resolveEnded } from "../resurrect/find";
42
+ import { findPiece, pieceLabel, workspacePieces } from "../plans/claim";
42
43
  import { approvePlanNow } from "../models/plan-step";
43
44
  import { braked } from "./brakes";
44
45
  import { getOrchestrator } from "./home";
@@ -96,7 +97,7 @@ export async function send(
96
97
  amendBrief(workspaceId, to.id, message);
97
98
  // In the decision log, so Saad sees every change of scope it records.
98
99
  addNote(workspaceId, `Scope change for ${to.name}: ${message}`);
99
- amended = ` Recorded as scope change ${amendmentsOf(to.id).length} on its brief: reviews from now on judge against it (call review with fresh to judge the current head again).`;
100
+ amended = ` Recorded as scope change ${scopeChangesOf(to.id).length} on its brief: reviews from now on judge against it (call review with fresh to judge the current head again).`;
100
101
  }
101
102
  const { delivery } = await sendMessage({
102
103
  fromId: orchestratorId(workspaceId),
@@ -127,7 +128,7 @@ async function sendToQueued(
127
128
  if (opts.scopeChange) {
128
129
  amendBrief(workspaceId, queued.id, message);
129
130
  addNote(workspaceId, `Scope change for ${queued.name}: ${message}`);
130
- amended = ` Recorded as scope change ${amendmentsOf(queued.id).length} on its brief: reviews judge against it once it has a PR.`;
131
+ amended = ` Recorded as scope change ${scopeChangesOf(queued.id).length} on its brief: reviews judge against it once it has a PR.`;
131
132
  }
132
133
  const sent = await sendMessage({
133
134
  fromId: orchestratorId(workspaceId),
@@ -161,6 +162,7 @@ export async function startTask(
161
162
  machine?: "mac" | "any";
162
163
  now?: boolean;
163
164
  nowReason?: string;
165
+ piece?: string;
164
166
  } = {}
165
167
  ): Promise<string> {
166
168
  if (opts.planOnly && opts.plan === false)
@@ -170,6 +172,9 @@ export async function startTask(
170
172
  const project = workspaceProject(workspaceId, projectRef);
171
173
  const model = parseOverride(opts.model) ?? undefined;
172
174
  const startedBy = orchestratorId(workspaceId);
175
+ const pieceId = opts.piece
176
+ ? findPiece(workspacePieces(workspaceId, project.id), opts.piece).id
177
+ : undefined;
173
178
  // Its brakes read this machine's usage, not the other machine's account.
174
179
  if (isRemoteHost(project.host_id))
175
180
  throw new Error(
@@ -190,6 +195,9 @@ export async function startTask(
190
195
  planOnly: opts.planOnly,
191
196
  planReason: opts.planReason,
192
197
  startedBy,
198
+ parentSessionId: startedBy,
199
+ startedVia: "orchestrator",
200
+ pieceId,
193
201
  baseBranch: opts.base || undefined,
194
202
  after: opts.after,
195
203
  originWorkspaceId: workspaceId,
@@ -213,13 +221,17 @@ export async function startTask(
213
221
  planOnly: opts.planOnly,
214
222
  planReason: opts.planReason,
215
223
  startedBy,
224
+ parentSessionId: startedBy,
225
+ startedVia: "orchestrator",
226
+ pieceId,
216
227
  }),
217
228
  (s) => s.id
218
229
  )
219
230
  );
220
231
  if ("queued" in out) {
221
232
  const { queued } = out;
222
- return `Queued (position ${queued.position}) task "${queued.name}" (id ${queued.id.slice(0, 8)}) in ${project.name}${queued.after ? `, after ${queued.after}` : ""}. It starts by itself; you'll get an event when it does.`;
233
+ const builds = pieceId ? ` It builds ${pieceLabel(pieceId)}.` : "";
234
+ return `Queued (position ${queued.position}) task "${queued.name}" (id ${queued.id.slice(0, 8)}) in ${project.name}${queued.after ? `, after ${queued.after}` : ""}.${builds} It starts by itself; you'll get an event when it does.`;
223
235
  }
224
236
  const task = out.started;
225
237
  if (opts.priority) stampPriority(task.id, opts.priority);
@@ -236,7 +248,7 @@ export async function startTask(
236
248
  opts.plan && !opts.planOnly && task.plan_phase !== "planning"
237
249
  ? ` Plan step skipped: ${view === "terminal" ? "a terminal task has none" : "planning and implementation use the same model"}.`
238
250
  : "";
239
- 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." : ""}`;
251
+ 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.${pieceId ? ` It builds ${pieceLabel(pieceId)}.` : ""}${skipped}${planOnlyNote}${past ? " Started past the running-task limit." : ""}`;
240
252
  }
241
253
 
242
254
  // Starts a queued task now, past the running-task limit and its `after`.
@@ -276,10 +288,10 @@ export async function approveTaskPlan(
276
288
  task: Session,
277
289
  message?: string
278
290
  ): Promise<string> {
279
- const out = await approvePlanNow(task.id, message);
291
+ const out = await approvePlanNow(task.id, message, "orchestrator");
280
292
  const amended = message
281
293
  ? ` Recorded as scope change ${out.amendments} on its brief: its review judges against it.`
282
- : "";
294
+ : " Recorded the approval on its brief.";
283
295
  return `Approved the plan of ${task.name}: implementing on ${out.ref}.${amended}`;
284
296
  }
285
297
 
@@ -312,6 +324,8 @@ export async function startSession(
312
324
  access: "full",
313
325
  model,
314
326
  startedBy,
327
+ parentSessionId: startedBy,
328
+ startedVia: "orchestrator",
315
329
  })
316
330
  ).session
317
331
  : spawnSession({
@@ -320,6 +334,7 @@ export async function startSession(
320
334
  name: opts.name,
321
335
  model,
322
336
  startedBy,
337
+ parentSessionId: startedBy,
323
338
  }),
324
339
  (s) => s.id
325
340
  );
@@ -10,19 +10,22 @@ export interface Amendment {
10
10
  session_id: string;
11
11
  workspace_id: string;
12
12
  text: string;
13
+ // scope: a change to what the task builds; approval: a plan approved as written.
14
+ kind: "scope" | "approval";
13
15
  created_at: string;
14
16
  }
15
17
 
16
18
  export function amendBrief(
17
19
  workspaceId: string,
18
20
  taskId: string,
19
- text: string
21
+ text: string,
22
+ kind: Amendment["kind"] = "scope"
20
23
  ): Amendment {
21
24
  const { lastInsertRowid } = db
22
25
  .prepare(
23
- `INSERT INTO brief_amendments (session_id, workspace_id, text) VALUES (?, ?, ?)`
26
+ `INSERT INTO brief_amendments (session_id, workspace_id, text, kind) VALUES (?, ?, ?, ?)`
24
27
  )
25
- .run(taskId, workspaceId, text.trim());
28
+ .run(taskId, workspaceId, text.trim(), kind);
26
29
  return db
27
30
  .prepare(`SELECT * FROM brief_amendments WHERE id = ?`)
28
31
  .get(Number(lastInsertRowid)) as Amendment;
@@ -34,3 +37,7 @@ export function amendmentsOf(taskId: string): Amendment[] {
34
37
  .prepare(`SELECT * FROM brief_amendments WHERE session_id = ? ORDER BY id`)
35
38
  .all(taskId) as Amendment[];
36
39
  }
40
+
41
+ // Only the changes of scope: an approval as written changes nothing.
42
+ export const scopeChangesOf = (taskId: string): Amendment[] =>
43
+ amendmentsOf(taskId).filter((a) => a.kind === "scope");
@@ -145,6 +145,7 @@ export async function runTool(
145
145
  machine: a.machine,
146
146
  now: a.now,
147
147
  nowReason: a.now_reason,
148
+ piece: a.piece,
148
149
  });
149
150
  }
150
151
  case "start_now": {
@@ -76,3 +76,11 @@ export function seedWorkspace(name = `ws-${randomUUID().slice(0, 6)}`) {
76
76
  });
77
77
  return { workspace, app, api, chat, task };
78
78
  }
79
+
80
+ // An orchestrator session with this id, for rows that name it as started_by.
81
+ export function seedOrchestrator(id: string): void {
82
+ db.prepare(
83
+ `INSERT OR IGNORE INTO sessions (id, name, tmux_name, working_directory, role)
84
+ VALUES (?, ?, ?, '/tmp', 'orchestrator')`
85
+ ).run(id, `orchestrator ${id}`, `claude-${id}`);
86
+ }
@@ -141,6 +141,15 @@ export const TOOL_SHAPES = {
141
141
  .max(300)
142
142
  .optional()
143
143
  .describe("With now: why it can't wait for a slot; logged for Saad"),
144
+ piece: z
145
+ .string()
146
+ .trim()
147
+ .min(1)
148
+ .max(120)
149
+ .optional()
150
+ .describe(
151
+ "The waiting plan piece this task builds (its name or number, or feature/piece): it is claimed so AgentOS won't start it again"
152
+ ),
144
153
  model: modelOverride,
145
154
  plan: z
146
155
  .boolean()
@@ -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. 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.',
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. piece names the waiting piece of a plan this task builds, so it is claimed and not started a second time. 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:
@@ -0,0 +1,131 @@
1
+ // A task that builds a waiting piece of a plan claims it, so the release
2
+ // sweep never starts that piece a second time.
3
+
4
+ import { db } from "../db";
5
+ import { getProject } from "../projects";
6
+ import type { FeaturePiece } from "./features";
7
+
8
+ export interface WaitingPiece extends FeaturePiece {
9
+ feature_name: string;
10
+ }
11
+
12
+ const COLUMNS = `fp.*, f.name AS feature_name`;
13
+
14
+ // Waiting pieces in `projectId` of the plans the caller belongs to: the plan
15
+ // it wrote, or the one whose piece it is building.
16
+ export function waitingPiecesFor(
17
+ callerId: string,
18
+ projectId: string
19
+ ): WaitingPiece[] {
20
+ return db
21
+ .prepare(
22
+ `SELECT ${COLUMNS} FROM feature_pieces fp JOIN features f ON f.id = fp.feature_id
23
+ WHERE f.status = 'building' AND fp.status = 'waiting' AND fp.project_id = ?
24
+ AND (f.plan_session_id = ?
25
+ OR EXISTS (SELECT 1 FROM feature_pieces o
26
+ WHERE o.feature_id = f.id AND o.session_id = ?))
27
+ ORDER BY f.created_at, fp.position`
28
+ )
29
+ .all(projectId, callerId, callerId) as WaitingPiece[];
30
+ }
31
+
32
+ // The orchestrator's view: waiting pieces of its workspace's plans.
33
+ export function workspacePieces(
34
+ workspaceId: string,
35
+ projectId: string
36
+ ): WaitingPiece[] {
37
+ return db
38
+ .prepare(
39
+ `SELECT ${COLUMNS} FROM feature_pieces fp JOIN features f ON f.id = fp.feature_id
40
+ WHERE f.status = 'building' AND fp.status = 'waiting' AND fp.project_id = ?
41
+ AND f.workspace_id = ?
42
+ ORDER BY f.created_at, fp.position`
43
+ )
44
+ .all(projectId, workspaceId) as WaitingPiece[];
45
+ }
46
+
47
+ export const listPieces = (waiting: WaitingPiece[]) =>
48
+ waiting.map((p) => `${p.position} ${p.name}`).join(", ");
49
+
50
+ // `name`, `position`, or `feature/name`; an error that lists what's waiting
51
+ // when nothing, or more than one, matches.
52
+ export function findPiece(waiting: WaitingPiece[], ref: string): WaitingPiece {
53
+ const want = ref.trim().toLowerCase();
54
+ const slash = want.indexOf("/");
55
+ const only = slash > 0 ? want.slice(0, slash) : null;
56
+ const piece = slash > 0 ? want.slice(slash + 1) : want;
57
+ const hits = waiting.filter(
58
+ (p) =>
59
+ (!only || p.feature_name.toLowerCase() === only) &&
60
+ (p.name.toLowerCase() === piece || String(p.position) === piece)
61
+ );
62
+ if (hits.length === 1) return hits[0];
63
+ const names = waiting
64
+ .map((p) =>
65
+ waiting.some((o) => o.feature_id !== p.feature_id)
66
+ ? `${p.feature_name}/${p.name}`
67
+ : `${p.position} ${p.name}`
68
+ )
69
+ .join(", ");
70
+ if (hits.length > 1)
71
+ throw new Error(
72
+ `"${ref}" matches more than one waiting piece: ${names}. Name it as feature/piece.`
73
+ );
74
+ throw new Error(
75
+ waiting.length
76
+ ? `No waiting piece "${ref}". Waiting pieces: ${names}.`
77
+ : `No waiting piece "${ref}": nothing in your plan is waiting in this project.`
78
+ );
79
+ }
80
+
81
+ export function pieceRefusal(
82
+ waiting: WaitingPiece[],
83
+ projectId: string
84
+ ): string {
85
+ const where = getProject(projectId)?.name ?? "this project";
86
+ return `Your plan "${waiting[0].feature_name}" has pieces waiting in ${where}: ${listPieces(waiting)}. Name the one this task builds with --piece <name>; AgentOS starts the others itself when they are due.`;
87
+ }
88
+
89
+ export function pieceStatus(id: number): string | undefined {
90
+ return (
91
+ db.prepare(`SELECT status FROM feature_pieces WHERE id = ?`).get(id) as
92
+ | { status: string }
93
+ | undefined
94
+ )?.status;
95
+ }
96
+
97
+ export function claimable(id: number): void {
98
+ const status = pieceStatus(id);
99
+ if (status !== "waiting")
100
+ throw new Error(`That piece is ${status ?? "gone"}, not waiting`);
101
+ }
102
+
103
+ // Synchronous, so nothing runs between the check and the write.
104
+ export function claimPiece(id: number, sessionId: string): void {
105
+ const changed = db
106
+ .prepare(
107
+ `UPDATE feature_pieces SET status = 'building', session_id = ?, note = NULL
108
+ WHERE id = ? AND status = 'waiting'`
109
+ )
110
+ .run(sessionId, id).changes;
111
+ if (!changed)
112
+ throw new Error(`That piece is ${pieceStatus(id) ?? "gone"}, not waiting`);
113
+ }
114
+
115
+ // Gives a claim back when the session it was made for never came to be.
116
+ export function releaseClaim(id: number, sessionId: string): void {
117
+ db.prepare(
118
+ `UPDATE feature_pieces SET status = 'waiting', session_id = NULL
119
+ WHERE id = ? AND session_id = ? AND status = 'building'`
120
+ ).run(id, sessionId);
121
+ }
122
+
123
+ export function pieceLabel(id: number): string {
124
+ const r = db
125
+ .prepare(
126
+ `SELECT fp.position, fp.name, f.name AS feature FROM feature_pieces fp
127
+ JOIN features f ON f.id = fp.feature_id WHERE fp.id = ?`
128
+ )
129
+ .get(id) as { position: number; name: string; feature: string } | undefined;
130
+ return r ? `piece ${r.position} ${r.name} of "${r.feature}"` : "a piece";
131
+ }
@@ -9,7 +9,7 @@ import { createTask } from "../tasks";
9
9
  import { startOrQueue } from "../tasks/queue";
10
10
  import { braked, BrakeRefused } from "../orchestrator/brakes";
11
11
  import { queueEvent, queueEventOnce } from "../orchestrator/events";
12
- import { amendBrief, amendmentsOf } from "../orchestrator/amendments";
12
+ import { amendBrief, scopeChangesOf } from "../orchestrator/amendments";
13
13
  import { usageRows } from "../session-logs/gather";
14
14
  import { pieceBrief } from "./pieces";
15
15
  import {
@@ -122,6 +122,9 @@ export const realDeps: ReleaseDeps = {
122
122
  // A retry under the same id clears what a failed attempt left behind.
123
123
  reclaim: true,
124
124
  startedBy: plan?.started_by ?? null,
125
+ parentSessionId: feature.plan_session_id,
126
+ startedVia: "plan" as const,
127
+ startedByDevice: plan?.started_by_device ?? null,
125
128
  originWorkspaceId: feature.workspace_id,
126
129
  };
127
130
  // A queued task keeps its queue id as its session id once it starts.
@@ -173,7 +176,7 @@ function creditParentMerge(feature: Feature, all: FeaturePiece[]): void {
173
176
  )
174
177
  )
175
178
  return;
176
- if (amendmentsOf(parent.id).length) return;
179
+ if (scopeChangesOf(parent.id).length) return;
177
180
  const covered = all.filter(
178
181
  (p) => p.status === "waiting" && p.project_id === parent.project_id
179
182
  );
@@ -152,6 +152,8 @@ export async function startRun(
152
152
  access: "full",
153
153
  model: typeof model === "string" ? model : null,
154
154
  startedBy,
155
+ parentSessionId: startedBy,
156
+ startedVia: byOrchestrator ? "orchestrator" : "recipe",
155
157
  }).then((l) => ({ ...l, id: l.session.id }))
156
158
  );
157
159
  settleStart(run.id, "running", { sessionId: session.id });
@@ -167,6 +169,8 @@ export async function startRun(
167
169
  plan: false,
168
170
  planReason: "recipe run",
169
171
  startedBy,
172
+ parentSessionId: startedBy,
173
+ startedVia: byOrchestrator ? "orchestrator" : "recipe",
170
174
  originWorkspaceId: byOrchestrator ? ws : undefined,
171
175
  },
172
176
  () =>
@@ -181,6 +185,8 @@ export async function startRun(
181
185
  plan: false,
182
186
  planReason: "recipe run",
183
187
  startedBy,
188
+ parentSessionId: startedBy,
189
+ startedVia: byOrchestrator ? "orchestrator" : "recipe",
184
190
  })
185
191
  )
186
192
  );
@@ -37,6 +37,7 @@ async function startTask(
37
37
  projectId: projectOf(schedule).id,
38
38
  prompt: schedule.prompt,
39
39
  name: schedule.name,
40
+ startedVia: "schedule",
40
41
  onCreated: onSession,
41
42
  });
42
43
  return session.id;
@@ -0,0 +1,10 @@
1
+ // A fork continues its parent's conversation: it has a parent but wasn't
2
+ // started by anyone and isn't a task. A task or session started from another
3
+ // session also records that parent, and is not a fork.
4
+ export const isFork = (s: {
5
+ parent_session_id?: string | null;
6
+ started_by?: string | null;
7
+ started_via?: string | null;
8
+ task_prompt?: string | null;
9
+ }): boolean =>
10
+ !!s.parent_session_id && !s.started_by && !s.started_via && !s.task_prompt;
@@ -3,6 +3,7 @@
3
3
  * then (for a worktree) the setup in the background, then the first message.
4
4
  */
5
5
 
6
+ import type { StartVia } from "../tasks/starter";
6
7
  import fs from "fs";
7
8
  import os from "os";
8
9
  import path from "path";
@@ -74,6 +75,7 @@ export interface LaunchInput {
74
75
  // The orchestrator session that starts it: it runs on the implementation
75
76
  // role's model (or `model`), not the interactive one's.
76
77
  startedBy?: string | null;
78
+ startedVia?: StartVia;
77
79
  groupPath?: string;
78
80
  systemPrompt?: string | null;
79
81
  }
@@ -277,12 +279,14 @@ async function launch(input: LaunchInput, id: string): Promise<Launched> {
277
279
  runOn
278
280
  );
279
281
  db.prepare(
280
- `UPDATE sessions SET view = ?, name_source = ?, setup_status = ?, started_by = ? WHERE id = ?`
282
+ `UPDATE sessions SET view = ?, name_source = ?, setup_status = ?, started_by = ?,
283
+ started_via = ? WHERE id = ?`
281
284
  ).run(
282
285
  view,
283
286
  naming.source,
284
287
  useWorktree ? "running" : null,
285
288
  input.startedBy ?? null,
289
+ input.startedVia ?? (input.startedBy ? "orchestrator" : null),
286
290
  id
287
291
  );
288
292
  if (interactive) setSessionModel(id, interactive);
@@ -119,6 +119,7 @@ export async function startItem(
119
119
  prompt: promptFromCard(card),
120
120
  name: card.title,
121
121
  cardId: card.id,
122
+ startedVia: "stack",
122
123
  base,
123
124
  onCreated: (sessionId) =>
124
125
  q.updateItem(db, item.id, {
@@ -16,6 +16,7 @@ import {
16
16
  import type { Delivery } from "../bus/delivery";
17
17
  import { wakeLine } from "../bus/format";
18
18
  import { orchestratedStart } from "./held";
19
+ import { orchestratorOf } from "./starter";
19
20
 
20
21
  const path = (id: string, leaf: string) =>
21
22
  `/api/tasks/${encodeURIComponent(id)}/${leaf}`;
@@ -23,11 +24,12 @@ const path = (id: string, leaf: string) =>
23
24
  /**
24
25
  * A task its orchestrator put on a linked machine, whether by moving it or
25
26
  * by the queue placing it there: the one test every relayed call goes by.
26
- * (held-replies.ts reads the same rule in SQL: started_by IS NOT NULL.)
27
+ * (held-replies.ts reads the same rule in SQL: started_by is an orchestrator.)
27
28
  */
28
29
  export const isHeldRemote = (
29
30
  s: Pick<Session, "started_by" | "task_prompt" | "host_id">
30
- ): boolean => !!s.started_by && !!s.task_prompt && !!hostLink(s.host_id);
31
+ ): boolean =>
32
+ !!s.task_prompt && !!hostLink(s.host_id) && !!orchestratorOf(s.started_by);
31
33
 
32
34
  type Held = Pick<
33
35
  Session,