@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
@@ -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,
@@ -2033,6 +2033,30 @@
2033
2033
  "area": "Updates",
2034
2034
  "text": "You can update AgentOS by hand again on machines that deploy themselves, and Update now skips the hourly wait.",
2035
2035
  "url": "https://github.com/saadnvd1/agent-os/pull/571"
2036
+ },
2037
+ {
2038
+ "id": 573,
2039
+ "date": "2026-11-02",
2040
+ "kind": "new",
2041
+ "area": "AgentOS",
2042
+ "text": "Mark a fork as done straight from the Forks list above the chat box.",
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"
2036
2060
  }
2037
2061
  ]
2038
2062
  }
@@ -5,6 +5,7 @@ import type { ArchivedView } from "@/lib/done/archive";
5
5
  import type { ResurrectOutcome } from "@/lib/resurrect";
6
6
  import { sessionKeys } from "../sessions/keys";
7
7
  import { taskKeys } from "../tasks";
8
+ import { forkKeys } from "../fork";
8
9
 
9
10
  export const archivedKeys = {
10
11
  all: ["archived-sessions"] as const,
@@ -52,7 +53,12 @@ function useDoneMutation<V, R>(fn: (v: V) => Promise<R>) {
52
53
  return useMutation({
53
54
  mutationFn: fn,
54
55
  onSettled: () => {
55
- for (const queryKey of [sessionKeys.all, taskKeys.all, archivedKeys.all])
56
+ for (const queryKey of [
57
+ sessionKeys.all,
58
+ taskKeys.all,
59
+ archivedKeys.all,
60
+ forkKeys.all,
61
+ ])
56
62
  queryClient.invalidateQueries({ queryKey });
57
63
  },
58
64
  });
@@ -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"
@@ -1,3 +1,4 @@
1
+ import { isStandingRole, RoleLabel } from "../leads/roles";
1
2
  import { db, type Session } from "../db";
2
3
  import { lastItems } from "../chat/store";
3
4
  import { launchSession } from "../sessions/launch";
@@ -18,7 +19,8 @@ const sessionRow = (id: string) =>
18
19
  export function coderRefusal(s: Session | undefined): string | null {
19
20
  if (!s) return "Session not found";
20
21
  if (s.archived_at) return `${s.name} is archived`;
21
- if (s.role === "orchestrator") return "An orchestrator can't have an analyst";
22
+ if (isStandingRole(s.role))
23
+ return `${RoleLabel(s.role)} can't have an analyst`;
22
24
  if (s.view !== "chat")
23
25
  return "Only a chat session can have an analyst: a terminal has no turns to review";
24
26
  if ((s.host_id || "local") !== "local")
@@ -4,6 +4,7 @@
4
4
  * as Away ends and kept with the period, so it reads the same later.
5
5
  */
6
6
 
7
+ import { sqlNoRole } from "../leads/roles";
7
8
  import { db } from "../db";
8
9
  import { listWorkspaces } from "../workspaces";
9
10
  import { HARD_LINES, openAsks } from "../orchestrator/asks";
@@ -178,7 +179,7 @@ function inFlight(workspaceId: string, awayId: number): InFlightItem[] {
178
179
  `SELECT s.id, s.name, p.name AS project, s.pr_number, s.pr_url,
179
180
  s.merge_armed_at AS armed_at
180
181
  FROM session_rows s JOIN projects p ON p.id = s.project_id
181
- WHERE p.workspace_id = ? AND s.role IS NULL AND s.archived_at IS NULL
182
+ WHERE p.workspace_id = ? AND ${sqlNoRole("s.role")} AND s.archived_at IS NULL
182
183
  AND s.task_status = 'running'
183
184
  AND EXISTS (SELECT 1 FROM away_actions a WHERE a.away_id = ?
184
185
  AND a.subject = s.id AND a.kind IN (${TOUCHED}))
package/lib/bus/format.ts CHANGED
@@ -81,6 +81,8 @@ export function matchesRef(
81
81
  // orchestrator: "<workspace>/<name>" and "<workspace>/orchestrator".
82
82
  workspaceName?: string | null;
83
83
  orchestrator?: boolean;
84
+ // A lead's department: "<workspace>/<kind>".
85
+ leadKind?: string | null;
84
86
  }
85
87
  ): boolean {
86
88
  const r = ref.trim().toLowerCase();
@@ -92,6 +94,7 @@ export function matchesRef(
92
94
  r === name ||
93
95
  (!!s.projectName && r === `${s.projectName.toLowerCase()}/${name}`) ||
94
96
  (!!ws && r === `${ws}/${name}`) ||
95
- (!!ws && !!s.orchestrator && r === `${ws}/orchestrator`)
97
+ (!!ws && !!s.orchestrator && r === `${ws}/orchestrator`) ||
98
+ (!!ws && !!s.leadKind && r === `${ws}/${s.leadKind}`)
96
99
  );
97
100
  }
package/lib/bus/index.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  * the recipient's tmux pane, which works for any agent CLI.
5
5
  */
6
6
 
7
+ import { isTechRole } from "../leads/roles";
7
8
  import { db, type Session } from "../db";
8
9
  import { statusDetector } from "../status-detector";
9
10
  import { chatState, sendChatConfirmed } from "../chat/runner";
@@ -63,6 +64,8 @@ export interface Peer {
63
64
  orchestratorOf: string | null;
64
65
  // The asking session's own workspace orchestrator.
65
66
  yours?: boolean;
67
+ // A lead's address, "<workspace>/<kind>", once its workspace has leads.
68
+ lead?: string;
66
69
  // Names of the sessions it waits on, and of those waiting on it.
67
70
  waitingOn: string[];
68
71
  waitedBy: string[];
@@ -93,7 +96,7 @@ async function peerState(
93
96
  }
94
97
 
95
98
  const orchestratorOf = (s: Session) =>
96
- s.role === "orchestrator" && s.workspace_id
99
+ isTechRole(s.role) && s.workspace_id
97
100
  ? (getWorkspace(s.workspace_id)?.name ?? "a workspace")
98
101
  : null;
99
102
 
@@ -121,6 +124,10 @@ export async function listPeers(callerId?: string): Promise<Peer[]> {
121
124
  running: info.running,
122
125
  doing: info.subtitle,
123
126
  orchestratorOf: orchestratorOf(s),
127
+ ...(named[i].leadKind &&
128
+ named[i].workspaceName && {
129
+ lead: `${named[i].workspaceName}/${named[i].leadKind}`,
130
+ }),
124
131
  waitingOn: badges.get(s.id)?.on.map((w) => w.name) ?? [],
125
132
  waitedBy: badges.get(s.id)?.by.map((w) => w.name) ?? [],
126
133
  ...(s.agent_role && { agentRole: s.agent_role }),
@@ -130,7 +137,7 @@ export async function listPeers(callerId?: string): Promise<Peer[]> {
130
137
  group: groups.get(s.session_group_id),
131
138
  }),
132
139
  ...(callerWorkspace &&
133
- s.role === "orchestrator" &&
140
+ isTechRole(s.role) &&
134
141
  s.workspace_id === callerWorkspace && { yours: true }),
135
142
  };
136
143
  })
@@ -1,3 +1,4 @@
1
+ import { isStandingRole, roleLabel } from "../leads/roles";
1
2
  import { db } from "../db";
2
3
  import type { Session } from "../db";
3
4
  import { getProject } from "../projects";
@@ -58,7 +59,7 @@ function candidates(rows: ReadableSession[]): Candidate[] {
58
59
  }
59
60
 
60
61
  function inScope(s: ReadableSession, scope: ReaderScope): boolean {
61
- if (s.role) return false;
62
+ if (isStandingRole(s.role)) return false;
62
63
  if (scope.workspaceId) {
63
64
  const p = s.project_id ? getProject(s.project_id) : null;
64
65
  // A session in no project is in the workspace it was started in.
@@ -113,8 +114,8 @@ export function resolveReadable(callerId: string, ref: string): Readable {
113
114
  return {
114
115
  ok: false,
115
116
  external: false,
116
- error: s.role
117
- ? `${s.name} is an orchestrator: it isn't readable. Message it with aos send`
117
+ error: isStandingRole(s.role)
118
+ ? `${s.name} is ${roleLabel(s.role)}: it isn't readable. Message it with aos send`
118
119
  : `"${ref}" isn't in your ${scope.label}; aos read reaches only sessions there`,
119
120
  };
120
121
  }
@@ -13,6 +13,8 @@ export interface Candidate {
13
13
  previousNames: string[];
14
14
  workspaceName?: string | null;
15
15
  orchestrator?: boolean;
16
+ // The department it heads, when its workspace has leads: "<workspace>/<kind>".
17
+ leadKind?: string | null;
16
18
  }
17
19
 
18
20
  export type Resolution =
@@ -23,7 +25,7 @@ export const MIN_ID_PREFIX = 4;
23
25
 
24
26
  const label = (c: Candidate) =>
25
27
  `${
26
- c.orchestrator && c.workspaceName
28
+ (c.orchestrator || c.leadKind) && c.workspaceName
27
29
  ? `${c.workspaceName}/`
28
30
  : c.projectName
29
31
  ? `${c.projectName}/`
@@ -46,6 +48,7 @@ const matchesOld = (r: string, c: Candidate) =>
46
48
  tmuxName: "",
47
49
  name: old,
48
50
  orchestrator: false,
51
+ leadKind: null,
49
52
  })
50
53
  );
51
54
 
@@ -5,6 +5,7 @@
5
5
  * refuses and lists them, like any other name.
6
6
  */
7
7
 
8
+ import { isTechRole } from "../leads/roles";
8
9
  import type { Session } from "../db";
9
10
  import { sessionWorkspace } from "../models/resolve";
10
11
 
@@ -60,7 +61,7 @@ export function resolveRoleAlias<T extends RoleSession>(
60
61
  const workspace = caller ? sessionWorkspace(caller) : null;
61
62
  if (role === "supervisor" && workspace) {
62
63
  const orchestrator = others.find(
63
- (s) => s.role === "orchestrator" && s.workspace_id === workspace
64
+ (s) => isTechRole(s.role) && s.workspace_id === workspace
64
65
  );
65
66
  if (orchestrator) return { ok: true, session: orchestrator };
66
67
  }
@@ -2,6 +2,8 @@
2
2
  * Which live session a name someone typed reaches, across workspaces.
3
3
  */
4
4
 
5
+ import { isTechRole, leadKindOf } from "../leads/roles";
6
+ import { hasLeads } from "../leads/settings";
5
7
  import { db, type Session } from "../db";
6
8
  import { getProject } from "../projects";
7
9
  import { getWorkspace } from "../workspaces";
@@ -37,7 +39,10 @@ export function candidates(sessions: Session[]): Candidate[] {
37
39
  tmuxName: s.tmux_name,
38
40
  previousNames: old.get(s.id) ?? [],
39
41
  workspaceName: workspaceName(s),
40
- orchestrator: s.role === "orchestrator",
42
+ orchestrator: isTechRole(s.role),
43
+ // The orchestrator answers to "tech" only once its workspace has leads.
44
+ leadKind:
45
+ isTechRole(s.role) && !hasLeads(s.workspace_id) ? null : leadKindOf(s),
41
46
  }));
42
47
  }
43
48
 
@@ -77,7 +82,7 @@ function ownOrchestrator(
77
82
  const workspace = caller ? sessionWorkspace(caller) : null;
78
83
  if (!workspace) return undefined;
79
84
  return sessions.find(
80
- (s) => s.role === "orchestrator" && s.workspace_id === workspace
85
+ (s) => isTechRole(s.role) && s.workspace_id === workspace
81
86
  );
82
87
  }
83
88
 
@@ -95,7 +100,7 @@ export function tryResolveTarget(
95
100
  const r = resolveRef(ref, candidates(sessions));
96
101
  if (!r.ok) {
97
102
  const orchestrators = /^(my )?orchestrator$/i.test(ref.trim())
98
- ? sessions.filter((s) => s.role === "orchestrator")
103
+ ? sessions.filter((s) => isTechRole(s.role))
99
104
  : [];
100
105
  if (orchestrators.length)
101
106
  return {
@@ -5,6 +5,7 @@
5
5
  * reads status through statusOf, the same idle test the orchestrator uses.
6
6
  */
7
7
 
8
+ import { isStandingRole } from "../../leads/roles";
8
9
  import type { Session } from "../../db";
9
10
  import { db } from "../../db";
10
11
  import { statusDetector } from "../../status-detector";
@@ -128,7 +129,7 @@ async function settle(w: WaitRow, waiter: Session, now: number): Promise<void> {
128
129
  timeoutLine(target, w.kind, w.deadline - w.created_at, w.request, is)
129
130
  );
130
131
  const workspace = sessionWorkspace(waiter);
131
- if (workspace && waiter.role !== "orchestrator")
132
+ if (workspace && !isStandingRole(waiter.role))
132
133
  queueEventOnce(
133
134
  workspace,
134
135
  `wait-timeout:${w.id}`,
@@ -5,6 +5,7 @@
5
5
  * deploy restarted the worker too.
6
6
  */
7
7
 
8
+ import { isStandingRole } from "../leads/roles";
8
9
  import os from "node:os";
9
10
  import { statusPorcelain } from "../git-exec";
10
11
  import { db, type Session } from "../db";
@@ -33,7 +34,7 @@ export async function compactContext(sessionId: string): Promise<string> {
33
34
  .get(sessionId) as
34
35
  Pick<Session, "working_directory" | "task_prompt" | "role"> | undefined;
35
36
  // An orchestrator starts fresh instead of compacting.
36
- if (!session || session.role === "orchestrator") return "";
37
+ if (!session || isStandingRole(session.role)) return "";
37
38
  const tools = lastItems(sessionId, 400, "tool").filter(
38
39
  (i) => i.kind === "tool"
39
40
  );
@@ -1,3 +1,4 @@
1
+ import { isStandingRole } from "../leads/roles";
1
2
  import fs from "fs";
2
3
  import os from "os";
3
4
  import path from "path";
@@ -35,7 +36,7 @@ export function mentionRoots(sessionId: string): MentionRoot[] | null {
35
36
  working_directory: string;
36
37
  }
37
38
  | undefined;
38
- if (session?.role !== "orchestrator") return null;
39
+ if (!isStandingRole(session?.role)) return null;
39
40
 
40
41
  // Never the home folder or the whole disk: a project pointed there would
41
42
  // list everything under it, dotfiles and keys included.
@@ -5,6 +5,7 @@
5
5
  * events to whoever is watching. Restarting the server leaves turns running.
6
6
  */
7
7
 
8
+ import { isStandingRole } from "../leads/roles";
8
9
  import { pushPlanReady } from "../notify/push/events";
9
10
  import {
10
11
  handoffAfterPlan,
@@ -638,7 +639,7 @@ async function resumeCutOff(sessionId: string): Promise<void> {
638
639
  const task =
639
640
  row.workspace_id &&
640
641
  row.task_status === "running" &&
641
- row.role !== "orchestrator"
642
+ !isStandingRole(row.role)
642
643
  ? row.workspace_id
643
644
  : null;
644
645
  try {
@@ -923,7 +924,7 @@ export async function freshStartChat(
923
924
  id: `note-refresh-${Date.now()}`,
924
925
  kind: "note",
925
926
  tone: "note",
926
- text: `Fresh start: the agent starts a new conversation with its brief${session.role === "orchestrator" ? " and the current state from AgentOS" : ""} at its next message.`,
927
+ text: `Fresh start: the agent starts a new conversation with its brief${isStandingRole(session.role) ? " and the current state from AgentOS" : ""} at its next message.`,
927
928
  createdAt: Date.now(),
928
929
  };
929
930
  saveItem(sessionId, item);
@@ -3,6 +3,7 @@
3
3
  * per-session settings that go with them: the model and the access level.
4
4
  */
5
5
 
6
+ import { isStandingRole } from "../leads/roles";
6
7
  import { effectiveChatAccess, noApprovalsOn } from "../approvals/no-approvals";
7
8
  import os from "os";
8
9
  import { agentLabel } from "../agent-options";
@@ -330,7 +331,7 @@ export async function setChatAccess(
330
331
  ): Promise<void> {
331
332
  if (!CHAT_ACCESS.includes(access)) return;
332
333
  // An orchestrator's access is fixed by its role.
333
- if (getSession(sessionId).role === "orchestrator") return;
334
+ if (isStandingRole(getSession(sessionId).role)) return;
334
335
  db.prepare(`UPDATE sessions SET chat_access = ? WHERE id = ?`).run(
335
336
  access,
336
337
  sessionId
@@ -341,7 +342,7 @@ export async function setChatAccess(
341
342
 
342
343
  // An orchestrator's permission mode is fixed by its role.
343
344
  const canPlan = (s: Session) =>
344
- s.role !== "orchestrator" && chatDriverFor(s.agent_type)?.plan !== false;
345
+ !isStandingRole(s.role) && chatDriverFor(s.agent_type)?.plan !== false;
345
346
 
346
347
  // Plan mode: the agent reads and plans, and changes nothing until the plan
347
348
  // is carried out. Now if a conversation is live, and for every next start.
@@ -1,3 +1,4 @@
1
+ import { isLeadRole } from "../../leads/roles";
1
2
  import type { Session } from "../../db";
2
3
  import type { ChatStartOptions } from "../driver";
3
4
  import { refreshSettings } from "../refresh";
@@ -40,7 +41,9 @@ export async function roleExtras(session: Session): Promise<RoleExtras> {
40
41
  }
41
42
 
42
43
  async function ownExtras(session: Session): Promise<RoleExtras> {
43
- if (session.role === "orchestrator")
44
+ // The orchestrator and the other leads run on the orchestrator's worker,
45
+ // each with its own profile.
46
+ if (isLeadRole(session.role))
44
47
  return (await import("../../orchestrator/worker")).orchestratorExtras(
45
48
  session
46
49
  );
@@ -4,6 +4,7 @@
4
4
  * events to whoever is connected.
5
5
  */
6
6
 
7
+ import { isStandingRole } from "../../leads/roles";
7
8
  import os from "os";
8
9
  import { db, type Session } from "../../db";
9
10
  import { agentEnv } from "../../agents/launch";
@@ -216,7 +217,7 @@ export class ChatHost {
216
217
  this.atRest = driver.atRest !== false;
217
218
  const cwd = session.working_directory.replace(/^~/, os.homedir());
218
219
  const env = { ...agentEnv(session.id), ...extras.env };
219
- const orchestrator = session.role === "orchestrator";
220
+ const orchestrator = isStandingRole(session.role);
220
221
  // Every chat can show visuals, asked for like any tool under its access
221
222
  // setting (they read files and reach the web). An orchestrator reads
222
223
  // other sessions' text, so it gets a shorter brief that says what stays
@@ -289,7 +290,7 @@ export class ChatHost {
289
290
  if (session.chat_refreshed)
290
291
  this.refreshedNote = refreshedNote(
291
292
  extras.refresh?.role ??
292
- (session.role === "orchestrator" ? "orchestrator" : "task"),
293
+ (isStandingRole(session.role) ? "orchestrator" : "task"),
293
294
  session.chat_refreshed
294
295
  );
295
296
  // Told only an orchestrator whose tools changed since its last worker:
@@ -964,7 +965,7 @@ export class ChatHost {
964
965
  const stale = this.staleNow(cmd.compact);
965
966
  // An orchestrator starts over at its own threshold (lib/chat/refresh);
966
967
  // only one gone stale does it early.
967
- if (!stale || (this.session.role === "orchestrator" && !stale.stale))
968
+ if (!stale || (isStandingRole(this.session.role) && !stale.stale))
968
969
  return false;
969
970
  try {
970
971
  enqueue(this.session.id, cmd);
@@ -978,7 +979,7 @@ export class ChatHost {
978
979
  // from tasks and CI without its untrusted fence. It starts a fresh
979
980
  // conversation from its brief and the database instead, as at its
980
981
  // threshold, and the next worker sends the message.
981
- if (this.session.role === "orchestrator") {
982
+ if (isStandingRole(this.session.role)) {
982
983
  this.staleRefresh = stale;
983
984
  this.refreshAt = stale.tokens;
984
985
  this.retiring = true;
@@ -1085,7 +1086,7 @@ export class ChatHost {
1085
1086
  // line saying where it started over.
1086
1087
  private endConversation(): void {
1087
1088
  releaseConversation(this.session.id, this.refreshAt);
1088
- const brief = `its brief${this.session.role === "orchestrator" ? " and the current state from AgentOS" : ""}`;
1089
+ const brief = `its brief${isStandingRole(this.session.role) ? " and the current state from AgentOS" : ""}`;
1089
1090
  const stale = this.staleRefresh;
1090
1091
  const requested = this.refreshAt === REQUESTED_FRESH;
1091
1092
  this.record({