@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
@@ -48,7 +48,9 @@ import {
48
48
  } from "./state";
49
49
  import { expandHome, prFor, storedPR, taskSessions } from "./session";
50
50
  import { InProgressError, type OrchestratedBundle } from "./move-bundle";
51
- import { holdFor, mayHold, recordStarter } from "./held";
51
+ import { holdFor, mayHold } from "./held";
52
+ import { orchestratorOf, recordStart, type StartVia } from "./starter";
53
+ import { claimable, claimPiece, releaseClaim } from "../plans/claim";
52
54
  import { nameFor } from "../session-titles";
53
55
  import { taskSetupOf, type TaskSetup } from "./setup";
54
56
  import { taskPlan } from "./plan-view";
@@ -136,8 +138,13 @@ export async function createTask(opts: {
136
138
  planReason?: string | null;
137
139
  // Stop after the plan and wait for approval (implies plan).
138
140
  planOnly?: boolean;
139
- // The orchestrator session that started it (null: a person).
141
+ // The session that started it (null: a person), the session it came from,
142
+ // and how: every start says (tasks/starter.ts).
140
143
  startedBy?: string | null;
144
+ parentSessionId?: string | null;
145
+ startedVia: StartVia;
146
+ // The waiting plan piece it builds: claimed for this task.
147
+ pieceId?: number;
141
148
  // Started from this LumifyHub card, which the task then moves.
142
149
  cardId?: string;
143
150
  // Stacked: cut from another task's pushed branch, at this exact commit.
@@ -165,7 +172,7 @@ export async function createTask(opts: {
165
172
  // (held.ts): that machine's paired device and its description of the task.
166
173
  held?: { device: string; orchestrated: OrchestratedBundle };
167
174
  // The paired device that sent this keyed start: the only one that may hold it later.
168
- startedByDevice?: string;
175
+ startedByDevice?: string | null;
169
176
  }): Promise<Session> {
170
177
  if (opts.id) {
171
178
  const existing = queries.getSession(db).get(opts.id) as Session | undefined;
@@ -222,6 +229,7 @@ async function startTask(
222
229
  if (!prompt) throw new Error("Describe the task");
223
230
  const project = getProject(opts.projectId);
224
231
  if (!project || project.is_uncategorized) throw new Error("Pick a project");
232
+ if (opts.pieceId) claimable(opts.pieceId);
225
233
  const hostId = opts.hostId ?? project.host_id;
226
234
  const view = opts.view ?? DEFAULT_START_VIEW;
227
235
  const agent = opts.agent ?? "claude";
@@ -251,6 +259,16 @@ async function startTask(
251
259
  view,
252
260
  startedBy: opts.startedBy,
253
261
  });
262
+ // The mirror's started_by stays orchestrator-only: a held-remote task is
263
+ // relayed, stopped and answered through it. A plain calling task is its
264
+ // parent and started_via only.
265
+ recordStart(session.id, {
266
+ startedBy: orchestratorOf(opts.startedBy),
267
+ parentSessionId: opts.parentSessionId ?? null,
268
+ startedVia: opts.startedVia,
269
+ startedByDevice: opts.held?.device ?? opts.startedByDevice,
270
+ });
271
+ if (opts.pieceId) claimPiece(opts.pieceId, session.id);
254
272
  opts.onCreated?.(session.id);
255
273
  return session;
256
274
  }
@@ -295,50 +313,71 @@ async function startTask(
295
313
  const model = routing.first.model;
296
314
  const tmuxName = `${provider.id}-${id}`;
297
315
 
298
- queries
299
- .createSession(db)
300
- .run(
301
- id,
302
- naming.name,
303
- tmuxName,
304
- wt.worktreePath,
305
- null,
306
- model,
307
- null,
308
- "sessions",
309
- agent,
310
- 1,
311
- project.id,
312
- "local"
316
+ // Claimed right before the row exists, with nothing awaited between, so the
317
+ // release sweep can't start the same piece.
318
+ if (opts.pieceId) {
319
+ try {
320
+ claimPiece(opts.pieceId, id);
321
+ } catch (error) {
322
+ await discardLeftoverStart(projectPath, feature).catch(() => {});
323
+ throw error;
324
+ }
325
+ }
326
+ try {
327
+ queries
328
+ .createSession(db)
329
+ .run(
330
+ id,
331
+ naming.name,
332
+ tmuxName,
333
+ wt.worktreePath,
334
+ null,
335
+ model,
336
+ null,
337
+ "sessions",
338
+ agent,
339
+ 1,
340
+ project.id,
341
+ "local"
342
+ );
343
+ queries
344
+ .updateSessionWorktree(db)
345
+ .run(wt.worktreePath, wt.branchName, baseBranch, null, id);
346
+ // Everything the launch needs is on the row, so a restart can resume it.
347
+ db.prepare(
348
+ `UPDATE sessions SET task_prompt = ?, task_brief = ?, task_status = 'running', setup_status = 'running', name_source = ?, view = ?,
349
+ model_provider = ?, model_overrides = ?, plan_phase = ?, plan_only = ?,
350
+ plan_decided = ?, plan_reason = ? WHERE id = ?`
351
+ ).run(
352
+ prompt,
353
+ buildTaskBrief({
354
+ branch: wt.branchName,
355
+ baseBranch,
356
+ stack: opts.base?.stack,
357
+ }),
358
+ naming.source,
359
+ view,
360
+ routing.first.provider.id === ANTHROPIC
361
+ ? null
362
+ : routing.first.provider.id,
363
+ routing.override ? JSON.stringify(routing.override) : null,
364
+ routing.plan ? "planning" : null,
365
+ routing.planOnly ? 1 : null,
366
+ routing.planOnly ? 1 : opts.plan === undefined ? null : opts.plan ? 1 : 0,
367
+ opts.planReason?.trim() || null,
368
+ id
313
369
  );
314
- queries
315
- .updateSessionWorktree(db)
316
- .run(wt.worktreePath, wt.branchName, baseBranch, null, id);
317
- // Everything the launch needs is on the row, so a restart can resume it.
318
- db.prepare(
319
- `UPDATE sessions SET task_prompt = ?, task_brief = ?, task_status = 'running', setup_status = 'running', name_source = ?, view = ?,
320
- model_provider = ?, model_overrides = ?, plan_phase = ?, plan_only = ?,
321
- plan_decided = ?, plan_reason = ?, started_by = ? WHERE id = ?`
322
- ).run(
323
- prompt,
324
- buildTaskBrief({
325
- branch: wt.branchName,
326
- baseBranch,
327
- stack: opts.base?.stack,
328
- }),
329
- naming.source,
330
- view,
331
- routing.first.provider.id === ANTHROPIC ? null : routing.first.provider.id,
332
- routing.override ? JSON.stringify(routing.override) : null,
333
- routing.plan ? "planning" : null,
334
- routing.planOnly ? 1 : null,
335
- routing.planOnly ? 1 : opts.plan === undefined ? null : opts.plan ? 1 : 0,
336
- opts.planReason?.trim() || null,
337
- opts.startedBy ?? null,
338
- id
339
- );
340
- const starter = opts.held?.device ?? opts.startedByDevice;
341
- if (starter) recordStarter(id, starter);
370
+ recordStart(id, {
371
+ startedBy: opts.startedBy ?? null,
372
+ parentSessionId: opts.parentSessionId ?? null,
373
+ startedVia: opts.startedVia,
374
+ startedByDevice: opts.held?.device ?? opts.startedByDevice,
375
+ });
376
+ } catch (error) {
377
+ // The claim was made for a session that never existed.
378
+ if (opts.pieceId) releaseClaim(opts.pieceId, id);
379
+ throw error;
380
+ }
342
381
  if (opts.held) holdFor(id, opts.held.device, opts.held.orchestrated);
343
382
  opts.onCreated?.(id);
344
383
  void naming.refine?.(id);
@@ -9,6 +9,7 @@
9
9
 
10
10
  import type Database from "better-sqlite3";
11
11
  import { db } from "../db";
12
+ import { orchestratorOf } from "./starter";
12
13
 
13
14
  // table, column, and how the id sits in it: the whole value, `task:<id>`, or
14
15
  // the tail of a `<condition>:<id>` key.
@@ -59,9 +60,19 @@ export const CARRIED_COLUMNS = [
59
60
  ] as const;
60
61
 
61
62
  /** The orchestration columns that make a session "an orchestrator's task". */
63
+ // started_by carries as the orchestrator only: a task a plain task started is
64
+ // not held, and a mirror reading it as held would relay to the wrong session.
62
65
  export const orchestrationOf = (session: Record<string, unknown>) =>
63
66
  Object.fromEntries(
64
- CARRIED_COLUMNS.map((c) => [c, session[c] ?? null] as const)
67
+ CARRIED_COLUMNS.map(
68
+ (c) =>
69
+ [
70
+ c,
71
+ c === "started_by"
72
+ ? orchestratorOf(session.started_by as string | null)
73
+ : (session[c] ?? null),
74
+ ] as const
75
+ )
65
76
  );
66
77
 
67
78
  export function carryTaskState(
@@ -10,10 +10,11 @@ import { db, type Session } from "../db";
10
10
  import { escalations } from "../orchestrator/gates";
11
11
  import { taskSubject } from "../orchestrator/asks";
12
12
  import { usesGitCrypt } from "../worktree-add";
13
+ import { orchestratorOf } from "./starter";
13
14
 
14
15
  /** Started by an orchestrator, or held for one by the machine it left. */
15
16
  export function isOrchestrated(session: Session): boolean {
16
- if (session.started_by || session.held_by_device) return true;
17
+ if (orchestratorOf(session.started_by) || session.held_by_device) return true;
17
18
  return !!db
18
19
  .prepare(
19
20
  `SELECT 1 FROM orchestrator_starts WHERE kind != 'stack' AND target = ? LIMIT 1`
package/lib/tasks/move.ts CHANGED
@@ -30,6 +30,7 @@ import { dropSessionDatabase } from "../project-config/database";
30
30
  import { releaseChat } from "../chat/runner";
31
31
  import { collectWhileMoving } from "../chat/hold";
32
32
  import { packChat, stopChatForMove } from "./move-chat";
33
+ import { orchestratorOf as starterOrchestrator } from "./starter";
33
34
  import {
34
35
  isClaudeSessionId,
35
36
  latestSessionId,
@@ -122,11 +123,12 @@ function orchestratorOf(
122
123
  ): { id: string; name: string } | undefined {
123
124
  if (session.held_for && session.held_for_name)
124
125
  return { id: session.held_for, name: session.held_for_name };
125
- if (!session.started_by) return undefined;
126
- const o = db
127
- .prepare(`SELECT name FROM sessions WHERE id = ?`)
128
- .get(session.started_by) as { name: string } | undefined;
129
- return o ? { id: session.started_by, name: o.name } : undefined;
126
+ const by = starterOrchestrator(session.started_by);
127
+ if (!by) return undefined;
128
+ const o = db.prepare(`SELECT name FROM sessions WHERE id = ?`).get(by) as
129
+ | { name: string }
130
+ | undefined;
131
+ return o ? { id: by, name: o.name } : undefined;
130
132
  }
131
133
 
132
134
  // What an orchestrator's task carries besides its work (the orchestrator's
@@ -28,7 +28,7 @@ import { launchPending } from "./launch-gate";
28
28
  import { killTmux } from "./start";
29
29
  import { launchClaude } from "../agents/launch";
30
30
  import { resolveRole, sessionWorkspace, taskOverride } from "../models/resolve";
31
- import { sessionRole } from "../models/roles";
31
+ import { sessionRole } from "../models/session-role";
32
32
  import { holdingEscalations } from "../orchestrator/gates";
33
33
  import { openBySubject, workSubject } from "../orchestrator/asks";
34
34
  import { queueEventOnce } from "../orchestrator/events";
@@ -85,6 +85,8 @@ import {
85
85
  resumeHoldFor,
86
86
  setPauseNote,
87
87
  } from "./pause";
88
+ import { orchestratorOf, type StartVia } from "./starter";
89
+ import { claimPiece } from "../plans/claim";
88
90
 
89
91
  export type QueueStatus =
90
92
  | "queued"
@@ -107,6 +109,9 @@ export interface QueueRow {
107
109
  plan_reason: string | null;
108
110
  plan_only: number | null;
109
111
  started_by: string | null;
112
+ parent_session_id: string | null;
113
+ started_by_device: string | null;
114
+ started_via: string | null;
110
115
  view: "chat" | "terminal" | null;
111
116
  agent_type: string | null;
112
117
  base_branch: string | null;
@@ -306,6 +311,11 @@ export interface QueueRequest {
306
311
  planReason?: string | null;
307
312
  planOnly?: boolean;
308
313
  startedBy?: string | null;
314
+ parentSessionId?: string | null;
315
+ startedByDevice?: string | null;
316
+ startedVia: StartVia;
317
+ // The waiting plan piece it builds, claimed for its queue id.
318
+ pieceId?: number;
309
319
  view?: "chat" | "terminal";
310
320
  agent?: AgentType;
311
321
  baseBranch?: string;
@@ -412,36 +422,43 @@ export function queueIfNeeded(req: QueueRequest): QueuedTaskView | null {
412
422
  if (!waits && !full && !loaded) return null;
413
423
  if (!req.prompt.trim()) throw new Error("Describe the task");
414
424
  const id = randomUUID();
415
- db.prepare(
416
- `INSERT INTO task_queue (id, project_id, prompt, name, model, model_overrides, plan, plan_reason, plan_only, started_by, view, agent_type, base_branch,
417
- host_id, after_task, after_any, origin_workspace_id, priority, machine_needs, position)
418
- VALUES (@id, @project_id, @prompt, @name, @model, @model_overrides, @plan, @plan_reason, @plan_only, @started_by, @view, @agent_type, @base_branch,
419
- @host_id, @after_task, @after_any, @origin, @priority, @machine_needs,
420
- (SELECT COALESCE(MAX(position), 0) + 1 FROM task_queue))`
421
- ).run({
422
- id,
423
- project_id: project.id,
424
- prompt: req.prompt.trim(),
425
- name: req.name?.trim() || null,
426
- model: typeof req.model === "string" ? req.model || null : null,
427
- model_overrides:
428
- req.model && typeof req.model === "object"
429
- ? JSON.stringify(req.model)
430
- : null,
431
- plan: req.plan === undefined ? null : req.plan ? 1 : 0,
432
- plan_reason: req.planReason?.trim() || null,
433
- plan_only: req.planOnly ? 1 : null,
434
- started_by: req.startedBy ?? null,
435
- view: req.view ?? null,
436
- agent_type: req.agent ?? null,
437
- base_branch: req.baseBranch || null,
438
- host_id: req.hostId || null,
439
- after_task: afterTask,
440
- after_any: afterAny ? JSON.stringify(afterAny) : null,
441
- origin: req.originWorkspaceId ?? null,
442
- priority: req.priority ?? DEFAULT_PRIORITY,
443
- machine_needs: setting ? JSON.stringify(setting) : null,
444
- });
425
+ // The row and the piece claim stand or fall together.
426
+ db.transaction(() => {
427
+ db.prepare(
428
+ `INSERT INTO task_queue (id, project_id, prompt, name, model, model_overrides, plan, plan_reason, plan_only, started_by, parent_session_id, started_by_device, started_via, view, agent_type, base_branch,
429
+ host_id, after_task, after_any, origin_workspace_id, priority, machine_needs, position)
430
+ VALUES (@id, @project_id, @prompt, @name, @model, @model_overrides, @plan, @plan_reason, @plan_only, @started_by, @parent_session_id, @started_by_device, @started_via, @view, @agent_type, @base_branch,
431
+ @host_id, @after_task, @after_any, @origin, @priority, @machine_needs,
432
+ (SELECT COALESCE(MAX(position), 0) + 1 FROM task_queue))`
433
+ ).run({
434
+ id,
435
+ project_id: project.id,
436
+ prompt: req.prompt.trim(),
437
+ name: req.name?.trim() || null,
438
+ model: typeof req.model === "string" ? req.model || null : null,
439
+ model_overrides:
440
+ req.model && typeof req.model === "object"
441
+ ? JSON.stringify(req.model)
442
+ : null,
443
+ plan: req.plan === undefined ? null : req.plan ? 1 : 0,
444
+ plan_reason: req.planReason?.trim() || null,
445
+ plan_only: req.planOnly ? 1 : null,
446
+ started_by: req.startedBy ?? null,
447
+ parent_session_id: req.parentSessionId ?? null,
448
+ started_by_device: req.startedByDevice ?? null,
449
+ started_via: req.startedVia,
450
+ view: req.view ?? null,
451
+ agent_type: req.agent ?? null,
452
+ base_branch: req.baseBranch || null,
453
+ host_id: req.hostId || null,
454
+ after_task: afterTask,
455
+ after_any: afterAny ? JSON.stringify(afterAny) : null,
456
+ origin: req.originWorkspaceId ?? null,
457
+ priority: req.priority ?? DEFAULT_PRIORITY,
458
+ machine_needs: setting ? JSON.stringify(setting) : null,
459
+ });
460
+ if (req.pieceId) claimPiece(req.pieceId, id);
461
+ })();
445
462
  tickQueueSoon();
446
463
  return queuedView(id)!;
447
464
  }
@@ -729,6 +746,10 @@ async function startRow(
729
746
  planReason: r.plan_reason,
730
747
  planOnly: !!r.plan_only,
731
748
  startedBy: r.started_by,
749
+ parentSessionId: r.parent_session_id,
750
+ startedByDevice: r.started_by_device,
751
+ startedVia: (r.started_via ??
752
+ (r.started_by ? "session" : "person")) as StartVia,
732
753
  view: r.view ?? undefined,
733
754
  agent: (r.agent_type as AgentType | null) ?? undefined,
734
755
  baseBranch: r.base_branch ?? undefined,
@@ -1022,7 +1043,9 @@ export async function tickOnce(now = Date.now()): Promise<"stale" | void> {
1022
1043
  )) {
1023
1044
  const r = rows.find((x) => x.id === c.id)!;
1024
1045
  const options = (peers.get(r.project_id) ?? []).filter(
1025
- (p) => !taken.has(p.hostId) && (!r.started_by || p.holds !== false)
1046
+ (p) =>
1047
+ !taken.has(p.hostId) &&
1048
+ (!orchestratorOf(r.started_by) || p.holds !== false)
1026
1049
  );
1027
1050
  const spot = place(c, home, options, built.get(c.id)!.needs, now);
1028
1051
  if (!spot) continue;
@@ -18,6 +18,7 @@ import { projectRef } from "./project-ref";
18
18
  import { orchestratedStart } from "./held";
19
19
  import type { TaskStatus } from "./state";
20
20
  import type { TaskView } from "./index";
21
+ import { orchestratorOf } from "./starter";
21
22
 
22
23
  // What this machine's task API does, for another machine deciding whether
23
24
  // it can trust it with a pinned merge or a move; keyed-start: a session
@@ -140,7 +141,8 @@ export async function startRemoteTask(
140
141
  }
141
142
  ): Promise<Session> {
142
143
  const link = requireHostLink(hostId);
143
- const { startedBy, ...rest } = opts;
144
+ const { startedBy: by, ...rest } = opts;
145
+ const startedBy = orchestratorOf(by);
144
146
  if (startedBy)
145
147
  await requireCapability(
146
148
  link,
@@ -0,0 +1,124 @@
1
+ // Who started a task and how. started_by is the session that started it (the
2
+ // calling task, chat or orchestrator; null for a person), parent_session_id
3
+ // the session it came from, started_via how it began.
4
+
5
+ import path from "path";
6
+ import { db } from "../db";
7
+
8
+ export type StartVia =
9
+ | "session"
10
+ | "orchestrator"
11
+ | "person"
12
+ | "plan"
13
+ | "schedule"
14
+ | "recipe"
15
+ | "stack"
16
+ | "remote";
17
+
18
+ export interface Starter {
19
+ startedBy: string | null;
20
+ parentSessionId: string | null;
21
+ startedVia: StartVia;
22
+ startedByDevice?: string | null;
23
+ }
24
+
25
+ export const PERSON: Starter = {
26
+ startedBy: null,
27
+ parentSessionId: null,
28
+ startedVia: "person",
29
+ };
30
+
31
+ export const personStarter = (device?: string | null): Starter => ({
32
+ ...PERSON,
33
+ startedByDevice: device ?? null,
34
+ });
35
+
36
+ interface Caller {
37
+ id: string;
38
+ role: string | null;
39
+ }
40
+
41
+ // The session making the call: `from` when it names a real session, else the
42
+ // one whose worktree holds `cwd`. A project folder is shared by many
43
+ // sessions, so only a worktree identifies one, and only a task's: a cwd
44
+ // outside every known task worktree names nobody. Remaining trust: any local
45
+ // process can still name a task by its id or its worktree path, exactly as
46
+ // it could name one in `from`; this records who asked, it grants nothing
47
+ // (a piece it claims is that task's own plan's).
48
+ export function callerSession(input: {
49
+ from?: unknown;
50
+ cwd?: unknown;
51
+ }): Caller | null {
52
+ if (typeof input.from === "string" && input.from) {
53
+ const row = db
54
+ .prepare(`SELECT id, role FROM sessions WHERE id = ?`)
55
+ .get(input.from) as Caller | undefined;
56
+ if (row) return row;
57
+ }
58
+ if (typeof input.cwd !== "string" || !path.isAbsolute(input.cwd)) return null;
59
+ const cwd = path.resolve(input.cwd);
60
+ const rows = db
61
+ .prepare(
62
+ `SELECT id, role, worktree_path FROM sessions
63
+ WHERE worktree_path IS NOT NULL AND archived_at IS NULL
64
+ AND task_status IS NOT NULL
65
+ AND (host_id IS NULL OR host_id = 'local')`
66
+ )
67
+ .all() as (Caller & { worktree_path: string })[];
68
+ let best: (Caller & { worktree_path: string }) | null = null;
69
+ for (const r of rows) {
70
+ const wt = path.resolve(r.worktree_path);
71
+ if (
72
+ (cwd === wt || cwd.startsWith(wt + path.sep)) &&
73
+ (!best || wt.length > best.worktree_path.length)
74
+ )
75
+ best = r;
76
+ }
77
+ return best;
78
+ }
79
+
80
+ export function starterOf(
81
+ caller: Caller | null,
82
+ device?: string | null
83
+ ): Starter {
84
+ if (!caller) return personStarter(device);
85
+ return {
86
+ startedBy: caller.id,
87
+ parentSessionId: caller.id,
88
+ startedVia: caller.role === "orchestrator" ? "orchestrator" : "session",
89
+ };
90
+ }
91
+
92
+ // The orchestrator that owns a session's work: follows started_by up to the
93
+ // first orchestrator, null when the chain ends at a person. It gives up after
94
+ // 6 hops (a deeper chain, or a cycle, counts as no orchestrator), so a bad
95
+ // row can never loop it.
96
+ export function orchestratorOf(startedBy: string | null | undefined) {
97
+ const seen = new Set<string>();
98
+ let id = startedBy ?? null;
99
+ for (let hops = 0; id && hops < 6 && !seen.has(id); hops++) {
100
+ seen.add(id);
101
+ const row = db
102
+ .prepare(`SELECT role, started_by FROM sessions WHERE id = ?`)
103
+ .get(id) as
104
+ | { role: string | null; started_by: string | null }
105
+ | undefined;
106
+ if (!row) return null;
107
+ if (row.role === "orchestrator") return id;
108
+ id = row.started_by;
109
+ }
110
+ return null;
111
+ }
112
+
113
+ export function recordStart(id: string, s: Starter): void {
114
+ db.prepare(
115
+ `UPDATE sessions SET started_by = ?, parent_session_id = (SELECT id FROM sessions WHERE id = ?), started_via = ?,
116
+ started_by_device = COALESCE(?, started_by_device) WHERE id = ?`
117
+ ).run(
118
+ s.startedBy,
119
+ s.parentSessionId,
120
+ s.startedVia,
121
+ s.startedByDevice ?? null,
122
+ id
123
+ );
124
+ }
@@ -6,7 +6,8 @@
6
6
  */
7
7
 
8
8
  import { db } from "../db";
9
- import { ANTHROPIC, sessionRole } from "../models/roles";
9
+ import { ANTHROPIC } from "../models/roles";
10
+ import { sessionRole } from "../models/session-role";
10
11
  import { ctxOf, weighted, type TokenRow } from "./weights";
11
12
  import type { CallKind, CallRow, CallSource, CallUsage } from "./types";
12
13
 
@@ -8,7 +8,8 @@ import { db, type Session } from "../db";
8
8
  import type { UsageTotals } from "../chat/events";
9
9
  import { totalTokens, turnDelta } from "../chat/context";
10
10
  import { readUsage } from "../orchestrator/usage";
11
- import { ANTHROPIC, sessionRole } from "../models/roles";
11
+ import { ANTHROPIC } from "../models/roles";
12
+ import { sessionRole } from "../models/session-role";
12
13
 
13
14
  // Where a worker's totals start: a resumed conversation continues from the
14
15
  // totals its transcript saved, which the last worker stored; a new one
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.597",
3
+ "version": "0.3.600",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"