@saadnvd1/agent-os 0.3.851 → 0.3.859

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 (128) hide show
  1. package/app/api/leads/adopt/route.ts +35 -0
  2. package/app/api/leads/proposals/route.ts +52 -0
  3. package/app/api/leads/route.test.ts +95 -0
  4. package/app/api/leads/route.ts +60 -0
  5. package/app/api/sessions/[id]/fresh/route.ts +2 -1
  6. package/app/api/sessions/[id]/route.ts +9 -1
  7. package/app/api/tmux/kill-all/route.ts +2 -1
  8. package/app/api/workspaces/[id]/orchestrator/tools/route.test.ts +43 -0
  9. package/app/api/workspaces/[id]/orchestrator/tools/route.ts +14 -11
  10. package/components/Analyst/pair.ts +2 -1
  11. package/components/Chat/ChatPanel.tsx +1 -0
  12. package/components/Chat/Forks.tsx +24 -2
  13. package/components/Orchestrator/AskCard.tsx +6 -0
  14. package/components/Orchestrator/NeedsYouStrip.tsx +7 -2
  15. package/components/Pane/index.tsx +5 -7
  16. package/components/Schedules/ScheduleForm.tsx +2 -1
  17. package/components/SessionList/RowSubtitle.tsx +2 -1
  18. package/components/SessionList/SessionRow.tsx +2 -1
  19. package/components/SessionList/useDoneAction.ts +8 -2
  20. package/components/SessionList/useSidebarData.ts +8 -3
  21. package/content/whats-new.json +24 -0
  22. package/data/done/index.ts +7 -1
  23. package/hooks/useNeedsYou.ts +10 -4
  24. package/hooks/useOrchestratorHeader.ts +2 -1
  25. package/lib/analyst/createLink.ts +3 -1
  26. package/lib/away/report.ts +2 -1
  27. package/lib/bus/format.ts +4 -1
  28. package/lib/bus/index.ts +9 -2
  29. package/lib/bus/read-scope.ts +4 -3
  30. package/lib/bus/resolve.ts +4 -1
  31. package/lib/bus/role-alias.ts +2 -1
  32. package/lib/bus/targets.ts +8 -3
  33. package/lib/bus/waits/tick.ts +2 -1
  34. package/lib/chat/compact-context.ts +2 -1
  35. package/lib/chat/mention-roots.ts +2 -1
  36. package/lib/chat/runner.ts +3 -2
  37. package/lib/chat/settings.ts +3 -2
  38. package/lib/chat/worker/extras.ts +4 -1
  39. package/lib/chat/worker/host.ts +6 -5
  40. package/lib/db/migrations/20261102T1944_leads.ts +142 -0
  41. package/lib/db/migrations/20261102T2300_needs_you_chat_per_session.ts +24 -0
  42. package/lib/db/session-parts/columns.ts +2 -0
  43. package/lib/db/types.ts +10 -2
  44. package/lib/done/archive.ts +3 -2
  45. package/lib/done/auto-archive.ts +2 -1
  46. package/lib/done/bulk.ts +4 -3
  47. package/lib/done/index.ts +3 -2
  48. package/lib/done/plan.ts +5 -2
  49. package/lib/experiments/registry.ts +11 -0
  50. package/lib/experiments/view.ts +3 -1
  51. package/lib/fork/brief.ts +2 -2
  52. package/lib/fork/extras.ts +4 -2
  53. package/lib/fork/guard.ts +4 -2
  54. package/lib/fork/scratch.ts +102 -12
  55. package/lib/fork/settle.ts +31 -7
  56. package/lib/fork/start.ts +5 -4
  57. package/lib/fork/tick.ts +19 -0
  58. package/lib/fork/types.ts +2 -0
  59. package/lib/fork/view.ts +10 -0
  60. package/lib/fork/worktree.ts +7 -3
  61. package/lib/health/env/sendFixNotes.ts +3 -2
  62. package/lib/hosts/peer-actions.ts +2 -1
  63. package/lib/hosts/peer-sessions.ts +3 -1
  64. package/lib/leads/actor.ts +16 -0
  65. package/lib/leads/adopt.ts +150 -0
  66. package/lib/leads/area.ts +77 -0
  67. package/lib/leads/brief-common.ts +122 -0
  68. package/lib/leads/brief.ts +155 -0
  69. package/lib/leads/charters.ts +99 -0
  70. package/lib/leads/deliver.ts +24 -0
  71. package/lib/leads/digest.ts +166 -0
  72. package/lib/leads/direction.ts +202 -0
  73. package/lib/leads/home.ts +196 -0
  74. package/lib/leads/index.ts +8 -0
  75. package/lib/leads/memos.ts +70 -0
  76. package/lib/leads/messages.ts +112 -0
  77. package/lib/leads/profiles.ts +83 -0
  78. package/lib/leads/roles.ts +114 -0
  79. package/lib/leads/settings.ts +55 -0
  80. package/lib/leads/templates.ts +51 -0
  81. package/lib/leads/tools.ts +159 -0
  82. package/lib/leads/types.ts +88 -0
  83. package/lib/leads/usage.ts +46 -0
  84. package/lib/learning-cards/input.ts +2 -1
  85. package/lib/live/drive.ts +2 -1
  86. package/lib/models/launch.ts +2 -1
  87. package/lib/models/session-role.ts +2 -1
  88. package/lib/needs-you.ts +12 -6
  89. package/lib/notify/index.ts +2 -1
  90. package/lib/orchestrator/act.ts +3 -1
  91. package/lib/orchestrator/ask-tool.ts +10 -0
  92. package/lib/orchestrator/ask-view.ts +2 -0
  93. package/lib/orchestrator/asks.ts +48 -8
  94. package/lib/orchestrator/brief.ts +32 -67
  95. package/lib/orchestrator/digest.ts +3 -2
  96. package/lib/orchestrator/facts.ts +2 -1
  97. package/lib/orchestrator/full-access.ts +2 -1
  98. package/lib/orchestrator/home.ts +8 -7
  99. package/lib/orchestrator/mcp-servers.ts +7 -6
  100. package/lib/orchestrator/notes.ts +15 -4
  101. package/lib/orchestrator/overview.ts +3 -1
  102. package/lib/orchestrator/recipe-tools.ts +2 -1
  103. package/lib/orchestrator/reminders.ts +2 -1
  104. package/lib/orchestrator/tools.ts +43 -13
  105. package/lib/orchestrator/worker.ts +45 -13
  106. package/lib/orchestrator/write-guard.ts +2 -2
  107. package/lib/projects.ts +3 -2
  108. package/lib/resurrect/find.ts +2 -1
  109. package/lib/resurrect/index.ts +3 -2
  110. package/lib/schedules/index.ts +3 -2
  111. package/lib/schedules/start.ts +2 -1
  112. package/lib/schedules/store.ts +3 -2
  113. package/lib/session-audit/sources.ts +4 -3
  114. package/lib/session-groups/workspace.ts +2 -1
  115. package/lib/sessions/launch.ts +2 -1
  116. package/lib/sidebar/machines.ts +2 -1
  117. package/lib/sidebar/row-actions.ts +2 -1
  118. package/lib/sidebar/row-badge.ts +3 -1
  119. package/lib/sidebar/row-menu.ts +6 -3
  120. package/lib/sidebar/shelves.ts +3 -3
  121. package/lib/status/collect.ts +4 -3
  122. package/lib/tasks/held-replies.ts +2 -1
  123. package/lib/tasks/move-targets.ts +2 -1
  124. package/lib/tasks/pause.ts +2 -1
  125. package/lib/tasks/starter.ts +3 -2
  126. package/lib/workspaces.ts +4 -3
  127. package/lib/worktree-sweep/findSettled.ts +2 -1
  128. package/package.json +1 -1
@@ -0,0 +1,35 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { experimentOn } from "@/lib/experiments";
3
+ import { requireApprover } from "@/lib/security/approver";
4
+ import { getWorkspace } from "@/lib/workspaces";
5
+ import { adoptLead } from "@/lib/leads/adopt";
6
+
7
+ // Adopt an existing chat as one of a workspace's leads. It keeps its id,
8
+ // its chat and its conversation; see lib/leads/adopt.
9
+ export async function POST(request: NextRequest) {
10
+ const gate = requireApprover(request);
11
+ if (!gate.ok) return gate.response;
12
+ if (!experimentOn("leads"))
13
+ return NextResponse.json(
14
+ { error: "Leads is off (Settings → Experiments)" },
15
+ { status: 409 }
16
+ );
17
+ const body = await request.json().catch(() => ({}));
18
+ const { sessionId, workspaceId, kind } = body as Record<string, unknown>;
19
+ if (typeof sessionId !== "string" || typeof kind !== "string")
20
+ return NextResponse.json(
21
+ { error: "Give sessionId, workspaceId and kind" },
22
+ { status: 400 }
23
+ );
24
+ if (typeof workspaceId !== "string" || !getWorkspace(workspaceId))
25
+ return NextResponse.json({ error: "Unknown workspace" }, { status: 404 });
26
+ try {
27
+ const session = await adoptLead({ sessionId, workspaceId, kind });
28
+ return NextResponse.json({ session });
29
+ } catch (error) {
30
+ return NextResponse.json(
31
+ { error: error instanceof Error ? error.message : String(error) },
32
+ { status: 400 }
33
+ );
34
+ }
35
+ }
@@ -0,0 +1,52 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { requireApprover } from "@/lib/security/approver";
3
+ import { getWorkspace } from "@/lib/workspaces";
4
+ import { isLeadKind } from "@/lib/leads/roles";
5
+ import { openProposals } from "@/lib/leads/direction";
6
+ import { confirmPins } from "@/lib/leads/adopt";
7
+
8
+ const bad = (error: string, status = 400) =>
9
+ NextResponse.json({ error }, { status });
10
+
11
+ // A lead's pins waiting for the owner.
12
+ export async function GET(request: NextRequest) {
13
+ const q = request.nextUrl.searchParams;
14
+ const workspaceId = q.get("workspaceId") ?? "";
15
+ const kind = q.get("kind") ?? "";
16
+ if (!getWorkspace(workspaceId)) return bad("Unknown workspace", 404);
17
+ if (!isLeadKind(kind)) return bad("Unknown lead");
18
+ return NextResponse.json({ proposals: openProposals(workspaceId, kind) });
19
+ }
20
+
21
+ // The owner's one tap: the confirmed proposals become pinned direction, the
22
+ // rest are dropped, and with switchOver the lead starts fresh on its memo
23
+ // and direction (an adopted lead's switch).
24
+ export async function POST(request: NextRequest) {
25
+ const gate = requireApprover(request);
26
+ if (!gate.ok) return gate.response;
27
+ const body = (await request.json().catch(() => ({}))) as Record<
28
+ string,
29
+ unknown
30
+ >;
31
+ const { workspaceId, kind, confirm, switchOver } = body;
32
+ if (typeof workspaceId !== "string" || !getWorkspace(workspaceId))
33
+ return bad("Unknown workspace", 404);
34
+ if (!isLeadKind(kind)) return bad("Unknown lead");
35
+ if (
36
+ !Array.isArray(confirm) ||
37
+ !confirm.every((n) => Number.isInteger(n) && n > 0)
38
+ )
39
+ return bad("confirm is a list of proposal ids");
40
+ try {
41
+ return NextResponse.json(
42
+ await confirmPins({
43
+ workspaceId,
44
+ kind,
45
+ confirm,
46
+ switchOver: switchOver === true,
47
+ })
48
+ );
49
+ } catch (error) {
50
+ return bad(error instanceof Error ? error.message : String(error));
51
+ }
52
+ }
@@ -0,0 +1,95 @@
1
+ import { NextRequest } from "next/server";
2
+ import { beforeEach, describe, expect, it } from "vitest";
3
+ import { db } from "@/lib/db";
4
+ import { setExperiment } from "@/lib/experiments";
5
+ import { TRUST_HEADER } from "@/lib/security/auth";
6
+ import { seedWorkspace } from "@/lib/orchestrator/testing";
7
+ import { GET, POST } from "./route";
8
+ import * as adopt from "./adopt/route";
9
+ import * as proposals from "./proposals/route";
10
+
11
+ const post = (
12
+ handler: (r: NextRequest) => Promise<Response>,
13
+ path: string,
14
+ body: unknown,
15
+ trust: string | null = "loopback"
16
+ ) =>
17
+ handler(
18
+ new NextRequest(`http://localhost/api/leads${path}`, {
19
+ method: "POST",
20
+ body: JSON.stringify(body),
21
+ headers: trust ? { [TRUST_HEADER]: trust } : {},
22
+ })
23
+ );
24
+
25
+ beforeEach(() => {
26
+ db.prepare(`DELETE FROM settings WHERE key LIKE 'experiments.%'`).run();
27
+ });
28
+
29
+ describe("/api/leads", () => {
30
+ it("refuses while Leads is off, and without an approver", async () => {
31
+ const { workspace } = seedWorkspace();
32
+ const body = { workspaceId: workspace.id, kinds: ["marketing"] };
33
+ expect((await post(POST, "", body)).status).toBe(409);
34
+ expect(
35
+ (
36
+ await post(adopt.POST, "/adopt", {
37
+ ...body,
38
+ sessionId: "x",
39
+ kind: "marketing",
40
+ })
41
+ ).status
42
+ ).toBe(409);
43
+ setExperiment("leads", true);
44
+ expect((await post(POST, "", body, null)).status).toBe(403);
45
+ expect((await post(proposals.POST, "/proposals", body, null)).status).toBe(
46
+ 403
47
+ );
48
+ });
49
+
50
+ it("adds leads and lists them", async () => {
51
+ setExperiment("leads", true);
52
+ const { workspace } = seedWorkspace();
53
+ const res = await post(POST, "", {
54
+ workspaceId: workspace.id,
55
+ kinds: ["marketing"],
56
+ });
57
+ expect(res.status).toBe(200);
58
+ expect(
59
+ (await res.json()).leads.map((l: { kind: string }) => l.kind)
60
+ ).toEqual(["tech", "marketing"]);
61
+ const list = await (
62
+ await GET(new NextRequest("http://localhost/api/leads"))
63
+ ).json();
64
+ expect(list.enabled).toBe(true);
65
+ expect(
66
+ list.workspaces.map((w: { workspaceId: string }) => w.workspaceId)
67
+ ).toContain(workspace.id);
68
+ const bad = await post(POST, "", {
69
+ workspaceId: workspace.id,
70
+ kinds: ["No Way"],
71
+ });
72
+ expect(bad.status).toBe(400);
73
+ expect(
74
+ (await post(POST, "", { workspaceId: "nope", kinds: [] })).status
75
+ ).toBe(404);
76
+ });
77
+
78
+ it("lists a lead's open proposals and refuses an unknown lead's", async () => {
79
+ setExperiment("leads", true);
80
+ const { workspace } = seedWorkspace();
81
+ await post(POST, "", { workspaceId: workspace.id, kinds: ["product"] });
82
+ const res = await proposals.GET(
83
+ new NextRequest(
84
+ `http://localhost/api/leads/proposals?workspaceId=${workspace.id}&kind=product`
85
+ )
86
+ );
87
+ expect((await res.json()).proposals).toEqual([]);
88
+ const none = await post(proposals.POST, "/proposals", {
89
+ workspaceId: workspace.id,
90
+ kind: "design",
91
+ confirm: [],
92
+ });
93
+ expect(none.status).toBe(400);
94
+ });
95
+ });
@@ -0,0 +1,60 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { experimentOn } from "@/lib/experiments";
3
+ import { requireApprover } from "@/lib/security/approver";
4
+ import { getWorkspace } from "@/lib/workspaces";
5
+ import { sessionById } from "@/lib/bus/targets";
6
+ import { sessionWorkspace } from "@/lib/schedules/store";
7
+ import { workspaceLeads, workspacesWithLeads } from "@/lib/leads/settings";
8
+ import { addLeads, leadViews } from "@/lib/leads/home";
9
+
10
+ const view = (workspaceId: string) => {
11
+ const w = workspaceLeads(workspaceId)!;
12
+ return {
13
+ workspaceId,
14
+ workspace: getWorkspace(workspaceId)?.name ?? "?",
15
+ chiefAutonomy: w.chiefAutonomy,
16
+ leads: leadViews(workspaceId),
17
+ };
18
+ };
19
+
20
+ // The workspaces with leads, and each one's leads. With `from` (a session),
21
+ // only that session's workspace, unless `all`.
22
+ export async function GET(request: NextRequest) {
23
+ const q = request.nextUrl.searchParams;
24
+ const from = sessionById(q.get("from"));
25
+ const own = from && !q.has("all") ? sessionWorkspace(from) : null;
26
+ const workspaces = workspacesWithLeads()
27
+ .filter((w) => !own || w.workspaceId === own)
28
+ .map((w) => view(w.workspaceId));
29
+ return NextResponse.json({
30
+ enabled: experimentOn("leads"),
31
+ workspace: own,
32
+ workspaces,
33
+ });
34
+ }
35
+
36
+ // "Add leads" on a workspace: its Tech lead (its orchestrator) and the
37
+ // leads picked. Safe to repeat.
38
+ export async function POST(request: NextRequest) {
39
+ const gate = requireApprover(request);
40
+ if (!gate.ok) return gate.response;
41
+ if (!experimentOn("leads"))
42
+ return NextResponse.json(
43
+ { error: "Leads is off (Settings → Experiments)" },
44
+ { status: 409 }
45
+ );
46
+ const body = await request.json().catch(() => ({}));
47
+ const workspaceId =
48
+ typeof body.workspaceId === "string" ? body.workspaceId : "";
49
+ if (!getWorkspace(workspaceId))
50
+ return NextResponse.json({ error: "Unknown workspace" }, { status: 404 });
51
+ try {
52
+ addLeads(workspaceId, body.kinds ?? []);
53
+ return NextResponse.json(view(workspaceId));
54
+ } catch (error) {
55
+ return NextResponse.json(
56
+ { error: error instanceof Error ? error.message : String(error) },
57
+ { status: 400 }
58
+ );
59
+ }
60
+ }
@@ -1,3 +1,4 @@
1
+ import { isStandingRole } from "@/lib/leads/roles";
1
2
  import { NextResponse } from "next/server";
2
3
  import { queries, db, type Session } from "@/lib/db";
3
4
  import { FreshRefusal, freshStartChat } from "@/lib/chat/runner";
@@ -14,7 +15,7 @@ export async function POST(
14
15
  const session = queries.getSession(db).get(id) as Session | undefined;
15
16
  if (!session)
16
17
  return NextResponse.json({ error: "Session not found" }, { status: 404 });
17
- if (session.role !== "orchestrator" && !session.task_brief)
18
+ if (!isStandingRole(session.role) && !session.task_brief)
18
19
  return NextResponse.json(
19
20
  { error: "Only an orchestrator or a task can start fresh" },
20
21
  { status: 400 }
@@ -1,3 +1,5 @@
1
+ import { isStandingRole } from "@/lib/leads/roles";
2
+ import { removeFork } from "@/lib/fork/scratch";
1
3
  import { removeSessionAttachments } from "@/lib/attachments/store";
2
4
  import { NextRequest, NextResponse } from "next/server";
3
5
  import { deletionRefusal } from "@/lib/orchestrator/home";
@@ -208,7 +210,7 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
208
210
  }
209
211
 
210
212
  let clearedGroup: string | null = null;
211
- if (typeof body.projectId === "string" && !existing.role) {
213
+ if (typeof body.projectId === "string" && !isStandingRole(existing.role)) {
212
214
  if (!getProject(body.projectId))
213
215
  return NextResponse.json(
214
216
  { error: "Project not found" },
@@ -307,6 +309,12 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
307
309
  }, `cleanup-worktree-${id}`);
308
310
  }
309
311
 
312
+ // A fork child's own folders go with it: deleting it is the ask.
313
+ runInBackground(async () => {
314
+ await agentEnded;
315
+ await removeFork(id);
316
+ }, `cleanup-fork-${id}`);
317
+
310
318
  // Also cleanup worker worktrees in background
311
319
  if (workers.length > 0) {
312
320
  for (const worker of workers) {
@@ -1,3 +1,4 @@
1
+ import { isStandingRole } from "@/lib/leads/roles";
1
2
  import { NextResponse } from "next/server";
2
3
  import { getDb, queries, type Session } from "@/lib/db";
3
4
  import { getManagedSessionPattern } from "@/lib/providers/registry";
@@ -36,7 +37,7 @@ export async function POST() {
36
37
  // Delete every session from the database but the workspaces'
37
38
  // orchestrators, which are never swept.
38
39
  const dbSessions = (queries.getAllSessions(db).all() as Session[]).filter(
39
- (s) => s.role !== "orchestrator"
40
+ (s) => !isStandingRole(s.role)
40
41
  );
41
42
  for (const session of dbSessions) {
42
43
  try {
@@ -0,0 +1,43 @@
1
+ import { NextRequest } from "next/server";
2
+ import { describe, expect, it } from "vitest";
3
+ import { seedWorkspace } from "@/lib/orchestrator/testing";
4
+ import { TOKEN_HEADER } from "@/lib/orchestrator/tools";
5
+ import { addLeads, getLead, standingToken } from "@/lib/leads/home";
6
+ import { latestMemo } from "@/lib/leads/memos";
7
+ import { POST } from "./route";
8
+
9
+ const call = (w: string, token: string | null, tool: string, args = {}) =>
10
+ POST(
11
+ new NextRequest(`http://localhost/api/workspaces/${w}/orchestrator/tools`, {
12
+ method: "POST",
13
+ body: JSON.stringify({ tool, args }),
14
+ headers: token ? { [TOKEN_HEADER]: token } : {},
15
+ }),
16
+ { params: Promise.resolve({ id: w }) }
17
+ );
18
+
19
+ describe("the standing sessions' tools", () => {
20
+ it("knows the caller by its token and keeps Tech's tools Tech's", async () => {
21
+ const { workspace } = seedWorkspace();
22
+ const w = workspace.id;
23
+ const [tech] = addLeads(w, ["marketing"]);
24
+ const marketing = getLead(w, "marketing")!;
25
+ const mine = standingToken(marketing.id);
26
+ expect((await call(w, null, "sessions")).status).toBe(403);
27
+ expect((await call(w, "wrong", "sessions")).status).toBe(403);
28
+ expect((await call(w, mine, "sessions")).status).toBe(200);
29
+ expect((await call(w, mine, "sign_off", { task: "x" })).status).toBe(403);
30
+ expect((await call(w, mine, "nonsense")).status).toBe(400);
31
+ const memo = await call(w, mine, "update_memo", { text: "Mine" });
32
+ expect(memo.status).toBe(200);
33
+ expect(latestMemo(w, "marketing")?.text).toBe("Mine");
34
+ // Tech's token isn't refused sign_off as not its own.
35
+ const techCall = await call(w, standingToken(tech.id), "sign_off", {
36
+ task: "x",
37
+ });
38
+ expect(techCall.status).toBe(400);
39
+ // Another workspace's token is no one here.
40
+ const other = seedWorkspace().workspace.id;
41
+ expect((await call(other, mine, "sessions")).status).toBe(403);
42
+ });
43
+ });
@@ -1,32 +1,35 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
- import { isToolName, runTool } from "@/lib/orchestrator/serve";
3
- import { isOrchestratorToken } from "@/lib/orchestrator/home";
4
2
  import { TOKEN_HEADER } from "@/lib/orchestrator/tools";
3
+ import { standingByToken } from "@/lib/leads/home";
4
+ import { NotYours, runStandingTool, UnknownTool } from "@/lib/leads/tools";
5
5
 
6
6
  type RouteParams = { params: Promise<{ id: string }> };
7
7
 
8
8
  const LEGACY_ASK = "ask_saad";
9
9
 
10
- // The orchestrator chat's tools call here from its worker: plain text back.
11
- // Only that workspace's orchestrator worker holds the secret: other local
12
- // sessions are trusted by the network, not to act as the orchestrator.
10
+ // The orchestrator chat's tools, and its leads', call here from their
11
+ // workers: plain text back. Each standing session's worker holds its own
12
+ // secret, which says who is calling: other local sessions are trusted by
13
+ // the network, not to act as the orchestrator or a lead.
13
14
  export async function POST(request: NextRequest, { params }: RouteParams) {
14
15
  const { id } = await params;
15
- if (!isOrchestratorToken(id, request.headers.get(TOKEN_HEADER)))
16
- return new NextResponse("Not the orchestrator", { status: 403 });
16
+ const caller = standingByToken(id, request.headers.get(TOKEN_HEADER));
17
+ if (!caller) return new NextResponse("Not the orchestrator", { status: 403 });
17
18
  const body = await request.json().catch(() => ({}));
18
19
  const { args } = body;
19
20
  // A worker started before the rename still calls ask_saad.
20
21
  const tool = body.tool === LEGACY_ASK ? "ask_owner" : body.tool;
21
- if (!isToolName(tool))
22
- return NextResponse.json({ error: "Unknown tool" }, { status: 400 });
23
22
  try {
24
- const text = await runTool(id, tool, args ?? {});
23
+ const text = await runStandingTool(id, caller, tool, args ?? {});
25
24
  return new NextResponse(text, {
26
25
  headers: { "Content-Type": "text/plain; charset=utf-8" },
27
26
  });
28
27
  } catch (error) {
28
+ if (error instanceof UnknownTool)
29
+ return NextResponse.json({ error: "Unknown tool" }, { status: 400 });
29
30
  const message = error instanceof Error ? error.message : String(error);
30
- return new NextResponse(message, { status: 400 });
31
+ return new NextResponse(message, {
32
+ status: error instanceof NotYours ? 403 : 400,
33
+ });
31
34
  }
32
35
  }
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
 
3
+ import { isStandingRole } from "@/lib/leads/roles";
3
4
  import { useCallback } from "react";
4
5
  import { toast } from "sonner";
5
6
  import { usePanes } from "@/contexts/PaneContext";
@@ -72,7 +73,7 @@ export function useCanAddAnalyst(session: Session | null | undefined) {
72
73
  const paired = useSessionAnalyst(chat?.id).data;
73
74
  return (
74
75
  !!chat &&
75
- !chat.role &&
76
+ !isStandingRole(chat.role) &&
76
77
  !chat.archived_at &&
77
78
  (chat.host_id || "local") === "local" &&
78
79
  chat.agent_role !== "analyst" &&
@@ -649,6 +649,7 @@ export function ChatPanel({
649
649
  {orchestratorOf && (
650
650
  <NeedsYouStrip
651
651
  workspaceId={orchestratorOf}
652
+ sessionId={sessionId}
652
653
  chatWaiting={state === "waiting"}
653
654
  onGo={(item) => {
654
655
  if (item.kind === "ask") needsYouFocusActions.ask(item.ask.id);
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { useState } from "react";
4
- import { GitFork, Square } from "lucide-react";
4
+ import { CircleCheck, GitFork, Square } from "lucide-react";
5
5
  import { toast } from "sonner";
6
6
  import { useExperiment } from "@/data/experiments";
7
7
  import {
@@ -13,6 +13,7 @@ import {
13
13
  import { formatElapsed } from "@/lib/chat/elapsed";
14
14
  import { Button } from "@/components/ui/button";
15
15
  import { StatusPill } from "@/components/ui/status-pill";
16
+ import { useDoneAction } from "@/components/SessionList/useDoneAction";
16
17
  import { SessionAnchor } from "./SessionAnchor";
17
18
 
18
19
  type Tone = "amber" | "muted" | "green" | "primary" | "red";
@@ -73,12 +74,14 @@ export const runningNote = (c: ChildView, now = Date.now()): string | null => {
73
74
  function Row({
74
75
  c,
75
76
  onStop,
77
+ onDone,
76
78
  stopping,
77
79
  again,
78
80
  }: {
79
81
  c: ChildView;
80
82
  again: ReturnType<typeof useRunAgain>;
81
83
  onStop: () => void;
84
+ onDone: () => void;
82
85
  stopping: boolean;
83
86
  }) {
84
87
  const { text, tone } = stateLabel(c);
@@ -144,6 +147,16 @@ function Row({
144
147
  <Square aria-hidden className="size-4" />
145
148
  </Button>
146
149
  )}
150
+ <Button
151
+ size="sm"
152
+ variant="ghost"
153
+ aria-label={`Mark fork ${c.n} done`}
154
+ data-tip="Done"
155
+ className="size-11 shrink-0"
156
+ onClick={onDone}
157
+ >
158
+ <CircleCheck aria-hidden className="size-4" />
159
+ </Button>
147
160
  </li>
148
161
  );
149
162
  }
@@ -154,10 +167,11 @@ export function ForksStrip({ sessionId }: { sessionId: string }) {
154
167
  const { data } = useForkChildren(sessionId, on);
155
168
  const cancel = useCancelForks(sessionId);
156
169
  const again = useRunAgain(sessionId);
170
+ const markDone = useDoneAction();
157
171
  const [collapsed, setCollapsed] = useState(false);
158
172
  if (!on || !data) return null;
159
173
  if (data.asChild) return <ForkOfBanner info={data.asChild} />;
160
- const { children } = data;
174
+ const children = data.children.filter((c) => !c.done);
161
175
  if (!children.length) return null;
162
176
  const open = children.filter((c) => c.state !== "settled").length;
163
177
  const stop = (ref?: string) =>
@@ -206,6 +220,14 @@ export function ForksStrip({ sessionId }: { sessionId: string }) {
206
220
  stopping={cancel.isPending}
207
221
  again={again}
208
222
  onStop={() => stop(String(c.n))}
223
+ onDone={() =>
224
+ markDone({
225
+ id: c.id,
226
+ name: c.label,
227
+ task_status: null,
228
+ pr_status: null,
229
+ })
230
+ }
209
231
  />
210
232
  ))}
211
233
  </ul>
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
 
3
+ import { TECH } from "@/lib/leads/types";
3
4
  import { useState, useSyncExternalStore } from "react";
4
5
  import { Check, CornerDownLeft, ExternalLink, X } from "lucide-react";
5
6
  import { Button } from "@/components/ui/button";
@@ -84,6 +85,11 @@ export function AskCard({
84
85
  {kind.label}
85
86
  </span>
86
87
  </div>
88
+ {ask.leadKind !== TECH && (
89
+ <p className="text-muted-foreground text-[11px] capitalize">
90
+ {ask.leadKind} lead
91
+ </p>
92
+ )}
87
93
  {ask.why && (
88
94
  <p
89
95
  className={cn(
@@ -40,11 +40,16 @@ export interface NeedsYouActions {
40
40
  // it is resolved. Dismissing an item only hides it; an ask stays open.
41
41
  export const NeedsYouStrip = memo(function NeedsYouStrip({
42
42
  workspaceId,
43
+ sessionId,
43
44
  chatWaiting,
44
45
  onGo,
45
46
  onReply,
46
- }: NeedsYouActions & { workspaceId: string; chatWaiting: boolean }) {
47
- const items = useNeedsYou(workspaceId, chatWaiting);
47
+ }: NeedsYouActions & {
48
+ workspaceId: string;
49
+ sessionId: string;
50
+ chatWaiting: boolean;
51
+ }) {
52
+ const items = useNeedsYou(workspaceId, sessionId, chatWaiting);
48
53
  const dismiss = useDismissNeedsYou(workspaceId);
49
54
  const cards = useApplyCard();
50
55
  // The learning card opened in place.
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
 
3
+ import { isStandingRole, isTechRole } from "@/lib/leads/roles";
3
4
  import { hasPrompt } from "@/lib/sessions/has-prompt";
4
5
  import { useTaskLogQuery } from "@/data/session-logs";
5
6
  import { SetupCard } from "@/components/Chat/SetupCard";
@@ -183,7 +184,7 @@ export const Pane = memo(function Pane({
183
184
  );
184
185
  const showGit = showGitToggle(isLocalSession, isGitRepo);
185
186
  const showGitDrawer =
186
- gitDrawerOpen && showGit && session?.role !== "orchestrator";
187
+ gitDrawerOpen && showGit && !isStandingRole(session?.role);
187
188
  // The folder turned out not to be a repo: don't leave its git view open.
188
189
  useEffect(() => {
189
190
  if (isGitRepo !== false) return;
@@ -339,14 +340,11 @@ export const Pane = memo(function Pane({
339
340
  <ChatPanel
340
341
  sessionId={chat.id}
341
342
  sessionName={chat.name}
342
- accessLocked={chat.role === "orchestrator"}
343
+ accessLocked={isStandingRole(chat.role)}
343
344
  freshStart={
344
- (chat.role === "orchestrator" || !!chat.has_brief) &&
345
- !chat.archived_at
346
- }
347
- orchestratorOf={
348
- chat.role === "orchestrator" ? chat.workspace_id : null
345
+ (isStandingRole(chat.role) || !!chat.has_brief) && !chat.archived_at
349
346
  }
347
+ orchestratorOf={isTechRole(chat.role) ? chat.workspace_id : null}
350
348
  active={isActive && isFocused}
351
349
  />
352
350
  );
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
 
3
+ import { isTechRole } from "@/lib/leads/roles";
3
4
  import { useEffect, useMemo, useState } from "react";
4
5
  import { Button } from "@/components/ui/button";
5
6
  import { Input } from "@/components/ui/input";
@@ -226,7 +227,7 @@ export function ScheduleForm({
226
227
  .filter(
227
228
  (s) =>
228
229
  !s.archived_at &&
229
- s.role !== "orchestrator" &&
230
+ !isTechRole(s.role) &&
230
231
  (!s.task_status || s.task_status === "running") &&
231
232
  (s.workspace_id ??
232
233
  (s.project_id ? projectWs.get(s.project_id) : null)) === workspace
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
 
3
+ import { isTechRole } from "@/lib/leads/roles";
3
4
  import { Repeat, Layers, Pause, Hourglass } from "lucide-react";
4
5
  import { HostBadge } from "@/components/Hosts";
5
6
  import { rowGroup } from "@/lib/recipes/chip";
@@ -35,7 +36,7 @@ export function RowSubtitle({
35
36
  const ctx = useRowContext();
36
37
  // What its processes use, on a busy session: "3.1 cores · 4.2 GB".
37
38
  const usage = useSessionUsage(session.id);
38
- if (session.role === "orchestrator") {
39
+ if (isTechRole(session.role)) {
39
40
  const ws = session.workspace_id ?? "";
40
41
  const overview = ctx.orchestrators.find((o) => o.workspaceId === ws);
41
42
  const parts = headerParts({
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
 
3
+ import { isTechRole } from "@/lib/leads/roles";
3
4
  import { AGENT_ROLE_DEFS, isAgentRole } from "@/lib/models/agent-roles";
4
5
  import { isFork } from "@/lib/sessions/fork";
5
6
  import { memo, useState } from "react";
@@ -173,7 +174,7 @@ export const SessionRow = memo(function SessionRow({
173
174
  }
174
175
  />
175
176
  </RowShell>
176
- {session.role === "orchestrator" && row.need && (
177
+ {isTechRole(session.role) && row.need && (
177
178
  <OrchestratorAsks workspaceId={session.workspace_id} />
178
179
  )}
179
180
  {(row.workers.length > 0 || !!row.quick?.length) && (
@@ -9,12 +9,18 @@ import {
9
9
  } from "@/data/done";
10
10
  import { useSettleSession } from "@/data/sessions/shelf";
11
11
 
12
+ export type DoneTarget = Pick<
13
+ Session,
14
+ "id" | "name" | "task_status" | "pr_status"
15
+ > &
16
+ Partial<Pick<Session, "settled_at">>;
17
+
12
18
  // Done from a session's menu: an open PR merges through the gates, after an
13
19
  // in-app confirmation; a failing gate comes back as an error toast naming it.
14
20
  export function useDoneAction() {
15
21
  const done = useDoneSession();
16
22
  const settle = useSettleSession();
17
- const run = (session: Session, merges: boolean) => {
23
+ const run = (session: DoneTarget, merges: boolean) => {
18
24
  const id = toast.loading(
19
25
  merges ? `Merging ${session.name}…` : `Finishing ${session.name}…`
20
26
  );
@@ -38,7 +44,7 @@ export function useDoneAction() {
38
44
  onError: (e) => toast.error(e.message, { id, duration: 12000 }),
39
45
  });
40
46
  };
41
- return (session: Session) => {
47
+ return (session: DoneTarget) => {
42
48
  const merges =
43
49
  session.task_status === "running" && session.pr_status === "open";
44
50
  if (!merges) return run(session, false);