@saadnvd1/agent-os 0.3.597 → 0.3.601

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 (53) 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/LiveLine.tsx +33 -6
  6. package/components/SessionList/QueuedRow.tsx +45 -46
  7. package/components/SessionList/RowParts.tsx +1 -54
  8. package/components/SessionList/RowShell.tsx +13 -6
  9. package/components/SessionList/RowStatusPlacement.test.ts +114 -0
  10. package/components/SessionList/RowSubtitle.tsx +2 -3
  11. package/components/SessionList/SessionRow.tsx +14 -11
  12. package/lib/agents/spawn.ts +16 -4
  13. package/lib/chat/refresh.ts +2 -1
  14. package/lib/chat/runner.ts +29 -0
  15. package/lib/chat/store.ts +53 -0
  16. package/lib/db/migrations/20261014T2110_task_start_record.ts +21 -0
  17. package/lib/db/migrations/20261014T2200_end_orphaned_tasks.ts +23 -0
  18. package/lib/db/types.ts +2 -0
  19. package/lib/learning-cards/apply.ts +7 -1
  20. package/lib/models/plan-step.ts +25 -8
  21. package/lib/models/resolve.ts +1 -1
  22. package/lib/models/resume.ts +1 -1
  23. package/lib/models/roles.ts +0 -16
  24. package/lib/models/session-role.ts +19 -0
  25. package/lib/orchestrator/act.ts +22 -7
  26. package/lib/orchestrator/amendments.ts +10 -3
  27. package/lib/orchestrator/serve.ts +1 -0
  28. package/lib/orchestrator/testing.ts +8 -0
  29. package/lib/orchestrator/tool-schemas.ts +9 -0
  30. package/lib/orchestrator/tools.ts +1 -1
  31. package/lib/plans/claim.ts +131 -0
  32. package/lib/plans/release.ts +5 -2
  33. package/lib/recipes/run.ts +6 -0
  34. package/lib/schedules/start.ts +1 -0
  35. package/lib/sessions/fork.ts +10 -0
  36. package/lib/sessions/launch.ts +5 -1
  37. package/lib/sidebar/live-status.ts +33 -2
  38. package/lib/sidebar/row-badge.ts +26 -2
  39. package/lib/stacks/start.ts +1 -0
  40. package/lib/tasks/held-remote.ts +4 -2
  41. package/lib/tasks/held-replies.ts +5 -2
  42. package/lib/tasks/held.ts +2 -1
  43. package/lib/tasks/index.ts +85 -46
  44. package/lib/tasks/move-carry.ts +12 -1
  45. package/lib/tasks/move-guard.ts +2 -1
  46. package/lib/tasks/move.ts +7 -5
  47. package/lib/tasks/pause.ts +1 -1
  48. package/lib/tasks/queue.ts +54 -31
  49. package/lib/tasks/remote.ts +3 -1
  50. package/lib/tasks/starter.ts +124 -0
  51. package/lib/usage/calls.ts +2 -1
  52. package/lib/usage/turns.ts +2 -1
  53. package/package.json +1 -1
@@ -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.601",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"