@saadnvd1/agent-os 0.3.824 → 0.3.825

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 (68) hide show
  1. package/app/api/fork/cancel/route.ts +19 -0
  2. package/app/api/fork/children/route.ts +52 -0
  3. package/app/api/fork/result/route.ts +29 -0
  4. package/app/api/fork/return/route.ts +20 -0
  5. package/app/api/fork/route.ts +32 -0
  6. package/app/api/fork/wait/route.ts +27 -0
  7. package/app/api/load/heavy/lease/route.ts +26 -0
  8. package/app/api/sessions/[id]/archive/route.ts +17 -4
  9. package/app/api/sessions/[id]/route.ts +2 -3
  10. package/app/page.tsx +0 -5
  11. package/components/Chat/ChatPanel.tsx +33 -206
  12. package/components/Chat/Forks.tsx +278 -0
  13. package/components/Chat/ReplyingTo.tsx +24 -0
  14. package/components/Chat/TimelineRows.tsx +194 -0
  15. package/components/Pane/DesktopTabBar.tsx +0 -6
  16. package/components/Pane/MobileTabBar.tsx +0 -6
  17. package/components/Pane/MobileViewPicker.test.ts +1 -8
  18. package/components/Pane/MobileViewPicker.tsx +0 -11
  19. package/components/Pane/PaneViewToggle.test.ts +0 -2
  20. package/components/Pane/PaneViewToggle.tsx +0 -17
  21. package/components/Pane/index.tsx +0 -50
  22. package/components/SessionList/useDoneAction.ts +18 -3
  23. package/contexts/PaneContext.tsx +1 -1
  24. package/data/done/index.ts +26 -2
  25. package/data/fork/index.ts +83 -0
  26. package/lib/chat/commands.ts +15 -0
  27. package/lib/chat/runner.ts +8 -2
  28. package/lib/chat/worker/extras.ts +7 -1
  29. package/lib/chat/worker/host.ts +2 -0
  30. package/lib/config/client-config-types.ts +7 -0
  31. package/lib/config/client-config.ts +2 -0
  32. package/lib/db/migrations/20261102T0100_forks.ts +87 -0
  33. package/lib/done/archive.ts +21 -1
  34. package/lib/experiments/registry.ts +8 -0
  35. package/lib/fork/api.ts +10 -0
  36. package/lib/fork/brief.ts +63 -0
  37. package/lib/fork/conversation.ts +75 -0
  38. package/lib/fork/deliver.ts +91 -0
  39. package/lib/fork/extras.ts +53 -0
  40. package/lib/fork/guard.ts +323 -0
  41. package/lib/fork/heavy-lease.ts +104 -0
  42. package/lib/fork/held.ts +26 -0
  43. package/lib/fork/ledger.ts +324 -0
  44. package/lib/fork/options.ts +46 -0
  45. package/lib/fork/point.ts +41 -0
  46. package/lib/fork/scratch.ts +67 -0
  47. package/lib/fork/settle.ts +215 -0
  48. package/lib/fork/snapshot.ts +69 -0
  49. package/lib/fork/start.ts +273 -0
  50. package/lib/fork/tick.ts +54 -0
  51. package/lib/fork/types.ts +79 -0
  52. package/lib/fork/view.ts +116 -0
  53. package/lib/fork/waits.ts +83 -0
  54. package/lib/fork/worktree.ts +115 -0
  55. package/lib/orchestrator/shell-guard.ts +6 -3
  56. package/lib/orchestrator/write-guard.ts +1 -1
  57. package/lib/security/demo.ts +1 -2
  58. package/lib/sessions/end.ts +5 -0
  59. package/package.json +1 -1
  60. package/server.ts +2 -0
  61. package/app/api/orchestrate/spawn/route.ts +0 -54
  62. package/app/api/orchestrate/workers/[id]/route.ts +0 -89
  63. package/app/api/orchestrate/workers/route.ts +0 -31
  64. package/app/api/sessions/[id]/mcp-config/route.ts +0 -34
  65. package/components/ConductorPanel.tsx +0 -258
  66. package/lib/mcp-config.ts +0 -80
  67. package/lib/orchestration.ts +0 -463
  68. package/mcp/orchestration-server.ts +0 -441
@@ -0,0 +1,115 @@
1
+ /**
2
+ * A throwaway checkout for a fork child that has to edit code to find its
3
+ * answer (`aos fork --worktree`): detached at the parent's HEAD, with the
4
+ * parent's uncommitted changes applied on top, so it sees what the parent
5
+ * sees. No branch is made and nothing from it is merged; it is removed when
6
+ * the child settles.
7
+ */
8
+
9
+ import fs from "fs";
10
+ import path from "path";
11
+ import { git } from "../git-exec";
12
+ import { setupWorktree } from "../env-setup";
13
+ import { forkHome, worktreeOf } from "./scratch";
14
+
15
+ const MAX_UNTRACKED = 200;
16
+ const MAX_UNTRACKED_BYTES = 5_000_000;
17
+ // A change to these means the parent's installed packages may not match.
18
+ const DEPENDENCY_FILES =
19
+ /(^|\/)(package\.json|package-lock\.json|pnpm-lock\.yaml|yarn\.lock|bun\.lockb?)$/;
20
+
21
+ export interface ForkWorktree {
22
+ // Where the child runs: the parent's folder, in the checkout.
23
+ cwd: string;
24
+ // The checkout's root.
25
+ root: string;
26
+ // The parent's packages are linked in instead of installed.
27
+ linked: boolean;
28
+ // The commit it started from, for the child's return.
29
+ head: string;
30
+ }
31
+
32
+ export async function createForkWorktree(opts: {
33
+ childId: string;
34
+ parentCwd: string;
35
+ }): Promise<ForkWorktree> {
36
+ const cwd = opts.parentCwd;
37
+ const top = (await git(cwd, ["rev-parse", "--show-toplevel"])).trim();
38
+ const head = (await git(top, ["rev-parse", "HEAD"])).trim();
39
+ const wt = worktreeOf(opts.childId);
40
+ await fs.promises.mkdir(forkHome(opts.childId), { recursive: true });
41
+ try {
42
+ await git(top, ["worktree", "add", "--detach", "--", wt, head], {
43
+ timeout: 5 * 60_000,
44
+ });
45
+ const diff = await git(top, ["diff", "HEAD", "--binary"], {
46
+ maxBuffer: 64 * 1024 * 1024,
47
+ });
48
+ if (diff.trim())
49
+ await git(wt, ["apply", "--whitespace=nowarn", "--binary", "-"], {
50
+ input: diff,
51
+ });
52
+ await copyUntracked(top, wt);
53
+ const changed = (await git(top, ["diff", "HEAD", "--name-only"]))
54
+ .split("\n")
55
+ .filter(Boolean);
56
+ const hasPackages = await exists(path.join(top, "node_modules"));
57
+ const linked =
58
+ hasPackages && !changed.some((f) => DEPENDENCY_FILES.test(f));
59
+ if (linked)
60
+ await fs.promises.symlink(
61
+ path.join(top, "node_modules"),
62
+ path.join(wt, "node_modules")
63
+ );
64
+ const setup = await setupWorktree({
65
+ worktreePath: wt,
66
+ sourcePath: top,
67
+ skipInstall: linked,
68
+ });
69
+ if (!setup.success)
70
+ throw new Error(
71
+ setup.steps.find((s) => !s.success)?.error ?? "worktree setup failed"
72
+ );
73
+ return {
74
+ cwd: path.join(wt, path.relative(top, cwd)),
75
+ root: wt,
76
+ linked,
77
+ head,
78
+ };
79
+ } catch (error) {
80
+ const { removeFork } = await import("./scratch");
81
+ await removeFork(opts.childId);
82
+ throw error;
83
+ }
84
+ }
85
+
86
+ const exists = (p: string) =>
87
+ fs.promises.access(p).then(
88
+ () => true,
89
+ () => false
90
+ );
91
+
92
+ // Files the parent made and hasn't added yet, which a diff doesn't carry.
93
+ async function copyUntracked(top: string, wt: string): Promise<void> {
94
+ const out = await git(top, [
95
+ "ls-files",
96
+ "--others",
97
+ "--exclude-standard",
98
+ "-z",
99
+ ]);
100
+ const files = out.split("\0").filter(Boolean);
101
+ if (files.length > MAX_UNTRACKED)
102
+ throw new Error(
103
+ `the parent has ${files.length} new files; commit or stage them first`
104
+ );
105
+ for (const rel of files) {
106
+ const from = path.join(top, rel);
107
+ const stat = await fs.promises.lstat(from);
108
+ if (stat.isSymbolicLink() || !stat.isFile()) continue;
109
+ if (stat.size > MAX_UNTRACKED_BYTES)
110
+ throw new Error(`${rel} is too large to carry into a checkout`);
111
+ const to = path.join(wt, rel);
112
+ await fs.promises.mkdir(path.dirname(to), { recursive: true });
113
+ await fs.promises.copyFile(from, to);
114
+ }
115
+ }
@@ -37,7 +37,7 @@ export interface ShellContext {
37
37
  protectedPids: () => Promise<Set<number>>;
38
38
  }
39
39
 
40
- interface Invocation {
40
+ export interface Invocation {
41
41
  name: string;
42
42
  args: string[];
43
43
  cmd: SimpleCommand;
@@ -87,7 +87,10 @@ const SHELLS = /^(ba|z|da|k|fi|c|tc)?sh$/;
87
87
 
88
88
  // What a simple command runs once its wrappers are taken off: one
89
89
  // invocation, or several (`bash -c '…'`, `find -exec`).
90
- function invocations(cmd: SimpleCommand, depth = 0): Invocation[] | string {
90
+ export function invocations(
91
+ cmd: SimpleCommand,
92
+ depth = 0
93
+ ): Invocation[] | string {
91
94
  let words = cmd.words;
92
95
  for (;;) {
93
96
  while (
@@ -183,7 +186,7 @@ function findInvocations(
183
186
  }
184
187
 
185
188
  // Words that aren't options; `--` ends options. `values` take the next word.
186
- function positionals(args: string[], values: string[] = []): string[] {
189
+ export function positionals(args: string[], values: string[] = []): string[] {
187
190
  const out: string[] = [];
188
191
  for (let i = 0; i < args.length; i++) {
189
192
  const a = args[i];
@@ -130,7 +130,7 @@ const FOLDS_CASE =
130
130
  const key = (p: string) =>
131
131
  FOLDS_CASE ? p.normalize("NFC").toLowerCase() : p.normalize("NFC");
132
132
 
133
- const within = (p: string, root: string) => {
133
+ export const within = (p: string, root: string) => {
134
134
  const [a, b] = [key(p), key(root)];
135
135
  return a === b || a.startsWith(b === "/" ? b : b + "/");
136
136
  };
@@ -120,10 +120,9 @@ const READS = [
120
120
  "schedules",
121
121
  "schedules/[id]",
122
122
  "orchestrators",
123
- "orchestrate/workers",
124
- "orchestrate/workers/[id]",
125
123
  "bus/messages",
126
124
  "bus/peers",
125
+ "fork/children",
127
126
  "usage",
128
127
  "vendor/mermaid",
129
128
  "artifacts/[id]",
@@ -5,6 +5,8 @@ import { endTmuxSession } from "../tmux/end";
5
5
  import { releaseOnEnd, shutTaskSimulators } from "../simulator";
6
6
  import { forgetPlanWait } from "../models/plan-step";
7
7
  import { kickWaits } from "../bus/waits/kick";
8
+ import { openChildren } from "../fork/ledger";
9
+ import { cancelChildren } from "../fork/settle";
8
10
 
9
11
  type Agent = Pick<Session, "id" | "host_id" | "tmux_name"> &
10
12
  Partial<Pick<Session, "worktree_path" | "branch_name">>;
@@ -14,6 +16,9 @@ type Agent = Pick<Session, "id" | "host_id" | "tmux_name"> &
14
16
  // finish share.
15
17
  export async function endSessionAgent(s: Agent): Promise<void> {
16
18
  forgetPlanWait(s.id);
19
+ // Its forks have no one to report to.
20
+ if (openChildren(s.id).length)
21
+ void cancelChildren(s.id).catch(() => undefined);
17
22
  stopChat(s.id);
18
23
  // The task's simulator goes with it; no row, no work.
19
24
  releaseOnEnd(s.id, { remove: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.824",
3
+ "version": "0.3.825",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"
package/server.ts CHANGED
@@ -17,6 +17,7 @@ import { stackStartGate } from "./lib/orchestrator/brakes";
17
17
  import { buildId } from "./lib/build";
18
18
  import { startOrchestratorWatcher } from "./lib/orchestrator/watcher";
19
19
  import { startWaitWatcher } from "./lib/bus/waits/tick";
20
+ import { startForkWatcher } from "./lib/fork/tick";
20
21
  import { installErrorCounter } from "./lib/health/errors";
21
22
  import { recordBoot } from "./lib/health/boots";
22
23
  import { realDeps, schedulesEnabled, startScheduler } from "./lib/schedules";
@@ -498,6 +499,7 @@ app.prepare().then(async () => {
498
499
  if (process.env.AGENTOS_ORCHESTRATOR !== "off") startOrchestratorWatcher();
499
500
  // Sessions waiting on each other (aos send --await, aos wait).
500
501
  startWaitWatcher();
502
+ startForkWatcher();
501
503
  // Schedules tick here, once a minute, and nowhere else.
502
504
  if (schedulesEnabled(process.env)) startScheduler(realDeps);
503
505
  // Looking for a newer version, and running an update when one is asked
@@ -1,54 +0,0 @@
1
- import { NextResponse } from "next/server";
2
- import { resolveModelForAgent } from "@/lib/model-catalog";
3
- import { isValidAgentType, type AgentType } from "@/lib/providers";
4
- import { spawnWorker } from "@/lib/orchestration";
5
-
6
- export async function POST(request: Request) {
7
- try {
8
- const body = await request.json();
9
- const {
10
- conductorSessionId,
11
- task,
12
- workingDirectory,
13
- branchName,
14
- useWorktree = true,
15
- model,
16
- agentType: rawAgentType = "claude",
17
- } = body;
18
- const agentType: AgentType = isValidAgentType(rawAgentType)
19
- ? rawAgentType
20
- : "claude";
21
- const resolvedModel = resolveModelForAgent(
22
- agentType,
23
- typeof model === "string" ? model.trim() : model
24
- );
25
-
26
- if (!conductorSessionId || !task || !workingDirectory) {
27
- return NextResponse.json(
28
- {
29
- error:
30
- "Missing required fields: conductorSessionId, task, workingDirectory",
31
- },
32
- { status: 400 }
33
- );
34
- }
35
-
36
- const session = await spawnWorker({
37
- conductorSessionId,
38
- task,
39
- workingDirectory,
40
- branchName,
41
- useWorktree,
42
- model: resolvedModel,
43
- agentType,
44
- });
45
-
46
- return NextResponse.json({ session });
47
- } catch (error) {
48
- console.error("Failed to spawn worker:", error);
49
- return NextResponse.json(
50
- { error: "Failed to spawn worker" },
51
- { status: 500 }
52
- );
53
- }
54
- }
@@ -1,89 +0,0 @@
1
- import { NextResponse } from "next/server";
2
- import {
3
- getWorkerOutput,
4
- sendToWorker,
5
- completeWorker,
6
- failWorker,
7
- killWorker,
8
- } from "@/lib/orchestration";
9
-
10
- interface RouteParams {
11
- params: Promise<{ id: string }>;
12
- }
13
-
14
- // GET /api/orchestrate/workers/[id] - Get worker output
15
- export async function GET(request: Request, { params }: RouteParams) {
16
- try {
17
- const { id } = await params;
18
- const { searchParams } = new URL(request.url);
19
- const lines = parseInt(searchParams.get("lines") || "50", 10);
20
-
21
- const output = await getWorkerOutput(id, lines);
22
- return NextResponse.json({ output });
23
- } catch (error) {
24
- console.error("Failed to get worker output:", error);
25
- return NextResponse.json(
26
- { error: "Failed to get worker output" },
27
- { status: 500 }
28
- );
29
- }
30
- }
31
-
32
- // POST /api/orchestrate/workers/[id] - Send message or update status
33
- export async function POST(request: Request, { params }: RouteParams) {
34
- try {
35
- const { id } = await params;
36
- const body = await request.json();
37
- const { action, message } = body;
38
-
39
- switch (action) {
40
- case "send":
41
- if (!message) {
42
- return NextResponse.json(
43
- { error: "Missing message" },
44
- { status: 400 }
45
- );
46
- }
47
- const sent = await sendToWorker(id, message);
48
- return NextResponse.json({ success: sent });
49
-
50
- case "complete":
51
- completeWorker(id);
52
- return NextResponse.json({ success: true });
53
-
54
- case "fail":
55
- failWorker(id);
56
- return NextResponse.json({ success: true });
57
-
58
- default:
59
- return NextResponse.json(
60
- { error: "Invalid action. Use: send, complete, or fail" },
61
- { status: 400 }
62
- );
63
- }
64
- } catch (error) {
65
- console.error("Failed to perform worker action:", error);
66
- return NextResponse.json(
67
- { error: "Failed to perform worker action" },
68
- { status: 500 }
69
- );
70
- }
71
- }
72
-
73
- // DELETE /api/orchestrate/workers/[id] - Kill worker
74
- export async function DELETE(request: Request, { params }: RouteParams) {
75
- try {
76
- const { id } = await params;
77
- const { searchParams } = new URL(request.url);
78
- const cleanupWorktree = searchParams.get("cleanup") === "true";
79
-
80
- await killWorker(id, cleanupWorktree);
81
- return NextResponse.json({ success: true });
82
- } catch (error) {
83
- console.error("Failed to kill worker:", error);
84
- return NextResponse.json(
85
- { error: "Failed to kill worker" },
86
- { status: 500 }
87
- );
88
- }
89
- }
@@ -1,31 +0,0 @@
1
- import { NextResponse } from "next/server";
2
- import { getWorkers, getWorkersSummary } from "@/lib/orchestration";
3
-
4
- export async function GET(request: Request) {
5
- try {
6
- const { searchParams } = new URL(request.url);
7
- const conductorId = searchParams.get("conductorId");
8
- const summaryOnly = searchParams.get("summary") === "true";
9
-
10
- if (!conductorId) {
11
- return NextResponse.json(
12
- { error: "Missing conductorId parameter" },
13
- { status: 400 }
14
- );
15
- }
16
-
17
- if (summaryOnly) {
18
- const summary = await getWorkersSummary(conductorId);
19
- return NextResponse.json({ summary });
20
- }
21
-
22
- const workers = await getWorkers(conductorId);
23
- return NextResponse.json({ workers });
24
- } catch (error) {
25
- console.error("Failed to get workers:", error);
26
- return NextResponse.json(
27
- { error: "Failed to get workers" },
28
- { status: 500 }
29
- );
30
- }
31
- }
@@ -1,34 +0,0 @@
1
- import { NextRequest, NextResponse } from "next/server";
2
- import { db, queries, type Session } from "@/lib/db";
3
- import { ensureMcpConfig } from "@/lib/mcp-config";
4
-
5
- // POST /api/sessions/[id]/mcp-config - Ensure MCP config exists for this session
6
- export async function POST(
7
- request: NextRequest,
8
- { params }: { params: Promise<{ id: string }> }
9
- ) {
10
- try {
11
- const { id } = await params;
12
- const session = queries.getSession(db).get(id) as Session | undefined;
13
-
14
- if (!session) {
15
- return NextResponse.json({ error: "Session not found" }, { status: 404 });
16
- }
17
-
18
- // Expand ~ to home directory
19
- const workingDirectory = session.working_directory.replace(
20
- /^~/,
21
- process.env.HOME || ""
22
- );
23
-
24
- ensureMcpConfig(workingDirectory, id);
25
-
26
- return NextResponse.json({ success: true });
27
- } catch (error) {
28
- console.error("Failed to write MCP config:", error);
29
- return NextResponse.json(
30
- { error: "Failed to write MCP config" },
31
- { status: 500 }
32
- );
33
- }
34
- }
@@ -1,258 +0,0 @@
1
- "use client";
2
-
3
- import { useState, useEffect, useCallback } from "react";
4
- import { WorkerCard, type WorkerInfo } from "./WorkerCard";
5
- import { Button } from "./ui/button";
6
- import {
7
- RefreshCw,
8
- Users,
9
- CheckCircle,
10
- Loader2,
11
- AlertCircle,
12
- XCircle,
13
- } from "lucide-react";
14
- import { cn } from "@/lib/utils";
15
-
16
- interface WorkersSummary {
17
- total: number;
18
- pending: number;
19
- running: number;
20
- waiting: number;
21
- completed: number;
22
- failed: number;
23
- }
24
-
25
- interface ConductorPanelProps {
26
- conductorSessionId: string;
27
- onAttachToWorker?: (workerId: string) => void;
28
- }
29
-
30
- export function ConductorPanel({
31
- conductorSessionId,
32
- onAttachToWorker,
33
- }: ConductorPanelProps) {
34
- const [workers, setWorkers] = useState<WorkerInfo[]>([]);
35
- const [summary, setSummary] = useState<WorkersSummary | null>(null);
36
- const [expandedWorkers, setExpandedWorkers] = useState<Set<string>>(
37
- new Set()
38
- );
39
- const [workerOutputs, setWorkerOutputs] = useState<Record<string, string>>(
40
- {}
41
- );
42
- const [loading, setLoading] = useState(true);
43
- const [refreshing, setRefreshing] = useState(false);
44
-
45
- const fetchWorkers = useCallback(async () => {
46
- try {
47
- const res = await fetch(
48
- `/api/orchestrate/workers?conductorId=${conductorSessionId}`
49
- );
50
- const data = await res.json();
51
- if (data.workers) {
52
- setWorkers(data.workers);
53
- }
54
- } catch (error) {
55
- console.error("Failed to fetch workers:", error);
56
- }
57
- }, [conductorSessionId]);
58
-
59
- const fetchSummary = useCallback(async () => {
60
- try {
61
- const res = await fetch(
62
- `/api/orchestrate/workers?conductorId=${conductorSessionId}&summary=true`
63
- );
64
- const data = await res.json();
65
- if (data.summary) {
66
- setSummary(data.summary);
67
- }
68
- } catch (error) {
69
- console.error("Failed to fetch summary:", error);
70
- }
71
- }, [conductorSessionId]);
72
-
73
- const fetchWorkerOutput = useCallback(async (workerId: string) => {
74
- try {
75
- const res = await fetch(`/api/orchestrate/workers/${workerId}?lines=30`);
76
- const data = await res.json();
77
- if (data.output) {
78
- setWorkerOutputs((prev) => ({ ...prev, [workerId]: data.output }));
79
- }
80
- } catch (error) {
81
- console.error("Failed to fetch worker output:", error);
82
- }
83
- }, []);
84
-
85
- const refresh = useCallback(async () => {
86
- setRefreshing(true);
87
- await Promise.all([fetchWorkers(), fetchSummary()]);
88
- setRefreshing(false);
89
- }, [fetchWorkers, fetchSummary]);
90
-
91
- // Initial load
92
- useEffect(() => {
93
- const load = async () => {
94
- setLoading(true);
95
- await Promise.all([fetchWorkers(), fetchSummary()]);
96
- setLoading(false);
97
- };
98
- load();
99
- }, [fetchWorkers, fetchSummary]);
100
-
101
- // Poll for updates every 5 seconds
102
- useEffect(() => {
103
- const interval = setInterval(refresh, 5000);
104
- return () => clearInterval(interval);
105
- }, [refresh]);
106
-
107
- // Fetch output for expanded workers
108
- useEffect(() => {
109
- expandedWorkers.forEach((workerId) => {
110
- if (!workerOutputs[workerId]) {
111
- fetchWorkerOutput(workerId);
112
- }
113
- });
114
- }, [expandedWorkers, workerOutputs, fetchWorkerOutput]);
115
-
116
- const toggleExpand = (workerId: string) => {
117
- setExpandedWorkers((prev) => {
118
- const next = new Set(prev);
119
- if (next.has(workerId)) {
120
- next.delete(workerId);
121
- } else {
122
- next.add(workerId);
123
- // Fetch output when expanding
124
- fetchWorkerOutput(workerId);
125
- }
126
- return next;
127
- });
128
- };
129
-
130
- const handleSendMessage = async (workerId: string, message: string) => {
131
- try {
132
- await fetch(`/api/orchestrate/workers/${workerId}`, {
133
- method: "POST",
134
- headers: { "Content-Type": "application/json" },
135
- body: JSON.stringify({ action: "send", message }),
136
- });
137
- // Refresh output after sending
138
- setTimeout(() => fetchWorkerOutput(workerId), 1000);
139
- } catch (error) {
140
- console.error("Failed to send message:", error);
141
- }
142
- };
143
-
144
- const handleKillWorker = async (workerId: string) => {
145
- if (!confirm("Kill this worker?")) return;
146
- try {
147
- await fetch(`/api/orchestrate/workers/${workerId}`, {
148
- method: "DELETE",
149
- });
150
- await refresh();
151
- } catch (error) {
152
- console.error("Failed to kill worker:", error);
153
- }
154
- };
155
-
156
- if (loading) {
157
- return (
158
- <div className="flex h-full items-center justify-center">
159
- <Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
160
- </div>
161
- );
162
- }
163
-
164
- if (workers.length === 0) {
165
- return (
166
- <div className="text-muted-foreground flex h-full flex-col items-center justify-center">
167
- <Users className="mb-4 h-12 w-12 opacity-50" />
168
- <p className="text-lg font-medium">No workers yet</p>
169
- <p className="text-sm">
170
- This conductor hasn&apos;t spawned any workers.
171
- </p>
172
- <p className="mt-4 max-w-md text-center text-xs">
173
- Use the MCP tools or API to spawn workers. The conductor can delegate
174
- tasks to parallel worker sessions.
175
- </p>
176
- </div>
177
- );
178
- }
179
-
180
- return (
181
- <div className="flex h-full flex-col">
182
- {/* Header with summary */}
183
- <div className="flex items-center justify-between border-b p-4">
184
- <div className="flex items-center gap-4">
185
- <div className="flex items-center gap-2">
186
- <Users className="text-primary h-5 w-5" />
187
- <span className="font-semibold">Workers</span>
188
- <span className="text-muted-foreground">
189
- ({summary?.total || workers.length})
190
- </span>
191
- </div>
192
-
193
- {summary && (
194
- <div className="flex items-center gap-3 text-sm">
195
- {summary.running > 0 && (
196
- <div className="flex items-center gap-1 text-green-500">
197
- <Loader2 className="h-3 w-3 animate-spin" />
198
- <span>{summary.running} running</span>
199
- </div>
200
- )}
201
- {summary.waiting > 0 && (
202
- <div className="flex items-center gap-1 text-yellow-500">
203
- <AlertCircle className="h-3 w-3" />
204
- <span>{summary.waiting} waiting</span>
205
- </div>
206
- )}
207
- {summary.completed > 0 && (
208
- <div className="flex items-center gap-1 text-green-500">
209
- <CheckCircle className="h-3 w-3" />
210
- <span>{summary.completed} done</span>
211
- </div>
212
- )}
213
- {summary.failed > 0 && (
214
- <div className="flex items-center gap-1 text-red-500">
215
- <XCircle className="h-3 w-3" />
216
- <span>{summary.failed} failed</span>
217
- </div>
218
- )}
219
- </div>
220
- )}
221
- </div>
222
-
223
- <Button
224
- variant="ghost"
225
- size="icon-sm"
226
- onClick={refresh}
227
- disabled={refreshing}
228
- >
229
- <RefreshCw className={cn("h-4 w-4", refreshing && "animate-spin")} />
230
- </Button>
231
- </div>
232
-
233
- {/* Workers grid */}
234
- <div className="flex-1 overflow-auto p-4">
235
- <div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
236
- {workers.map((worker) => (
237
- <WorkerCard
238
- key={worker.id}
239
- worker={worker}
240
- isExpanded={expandedWorkers.has(worker.id)}
241
- output={workerOutputs[worker.id]}
242
- onToggleExpand={() => toggleExpand(worker.id)}
243
- onViewOutput={() => {
244
- toggleExpand(worker.id);
245
- fetchWorkerOutput(worker.id);
246
- }}
247
- onSendMessage={(msg) => handleSendMessage(worker.id, msg)}
248
- onKill={() => handleKillWorker(worker.id)}
249
- onAttach={
250
- onAttachToWorker ? () => onAttachToWorker(worker.id) : undefined
251
- }
252
- />
253
- ))}
254
- </div>
255
- </div>
256
- </div>
257
- );
258
- }