@saadnvd1/agent-os 0.3.853 → 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 (123) 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/Orchestrator/AskCard.tsx +6 -0
  13. package/components/Orchestrator/NeedsYouStrip.tsx +7 -2
  14. package/components/Pane/index.tsx +5 -7
  15. package/components/Schedules/ScheduleForm.tsx +2 -1
  16. package/components/SessionList/RowSubtitle.tsx +2 -1
  17. package/components/SessionList/SessionRow.tsx +2 -1
  18. package/components/SessionList/useSidebarData.ts +8 -3
  19. package/content/whats-new.json +16 -0
  20. package/hooks/useNeedsYou.ts +10 -4
  21. package/hooks/useOrchestratorHeader.ts +2 -1
  22. package/lib/analyst/createLink.ts +3 -1
  23. package/lib/away/report.ts +2 -1
  24. package/lib/bus/format.ts +4 -1
  25. package/lib/bus/index.ts +9 -2
  26. package/lib/bus/read-scope.ts +4 -3
  27. package/lib/bus/resolve.ts +4 -1
  28. package/lib/bus/role-alias.ts +2 -1
  29. package/lib/bus/targets.ts +8 -3
  30. package/lib/bus/waits/tick.ts +2 -1
  31. package/lib/chat/compact-context.ts +2 -1
  32. package/lib/chat/mention-roots.ts +2 -1
  33. package/lib/chat/runner.ts +3 -2
  34. package/lib/chat/settings.ts +3 -2
  35. package/lib/chat/worker/extras.ts +4 -1
  36. package/lib/chat/worker/host.ts +6 -5
  37. package/lib/db/migrations/20261102T1944_leads.ts +142 -0
  38. package/lib/db/migrations/20261102T2300_needs_you_chat_per_session.ts +24 -0
  39. package/lib/db/session-parts/columns.ts +2 -0
  40. package/lib/db/types.ts +10 -2
  41. package/lib/done/archive.ts +3 -2
  42. package/lib/done/auto-archive.ts +2 -1
  43. package/lib/done/bulk.ts +4 -3
  44. package/lib/done/index.ts +3 -2
  45. package/lib/done/plan.ts +5 -2
  46. package/lib/experiments/registry.ts +11 -0
  47. package/lib/experiments/view.ts +3 -1
  48. package/lib/fork/brief.ts +2 -2
  49. package/lib/fork/extras.ts +4 -2
  50. package/lib/fork/guard.ts +4 -2
  51. package/lib/fork/scratch.ts +102 -12
  52. package/lib/fork/settle.ts +31 -7
  53. package/lib/fork/start.ts +5 -4
  54. package/lib/fork/tick.ts +19 -0
  55. package/lib/fork/worktree.ts +7 -3
  56. package/lib/health/env/sendFixNotes.ts +3 -2
  57. package/lib/hosts/peer-actions.ts +2 -1
  58. package/lib/hosts/peer-sessions.ts +3 -1
  59. package/lib/leads/actor.ts +16 -0
  60. package/lib/leads/adopt.ts +150 -0
  61. package/lib/leads/area.ts +77 -0
  62. package/lib/leads/brief-common.ts +122 -0
  63. package/lib/leads/brief.ts +155 -0
  64. package/lib/leads/charters.ts +99 -0
  65. package/lib/leads/deliver.ts +24 -0
  66. package/lib/leads/digest.ts +166 -0
  67. package/lib/leads/direction.ts +202 -0
  68. package/lib/leads/home.ts +196 -0
  69. package/lib/leads/index.ts +8 -0
  70. package/lib/leads/memos.ts +70 -0
  71. package/lib/leads/messages.ts +112 -0
  72. package/lib/leads/profiles.ts +83 -0
  73. package/lib/leads/roles.ts +114 -0
  74. package/lib/leads/settings.ts +55 -0
  75. package/lib/leads/templates.ts +51 -0
  76. package/lib/leads/tools.ts +159 -0
  77. package/lib/leads/types.ts +88 -0
  78. package/lib/leads/usage.ts +46 -0
  79. package/lib/learning-cards/input.ts +2 -1
  80. package/lib/live/drive.ts +2 -1
  81. package/lib/models/launch.ts +2 -1
  82. package/lib/models/session-role.ts +2 -1
  83. package/lib/needs-you.ts +12 -6
  84. package/lib/notify/index.ts +2 -1
  85. package/lib/orchestrator/act.ts +3 -1
  86. package/lib/orchestrator/ask-tool.ts +10 -0
  87. package/lib/orchestrator/ask-view.ts +2 -0
  88. package/lib/orchestrator/asks.ts +48 -8
  89. package/lib/orchestrator/brief.ts +32 -67
  90. package/lib/orchestrator/digest.ts +3 -2
  91. package/lib/orchestrator/facts.ts +2 -1
  92. package/lib/orchestrator/full-access.ts +2 -1
  93. package/lib/orchestrator/home.ts +8 -7
  94. package/lib/orchestrator/mcp-servers.ts +7 -6
  95. package/lib/orchestrator/notes.ts +15 -4
  96. package/lib/orchestrator/overview.ts +3 -1
  97. package/lib/orchestrator/recipe-tools.ts +2 -1
  98. package/lib/orchestrator/reminders.ts +2 -1
  99. package/lib/orchestrator/tools.ts +43 -13
  100. package/lib/orchestrator/worker.ts +45 -13
  101. package/lib/orchestrator/write-guard.ts +2 -2
  102. package/lib/projects.ts +3 -2
  103. package/lib/resurrect/find.ts +2 -1
  104. package/lib/resurrect/index.ts +3 -2
  105. package/lib/schedules/index.ts +3 -2
  106. package/lib/schedules/start.ts +2 -1
  107. package/lib/schedules/store.ts +3 -2
  108. package/lib/session-audit/sources.ts +4 -3
  109. package/lib/session-groups/workspace.ts +2 -1
  110. package/lib/sessions/launch.ts +2 -1
  111. package/lib/sidebar/machines.ts +2 -1
  112. package/lib/sidebar/row-actions.ts +2 -1
  113. package/lib/sidebar/row-badge.ts +3 -1
  114. package/lib/sidebar/row-menu.ts +6 -3
  115. package/lib/sidebar/shelves.ts +3 -3
  116. package/lib/status/collect.ts +4 -3
  117. package/lib/tasks/held-replies.ts +2 -1
  118. package/lib/tasks/move-targets.ts +2 -1
  119. package/lib/tasks/pause.ts +2 -1
  120. package/lib/tasks/starter.ts +3 -2
  121. package/lib/workspaces.ts +4 -3
  122. package/lib/worktree-sweep/findSettled.ts +2 -1
  123. 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,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) && (
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
 
3
+ import { isStandingRole, isTechRole } from "@/lib/leads/roles";
3
4
  import { useCallback, useEffect, useMemo } from "react";
4
5
  import { useSnapshot } from "valtio";
5
6
  import type { Session } from "@/lib/db";
@@ -92,7 +93,7 @@ export function useSidebarData(
92
93
  const taskStates: Record<string, TaskState> = {};
93
94
  for (const t of tasks) taskStates[t.id] = t.state;
94
95
  const projectName = (s: Session) =>
95
- s.role === "orchestrator"
96
+ isTechRole(s.role)
96
97
  ? `Orchestrator · ${workspaceName.get(s.workspace_id ?? "") ?? ""}`
97
98
  : (byId.get(s.project_id ?? "")?.name ?? "");
98
99
  const common = {
@@ -167,7 +168,11 @@ export function useSidebarData(
167
168
  const counts = new Map<string, number>();
168
169
  for (const s of sessions) {
169
170
  const ws = byId.get(s.project_id ?? "");
170
- if (s.role || !ws || sessionStatuses?.[s.id]?.status !== "running")
171
+ if (
172
+ isStandingRole(s.role) ||
173
+ !ws ||
174
+ sessionStatuses?.[s.id]?.status !== "running"
175
+ )
171
176
  continue;
172
177
  counts.set(ws, (counts.get(ws) ?? 0) + 1);
173
178
  }
@@ -226,7 +231,7 @@ export function useSidebarData(
226
231
  // Orchestrator session id -> the workspace it serves, for tags and chips.
227
232
  const orchestratorKey = JSON.stringify(
228
233
  sessions
229
- .filter((s) => s.role === "orchestrator")
234
+ .filter((s) => isTechRole(s.role))
230
235
  .map((s) => [
231
236
  s.id,
232
237
  workspaces.find((w) => w.id === s.workspace_id)?.name ?? s.name,
@@ -2041,6 +2041,22 @@
2041
2041
  "area": "AgentOS",
2042
2042
  "text": "Mark a fork as done straight from the Forks list above the chat box.",
2043
2043
  "url": "https://github.com/saadnvd1/agent-os/pull/573"
2044
+ },
2045
+ {
2046
+ "id": 574,
2047
+ "date": "2026-11-02",
2048
+ "kind": "fixed",
2049
+ "area": "AgentOS",
2050
+ "text": "Files a forked session writes are no longer deleted when it finishes.",
2051
+ "url": "https://github.com/saadnvd1/agent-os/pull/574"
2052
+ },
2053
+ {
2054
+ "id": 576,
2055
+ "date": "2026-11-02",
2056
+ "kind": "improved",
2057
+ "area": "AgentOS",
2058
+ "text": "Heavy builds and test runs on Linux machines are now memory-capped, so one runaway command can no longer freeze the whole machine.",
2059
+ "url": "https://github.com/saadnvd1/agent-os/pull/576"
2044
2060
  }
2045
2061
  ]
2046
2062
  }
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
 
3
+ import { isStandingRole } from "@/lib/leads/roles";
3
4
  import { useMemo } from "react";
4
5
  import { useQuery } from "@tanstack/react-query";
5
6
  import { useSessionsQuery } from "@/data/sessions/queries";
@@ -15,9 +16,11 @@ import {
15
16
  } from "@/lib/orchestrator/needs-you-items";
16
17
 
17
18
  // Everything waiting on Saad in a workspace: its open asks and questions, the
18
- // tasks the sidebar marks as needing him, and its chat waiting on an answer.
19
+ // tasks the sidebar marks as needing him, and this chat waiting on an answer
20
+ // (keyed by the chat, so one lead's waiting chat never hides another's).
19
21
  export function useNeedsYou(
20
22
  workspaceId: string,
23
+ sessionId: string,
21
24
  chatWaiting: boolean
22
25
  ): NeedsYouItem[] {
23
26
  const overview = useOrchestratorOverview(workspaceId);
@@ -35,7 +38,10 @@ export function useNeedsYou(
35
38
  );
36
39
  return (sessions?.sessions ?? []).flatMap((s): TaskNeed[] => {
37
40
  const need = statuses?.statuses[s.id]?.need;
38
- return !s.role && s.project_id && members.has(s.project_id) && need
41
+ return !isStandingRole(s.role) &&
42
+ s.project_id &&
43
+ members.has(s.project_id) &&
44
+ need
39
45
  ? [{ sessionId: s.id, name: s.name, need }]
40
46
  : [];
41
47
  });
@@ -49,8 +55,8 @@ export function useNeedsYou(
49
55
  tasks,
50
56
  cards: cardsOn ? (overview?.cards ?? []) : [],
51
57
  chatWaiting,
52
- chatKey: `approval:${workspaceId}`,
58
+ chatKey: `approval:${sessionId}`,
53
59
  }),
54
- [overview, tasks, cardsOn, chatWaiting, workspaceId]
60
+ [overview, tasks, cardsOn, chatWaiting, sessionId]
55
61
  );
56
62
  }
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
 
3
+ import { isStandingRole } from "@/lib/leads/roles";
3
4
  import { useQuery } from "@tanstack/react-query";
4
5
  import { useSessionsQuery } from "@/data/sessions/queries";
5
6
  import { useProjectsQuery } from "@/data/projects/queries";
@@ -23,7 +24,7 @@ export function useWorkspaceRunning(workspaceId: string): number {
23
24
  );
24
25
  return (sessions?.sessions ?? []).filter(
25
26
  (s) =>
26
- !s.role &&
27
+ !isStandingRole(s.role) &&
27
28
  s.project_id &&
28
29
  members.has(s.project_id) &&
29
30
  statuses?.statuses[s.id]?.status === "running"