@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
@@ -14,7 +14,7 @@ import type { PermissionMode } from "@anthropic-ai/claude-agent-sdk";
14
14
  import os from "os";
15
15
  import path from "path";
16
16
  import { ORCHESTRATOR_PERMISSIONS } from "./tool-names";
17
- import { getOrchestrator } from "./home";
17
+ import { listLeads } from "../leads/home";
18
18
  import { getWorkspace } from "../workspaces";
19
19
  import { retireChat } from "../chat/runner";
20
20
 
@@ -92,10 +92,11 @@ export function configuredMcpServers(): string[] {
92
92
  return [...names].sort((a, b) => a.localeCompare(b));
93
93
  }
94
94
 
95
- // A workspace's allowed servers changed: its orchestrator's worker ends at its
96
- // next turn boundary and the next one starts with the new permissions.
95
+ // A workspace's allowed servers changed: its orchestrator's worker, and each
96
+ // lead's, ends at its next turn boundary and the next one starts with the
97
+ // new permissions.
97
98
  export async function applyMcpServers(workspaceId: string): Promise<void> {
98
- const orchestrator = getOrchestrator(workspaceId);
99
- if (orchestrator && getWorkspace(workspaceId))
100
- await retireChat(orchestrator.id).catch(() => {});
99
+ if (!getWorkspace(workspaceId)) return;
100
+ for (const lead of listLeads(workspaceId))
101
+ await retireChat(lead.id).catch(() => {});
101
102
  }
@@ -9,6 +9,10 @@ import { emit } from "../chat/registry";
9
9
  import { saveItem } from "../chat/store";
10
10
  import type { ChatItem } from "../chat/events";
11
11
  import { getOrchestrator } from "./home";
12
+ import { actorIn } from "../leads/actor";
13
+ import { getLead } from "../leads/home";
14
+ import { leadKindOf } from "../leads/roles";
15
+ import { TECH, type LeadKind } from "../leads/types";
12
16
 
13
17
  export type NoteKind =
14
18
  | "note"
@@ -32,16 +36,23 @@ const NOTE_CAP = 2000;
32
36
  export function addNote(
33
37
  workspaceId: string,
34
38
  text: string,
35
- kind: NoteKind = "note"
39
+ kind: NoteKind = "note",
40
+ // Whose log it goes in: the lead calling, else the one named, else Tech.
41
+ lead?: LeadKind
36
42
  ): NoteRow {
37
43
  const body = text.trim().slice(0, NOTE_CAP);
38
44
  if (!body) throw new Error("The note is empty");
45
+ const actor = actorIn(workspaceId);
46
+ const leadKind = lead ?? (actor && leadKindOf(actor)) ?? TECH;
39
47
  const { lastInsertRowid } = db
40
48
  .prepare(
41
- `INSERT INTO orchestrator_notes (workspace_id, kind, text) VALUES (?, ?, ?)`
49
+ `INSERT INTO orchestrator_notes (workspace_id, kind, text, lead_kind) VALUES (?, ?, ?, ?)`
42
50
  )
43
- .run(workspaceId, kind, body);
44
- const orchestrator = getOrchestrator(workspaceId);
51
+ .run(workspaceId, kind, body, leadKind);
52
+ const orchestrator =
53
+ leadKind === TECH
54
+ ? getOrchestrator(workspaceId)
55
+ : getLead(workspaceId, leadKind);
45
56
  if (orchestrator) {
46
57
  const item: ChatItem = {
47
58
  id: `note-${lastInsertRowid}-${randomUUID().slice(0, 5)}`,
@@ -4,6 +4,7 @@
4
4
  * counts come from the live session statuses on the client.
5
5
  */
6
6
 
7
+ import { sqlNoRole } from "../leads/roles";
7
8
  import { demoMode } from "../security/demo";
8
9
  import { db } from "../db";
9
10
  import { listWorkspaces } from "../workspaces";
@@ -26,7 +27,7 @@ function inReview(workspaceId: string): number {
26
27
  db
27
28
  .prepare(
28
29
  `SELECT COUNT(*) AS n FROM session_rows s JOIN projects p ON p.id = s.project_id
29
- WHERE p.workspace_id = ? AND s.role IS NULL
30
+ WHERE p.workspace_id = ? AND ${sqlNoRole("s.role")}
30
31
  AND s.task_status = 'running' AND s.pr_status = 'open'`
31
32
  )
32
33
  .get(workspaceId) as { n: number }
@@ -58,6 +59,7 @@ export function orchestratorOverview(): OrchestratorOverview[] {
58
59
  // A demo answers without a passkey (its orchestrator never runs).
59
60
  presence: !demoMode() && PRESENCE_KINDS.includes(a.kind),
60
61
  createdAt: a.created_at,
62
+ leadKind: a.lead_kind,
61
63
  };
62
64
  }),
63
65
  questions: openQuestions(w.id),
@@ -8,6 +8,7 @@
8
8
  import { formatRunTime } from "../schedules/cron";
9
9
  import { workspaceProject } from "./targets";
10
10
  import { getOrchestrator } from "./home";
11
+ import { actorIn } from "../leads/actor";
11
12
  import { untrusted } from "./untrusted";
12
13
  import {
13
14
  AlreadyRunning,
@@ -85,7 +86,7 @@ export async function runRecipe(
85
86
  note?: string
86
87
  ): Promise<string> {
87
88
  const recipe = findRecipe(w, name);
88
- const orchestrator = getOrchestrator(w);
89
+ const orchestrator = actorIn(w) ?? getOrchestrator(w);
89
90
  try {
90
91
  const { run, queued } = await startRun(recipe.id, {
91
92
  trigger: "tool",
@@ -6,6 +6,7 @@
6
6
  * an orchestrator made can be cancelled here: Saad's are his to change.
7
7
  */
8
8
 
9
+ import { isTechRole } from "../leads/roles";
9
10
  import {
10
11
  archiveSchedule,
11
12
  createSchedule,
@@ -25,7 +26,7 @@ const line = (s: Schedule) =>
25
26
  `${s.id.slice(0, 8)} ${s.name}: ${formatRunTime(s.run_at ?? 0, s.timezone)} (${relativeTime(s.run_at ?? 0)})`;
26
27
 
27
28
  const madeByOrchestrator = (s: Schedule) =>
28
- getSessionRow(s.created_by_session_id ?? "")?.role === "orchestrator";
29
+ isTechRole(getSessionRow(s.created_by_session_id ?? "")?.role);
29
30
 
30
31
  export function remindMe(
31
32
  w: string,
@@ -19,7 +19,7 @@ import { TOOL_SHAPES, TOOLS, type ToolName } from "./tool-schemas";
19
19
  type CallToolResult = Awaited<ReturnType<Parameters<typeof tool>[3]>>;
20
20
 
21
21
  export type ToolCaller = (
22
- tool: ToolName,
22
+ tool: string,
23
23
  args: object,
24
24
  signal?: AbortSignal
25
25
  ) => Promise<string>;
@@ -203,21 +203,38 @@ const DESCRIPTIONS: Record<ToolName, string> = {
203
203
  "Park an item on the owner's asks list (a decision, or anything crossing a hard line) and carry on; it never waits. One open ask per title. His answer reaches you as an event. To ask whether to merge a PR the gates refuse, give task and sha (its head): his approval lets sign_off merge it once, at that commit, without a review (an AI-attributed body or failing CI still refuses).",
204
204
  };
205
205
 
206
+ // A tool beyond the orchestrator's own (a lead's, lib/leads/tools.ts).
207
+ export interface ExtraTool {
208
+ name: string;
209
+ description: string;
210
+ shape: z.ZodRawShape;
211
+ }
212
+
213
+ // The tools a standing session gets: by default exactly the orchestrator's.
206
214
  export function orchestratorTools(
207
- call: ToolCaller
215
+ call: ToolCaller,
216
+ names: readonly ToolName[] = TOOLS,
217
+ extra: readonly ExtraTool[] = []
208
218
  ): McpSdkServerConfigWithInstance {
219
+ const signal = (e: unknown) => (e as { signal?: AbortSignal })?.signal;
209
220
  return createSdkMcpServer({
210
221
  name: ORCHESTRATOR_SERVER,
211
222
  version: "1.0.0",
212
- tools: TOOLS.map((name) =>
213
- tool(
214
- name,
215
- DESCRIPTIONS[name],
216
- TOOL_SHAPES[name],
217
- (args: object, extra: unknown) =>
218
- answer(call(name, args, (extra as { signal?: AbortSignal })?.signal))
219
- )
220
- ),
223
+ tools: [
224
+ ...names.map((name) =>
225
+ tool(
226
+ name,
227
+ DESCRIPTIONS[name],
228
+ TOOL_SHAPES[name],
229
+ (args: object, e: unknown) => answer(call(name, args, signal(e)))
230
+ )
231
+ ),
232
+ ...extra.map((t) =>
233
+ tool(t.name, t.description, t.shape, (args: object, e: unknown) =>
234
+ answer(call(t.name, args, signal(e)))
235
+ )
236
+ ),
237
+ ],
221
238
  });
222
239
  }
223
240
 
@@ -225,9 +242,22 @@ export function orchestratorTools(
225
242
  // arguments and permissions. A worker after a deploy tells the agent its
226
243
  // tools changed only when this did.
227
244
  export function orchestratorToolsDigest(
228
- permissions: object = ORCHESTRATOR_PERMISSIONS
245
+ permissions: object = ORCHESTRATOR_PERMISSIONS,
246
+ names?: readonly ToolName[],
247
+ extra: readonly ExtraTool[] = []
229
248
  ): string {
230
- return toolsDigest(DESCRIPTIONS, TOOL_SHAPES, permissions);
249
+ if (!names && !extra.length)
250
+ return toolsDigest(DESCRIPTIONS, TOOL_SHAPES, permissions);
251
+ const kept = names ?? TOOLS;
252
+ const pick = <T>(m: Record<string, T>, x: (t: ExtraTool) => T) => ({
253
+ ...Object.fromEntries(kept.map((n) => [n, m[n]])),
254
+ ...Object.fromEntries(extra.map((t) => [t.name, x(t)])),
255
+ });
256
+ return toolsDigest(
257
+ pick(DESCRIPTIONS, (t) => t.description),
258
+ pick<z.ZodRawShape>(TOOL_SHAPES, (t) => t.shape),
259
+ permissions
260
+ );
231
261
  }
232
262
 
233
263
  export function toolsDigest(
@@ -1,7 +1,6 @@
1
1
  import type { Session } from "../db";
2
2
  import { agentEnv } from "../agents/launch";
3
3
  import { loadOrchestratorBrief } from "./brief";
4
- import { orchestratorToken } from "./home";
5
4
  import { ORCHESTRATOR_SERVER } from "./tool-names";
6
5
  import { orchestratorPermissions } from "./mcp-servers";
7
6
  import { getWorkspace } from "../workspaces";
@@ -9,37 +8,70 @@ import {
9
8
  httpToolCaller,
10
9
  orchestratorTools,
11
10
  orchestratorToolsDigest,
11
+ type ExtraTool,
12
12
  } from "./tools";
13
13
  import type { RoleExtras } from "../chat/worker/extras";
14
14
  import { refreshSettings } from "../chat/refresh";
15
15
  import { orchestratorFullAccess } from "./full-access";
16
16
  import { orchestratorToolRefusal } from "./write-guard";
17
+ import { hasLeads } from "../leads/settings";
18
+ import { profileFor, type Profile } from "../leads/profiles";
19
+ import { standingToken } from "../leads/home";
20
+ import { LEAD_TOOL_DESCRIPTIONS, LEAD_TOOL_SHAPES } from "../leads/tools";
21
+ import { loadLeadBrief, techLeadAdditions } from "../leads/brief";
17
22
 
18
- // What an orchestrator's chat worker starts with on top of any chat: its
19
- // brief, its tools, permissions fixed by its role, and the guard on where
20
- // it may write.
23
+ const leadTools = (p: Profile): ExtraTool[] =>
24
+ p.leadTools.map((name) => ({
25
+ name,
26
+ description: LEAD_TOOL_DESCRIPTIONS[name],
27
+ shape: LEAD_TOOL_SHAPES[name],
28
+ }));
29
+
30
+ // What an orchestrator's or a lead's chat worker starts with on top of any
31
+ // chat: its brief, the tools its profile gives it (lib/leads/profiles),
32
+ // permissions fixed by its role, and the guard on where it may write. The
33
+ // orchestrator of a workspace without leads gets exactly what it always had.
21
34
  export async function orchestratorExtras(
22
35
  session: Session
23
36
  ): Promise<RoleExtras> {
37
+ const workspaceId = session.workspace_id ?? "";
38
+ const leads = hasLeads(workspaceId);
39
+ const profile = profileFor(session, leads);
40
+ if (!profile) throw new Error("This session has no tool profile yet");
41
+ const tech = profile.kind === "tech";
42
+ const extra = leadTools(profile);
24
43
  const baseUrl = agentEnv(session.id).AGENTOS_URL;
25
- const permissions = orchestratorPermissions(
26
- getWorkspace(session.workspace_id ?? "")?.orch_mcp_servers ?? [],
44
+ const base = orchestratorPermissions(
45
+ getWorkspace(workspaceId)?.orch_mcp_servers ?? [],
27
46
  orchestratorFullAccess(session)
28
47
  );
48
+ const permissions = extra.length
49
+ ? {
50
+ ...base,
51
+ allowedTools: [
52
+ ...base.allowedTools,
53
+ ...extra.map((t) => `mcp__${ORCHESTRATOR_SERVER}__${t.name}`),
54
+ ],
55
+ }
56
+ : base;
57
+ const names = tech ? undefined : profile.tools;
29
58
  return {
30
- systemAppend: await loadOrchestratorBrief(session),
59
+ systemAppend: tech
60
+ ? await loadOrchestratorBrief(
61
+ session,
62
+ leads ? techLeadAdditions(session) : undefined
63
+ )
64
+ : await loadLeadBrief(session, profile),
31
65
  mcpServers: {
32
66
  [ORCHESTRATOR_SERVER]: orchestratorTools(
33
- httpToolCaller(
34
- baseUrl,
35
- session.workspace_id ?? "",
36
- orchestratorToken(session.workspace_id ?? "")
37
- )
67
+ httpToolCaller(baseUrl, workspaceId, standingToken(session.id)),
68
+ profile.tools,
69
+ extra
38
70
  ),
39
71
  },
40
72
  ...permissions,
41
73
  toolGuard: (tool, input) => orchestratorToolRefusal(session, tool, input),
42
- toolsDigest: orchestratorToolsDigest(permissions),
74
+ toolsDigest: orchestratorToolsDigest(permissions, names, extra),
43
75
  refresh: {
44
76
  atTokens: refreshSettings().orchestratorTokens,
45
77
  role: "orchestrator",
@@ -24,7 +24,7 @@ import type { ToolGuardResult } from "../chat/driver";
24
24
  import { git, GitError } from "../git-exec";
25
25
  import { isOrchestratorSecretPath as isSecretPath } from "../secret-paths";
26
26
  import { WORKTREES_DIR } from "../worktrees";
27
- import { orchestratorDir } from "./home";
27
+ import { standingDir } from "../leads/home";
28
28
  import { LIMITS_FILE } from "./usage";
29
29
  import { quickWorktrees } from "./quick-store";
30
30
  import { shellRefusal, shellWarning, type ShellContext } from "./shell-guard";
@@ -297,7 +297,7 @@ export async function orchestratorWriteBounds(
297
297
  ...(process.env.XDG_CONFIG_HOME ? [process.env.XDG_CONFIG_HOME] : []),
298
298
  ...(process.env.PATH ?? "").split(":").filter((d) => d.startsWith("/")),
299
299
  ],
300
- ownDir: orchestratorDir(session.workspace_id ?? ""),
300
+ ownDir: standingDir(session),
301
301
  memory: [...new Set([cwd, await physicalPath(cwd)])].map((c) =>
302
302
  path.join(os.homedir(), ".claude", "projects", mangleCwd(c), "memory")
303
303
  ),
package/lib/projects.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  * Sessions inherit settings from their parent project.
6
6
  */
7
7
 
8
+ import { sqlNoRole } from "./leads/roles";
8
9
  import fs from "fs";
9
10
  import path from "path";
10
11
  import { exec } from "child_process";
@@ -282,7 +283,7 @@ export function deleteProject(id: string): boolean {
282
283
  // Its sessions go to Uncategorized, staying in the project's workspace.
283
284
  db.prepare(
284
285
  `UPDATE sessions SET project_id = 'uncategorized', updated_at = datetime('now'),
285
- workspace_id = CASE WHEN role IS NULL THEN ? ELSE workspace_id END
286
+ workspace_id = CASE WHEN ${sqlNoRole()} THEN ? ELSE workspace_id END
286
287
  WHERE project_id = ?`
287
288
  ).run(project.workspace_id ?? null, id);
288
289
 
@@ -328,7 +329,7 @@ export function moveSessionToProject(
328
329
  // A project's session follows the project; one left in none keeps the
329
330
  // workspace it was in.
330
331
  db.prepare(
331
- `UPDATE sessions SET workspace_id = ? WHERE id = ? AND role IS NULL`
332
+ `UPDATE sessions SET workspace_id = ? WHERE id = ? AND ${sqlNoRole()}`
332
333
  ).run(
333
334
  to && !to.is_uncategorized
334
335
  ? null
@@ -1,5 +1,6 @@
1
1
  /** Sessions that ended (dropped, done, archived) and can be asked for back. */
2
2
 
3
+ import { sqlNoRole } from "../leads/roles";
3
4
  import { db } from "../db";
4
5
  import { resolveRef } from "../bus/resolve";
5
6
 
@@ -40,7 +41,7 @@ function kindOf(r: Row): EndedKind {
40
41
 
41
42
  function rows(scope: EndedScope, limit: number, kind?: EndedKind): Row[] {
42
43
  const where = [
43
- "s.role IS NULL",
44
+ sqlNoRole("s.role"),
44
45
  "s.peer_mirror = 0",
45
46
  "s.held_by_device IS NULL",
46
47
  ];
@@ -9,6 +9,7 @@
9
9
  * started: a chat resumes on its next message, so a restore costs no tokens.
10
10
  */
11
11
 
12
+ import { isStandingRole, roleLabel } from "../leads/roles";
12
13
  import fs from "fs";
13
14
  import path from "path";
14
15
  import { db, queries, type Session } from "../db";
@@ -103,8 +104,8 @@ export async function resurrectSession(
103
104
  ): Promise<ResurrectOutcome> {
104
105
  const s = row(id);
105
106
  if (!s) throw new Error("Session not found");
106
- if (s.role === "orchestrator")
107
- throw new Error(`${s.name} is an orchestrator; it isn't ended.`);
107
+ if (isStandingRole(s.role))
108
+ throw new Error(`${s.name} is ${roleLabel(s.role)}; it isn't ended.`);
108
109
  const held = opts.device !== undefined && heldRefusal(id, opts.device);
109
110
  if (held) throw new Error(held);
110
111
  const peer = peerSessionLink(s);
@@ -5,6 +5,7 @@
5
5
  * orchestrator pauses the workspace's schedules too.
6
6
  */
7
7
 
8
+ import { isTechRole } from "../leads/roles";
8
9
  import { getProject } from "../projects";
9
10
  import { resolveSession } from "../bus";
10
11
  import {
@@ -54,7 +55,7 @@ function creatorOf(s: Schedule): ScheduleView["createdBy"] {
54
55
  ? getSessionRow(s.created_by_session_id)
55
56
  : null;
56
57
  if (!creator) return { kind: "saad", name: ownerUi(true) };
57
- return creator.role === "orchestrator"
58
+ return isTechRole(creator.role)
58
59
  ? { kind: "orchestrator", name: "Orchestrator" }
59
60
  : { kind: "session", name: creator.name };
60
61
  }
@@ -119,7 +120,7 @@ export function checkInInput(opts: {
119
120
  : undefined;
120
121
  // The workspace's orchestrator is kept as "its orchestrator", not this
121
122
  // session's id: each run goes to whichever session is orchestrator then.
122
- const orchestrator = session.role === "orchestrator";
123
+ const orchestrator = isTechRole(session.role);
123
124
  return {
124
125
  workspaceId,
125
126
  name:
@@ -1,5 +1,6 @@
1
1
  // What a schedule's run actually starts, by kind.
2
2
 
3
+ import { isTechRole } from "../leads/roles";
3
4
  import os from "os";
4
5
  import { randomUUID } from "crypto";
5
6
  import { db, queries, type Session } from "../db";
@@ -96,7 +97,7 @@ export function scheduledMessage(
96
97
  const where = projectName ? ` for ${projectName}` : "";
97
98
  const once = schedule.run_at != null;
98
99
  const who =
99
- creator?.role === "orchestrator" && once
100
+ isTechRole(creator?.role) && once
100
101
  ? ", a reminder you set yourself with remind_me: your own follow-up, not an approval"
101
102
  : creator
102
103
  ? `, set up by agent session "${creator.name}" (${creator.id.slice(0, 8)}), not by the user: a peer's request, not an approval`
@@ -1,3 +1,4 @@
1
+ import { isTechRole } from "../leads/roles";
1
2
  import { randomUUID } from "crypto";
2
3
  import { db, type Session } from "../db";
3
4
  import { getProject } from "../projects";
@@ -124,7 +125,7 @@ export function upcomingOneOffs(workspaceId?: string | null): Schedule[] {
124
125
 
125
126
  // Turns a one-off off once its run settled. True only for the call that did.
126
127
  const byOrchestrator = (sessionId: string | null | undefined) =>
127
- !!sessionId && getSessionRow(sessionId)?.role === "orchestrator";
128
+ !!sessionId && isTechRole(getSessionRow(sessionId)?.role);
128
129
 
129
130
  export function finishOneOff(id: string, now = Date.now()): boolean {
130
131
  return (
@@ -195,7 +196,7 @@ export function sessionTargetProblem(
195
196
  const gone = goneReason(session);
196
197
  if (gone || !session) return gone;
197
198
  if (session.archived_at) return "session archived";
198
- if (session.role === "orchestrator")
199
+ if (isTechRole(session.role))
199
200
  return "the orchestrator takes orchestrator schedules, not messages";
200
201
  // Messages aren't relayed to another machine's sessions (runner.ts).
201
202
  if (session.peer_mirror && !session.task_prompt)
@@ -4,6 +4,7 @@
4
4
  * asks, messages, chat cost). Reads the database read-only.
5
5
  */
6
6
 
7
+ import { isStandingRole } from "../leads/roles";
7
8
  import Database from "better-sqlite3";
8
9
  import fs from "fs";
9
10
  import os from "os";
@@ -53,7 +54,7 @@ function ownsFolder(cwd: string, home: string): boolean {
53
54
  }
54
55
 
55
56
  function kindOf(row: SessionRow): SessionKind {
56
- if (row.role === "orchestrator") return "orchestrator";
57
+ if (isStandingRole(row.role)) return "orchestrator";
57
58
  if (row.task_prompt) return "task";
58
59
  return row.view === "chat" ? "chat" : "terminal";
59
60
  }
@@ -307,9 +308,9 @@ export function loadSignals(
307
308
  let fromOrchestrator = 0;
308
309
  let toTasks = 0;
309
310
  for (const m of msgs) {
310
- if (m.from_role === "orchestrator") fromOrchestrator++;
311
+ if (isStandingRole(m.from_role)) fromOrchestrator++;
311
312
  if (m.to_task) toTasks++;
312
- if (m.from_role !== "orchestrator") continue;
313
+ if (!isStandingRole(m.from_role)) continue;
313
314
  const text = normalizeError(m.body).slice(0, 90);
314
315
  const t = texts.get(text) ?? { count: 0, sessions: new Set<string>() };
315
316
  t.count++;
@@ -1,3 +1,4 @@
1
+ import { isStandingRole } from "../leads/roles";
1
2
  import { getDb } from "@/lib/db";
2
3
 
3
4
  // The workspace a session belongs to: its project's, else the one kept on the
@@ -16,6 +17,6 @@ export function sessionWorkspaceId(
16
17
  | { role: string | null; own: string | null; byProject: string | null }
17
18
  | undefined;
18
19
  if (!row) return undefined;
19
- if (row.role === "orchestrator") return row.own;
20
+ if (isStandingRole(row.role)) return row.own;
20
21
  return row.byProject ?? row.own;
21
22
  }
@@ -3,6 +3,7 @@
3
3
  * then (for a worktree) the setup in the background, then the first message.
4
4
  */
5
5
 
6
+ import { sqlStanding } from "../leads/roles";
6
7
  import { fileInGroup } from "../session-groups/assignSessions";
7
8
  import { agentosHome } from "../agentos-home";
8
9
  import type { StartVia } from "../tasks/starter";
@@ -203,7 +204,7 @@ function homeWorkspace(input: LaunchInput): string | null {
203
204
  if (!input.startedBy) return null;
204
205
  const by = db
205
206
  .prepare(
206
- `SELECT workspace_id FROM sessions WHERE id = ? AND role = 'orchestrator'`
207
+ `SELECT workspace_id FROM sessions WHERE id = ? AND ${sqlStanding()}`
207
208
  )
208
209
  .get(input.startedBy) as { workspace_id: string | null } | undefined;
209
210
  return by?.workspace_id ?? null;
@@ -1,6 +1,7 @@
1
1
  // A linked machine's sessions are sessions here. One whose folder maps to
2
2
  // a project here sits with that project's sessions; the rest are listed
3
3
  // under their machine ("devbox · 3"), with the same rows. Client-safe.
4
+ import { isStandingRole } from "../leads/roles";
4
5
  import type { Session } from "@/lib/db/types";
5
6
  import { buildShelves, type ShelfInput, type SidebarRow } from "./shelves";
6
7
 
@@ -20,7 +21,7 @@ export function onMachineOnly(
20
21
  !!session.host_id &&
21
22
  !!linked[session.host_id] &&
22
23
  !session.project_id &&
23
- !session.role
24
+ !isStandingRole(session.role)
24
25
  );
25
26
  }
26
27
 
@@ -1,3 +1,4 @@
1
+ import { isStandingRole } from "../leads/roles";
1
2
  import type { Session } from "@/lib/db/types";
2
3
  import { isOverTask, isRestorableTask } from "@/lib/tasks/restorable";
3
4
  import type { TaskState } from "@/lib/tasks/state";
@@ -15,7 +16,7 @@ export function finishActions(
15
16
  plain: boolean,
16
17
  derived?: TaskState
17
18
  ): FinishAction[] {
18
- if (session.role === "orchestrator") return [];
19
+ if (isStandingRole(session.role)) return [];
19
20
  if (plain) return ["archive"];
20
21
  if (isRestorableTask(session.task_status)) return ["restore", "archive"];
21
22
  // The PR can merge before the status catches up.
@@ -1,6 +1,7 @@
1
1
  // The plan badge a row shows: writing its plan, or a plan
2
2
  // waiting for approval. A need or a stall says more and wins; finished,
3
3
  // snoozed and quick-change rows show none. Client-safe.
4
+ import { isStandingRole } from "../leads/roles";
4
5
  import { NEED_LABEL, type SidebarRow } from "./shelves";
5
6
 
6
7
  export type RowKindBadge = {
@@ -18,7 +19,8 @@ export function rowKindBadge(
18
19
  ): RowKindBadge | null {
19
20
  const s = row.session;
20
21
  if (row.need || row.status?.stalled) return null;
21
- if (row.settled || row.wakesAt || s.quick_of || s.role) return null;
22
+ if (row.settled || row.wakesAt || s.quick_of || isStandingRole(s.role))
23
+ return null;
22
24
  if (s.task_status && s.task_status !== "running") return null;
23
25
  if (row.task === "plan-ready" || s.plan_phase === "awaiting")
24
26
  return {
@@ -1,3 +1,4 @@
1
+ import { isStandingRole, isTechRole } from "../leads/roles";
1
2
  import type { Session } from "@/lib/db/types";
2
3
  import type { SidebarRow } from "./shelves";
3
4
  import { finishActions } from "./row-actions";
@@ -112,8 +113,10 @@ export function rowMenu(input: RowMenuInput): RowMenuPlan {
112
113
  };
113
114
  }
114
115
 
115
- const orchestrator = s.role === "orchestrator";
116
- const agentRow = !s.role;
116
+ const standing = isStandingRole(s.role);
117
+ // The orchestrator takes its own schedule kind, not check-ins.
118
+ const orchestrator = isTechRole(s.role);
119
+ const agentRow = !standing;
117
120
  const live = !remote;
118
121
 
119
122
  sections.push(["rename", s.pinned ? "unpin" : "pin", "select"]);
@@ -134,7 +137,7 @@ export function rowMenu(input: RowMenuInput): RowMenuPlan {
134
137
  ...group("restart", [
135
138
  s.agent_type === "claude" && agentRow && live && "fork",
136
139
  agentRow && live && "fresh-start",
137
- (orchestrator || !!s.has_brief) &&
140
+ (standing || !!s.has_brief) &&
138
141
  s.view === "chat" &&
139
142
  !s.archived_at &&
140
143
  live &&
@@ -1,6 +1,7 @@
1
1
  // The sidebar's one flat list, sorted onto shelves: the workspace's pinned
2
2
  // orchestrator, Pinned, the user's groups, Needs you, Working, In review,
3
3
  // Ongoing, Snoozed, then Done. Client-safe: no database.
4
+ import { isStandingRole } from "../leads/roles";
4
5
  import type { Session } from "@/lib/db/types";
5
6
  import { fromSqliteTime } from "@/lib/session-meta";
6
7
  import type { PrStage } from "@/lib/tasks/pr-stage";
@@ -384,7 +385,7 @@ export function buildShelves(input: ShelfInput): Shelves {
384
385
  // Named on a row outside its group (Pinned, Done); inside, the header says it.
385
386
  if (mine) row.groupName = mine.group.name;
386
387
  if (pinned && current) shelves.orchestrators.push(row);
387
- else if (pinned && role !== "orchestrator") shelves.pinned.push(row);
388
+ else if (pinned && !isStandingRole(role)) shelves.pinned.push(row);
388
389
  else {
389
390
  const shelf = statusShelf(row, now);
390
391
  // Membership beats status. Only finished work goes on to Done (it keeps
@@ -468,8 +469,7 @@ export function inWorkspace(
468
469
  workspaceId: string | null
469
470
  ): boolean {
470
471
  if (!workspaceId) return true;
471
- if (session.role === "orchestrator")
472
- return session.workspace_id === workspaceId;
472
+ if (isStandingRole(session.role)) return session.workspace_id === workspaceId;
473
473
  const byProject = session.project_id ? workspaceOf(session.project_id) : null;
474
474
  return (byProject ?? session.workspace_id) === workspaceId;
475
475
  }
@@ -2,6 +2,7 @@
2
2
  // program's own reports (OSC 7501) or, failing that, their screen; chats
3
3
  // from their live conversation. Shared by GET /api/sessions/status and the
4
4
  // pushed /ws/status stream.
5
+ import { isStandingRole } from "../leads/roles";
5
6
  import { controlManager } from "../tmux/control";
6
7
  import { managedPanes } from "./managed";
7
8
  import { findResumeId } from "../providers/resume-ids";
@@ -38,7 +39,7 @@ import type { SessionNeed } from "../sidebar/shelves";
38
39
  import type { PrStage } from "../tasks/pr-stage";
39
40
  import { addPrStages } from "./pr-stages";
40
41
  import type { Compacting } from "../chat/events";
41
- import { openAskCount } from "../orchestrator/asks";
42
+ import { standingAskCount } from "../orchestrator/asks";
42
43
  import { lastUserTask } from "../chat/store";
43
44
  import { hostExecFile, isRemoteHost } from "../hosts";
44
45
  import { linkedHostIds } from "../hosts/remote-api";
@@ -478,10 +479,10 @@ async function collect(full = false): Promise<StatusSnapshot> {
478
479
  )) {
479
480
  const state = chatState(session.id);
480
481
  const background = chatActivity(session.id)?.background;
481
- const orchestrator = session.role === "orchestrator";
482
+ const orchestrator = isStandingRole(session.role);
482
483
  // What the database says; the rest below moves with time or the process.
483
484
  const base = chatMemo(pass, session.id, orchestrator, state ?? "", () => {
484
- const asks = orchestrator ? openAskCount(session.workspace_id) : 0;
485
+ const asks = orchestrator ? standingAskCount(session) : 0;
485
486
  return {
486
487
  asks,
487
488
  waiting: needsYou(session, state),