@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
@@ -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({
@@ -0,0 +1,142 @@
1
+ import type { Migration } from "../migration-types";
2
+ import { createSessionRowsView } from "../session-parts/view";
3
+
4
+ const columnsOf = (db: Parameters<Migration["up"]>[0], table: string) =>
5
+ (db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[]).map(
6
+ (c) => c.name
7
+ );
8
+
9
+ export default {
10
+ key: "20261102T1944_leads",
11
+ up: (db) => {
12
+ // Leads (lib/leads): a lead is a standing chat like the orchestrator,
13
+ // with role 'lead' and a lead_kind; the orchestrator is the 'tech' lead
14
+ // once its workspace adds leads, and a workspace has one 'chief'.
15
+ // lead_since is when it became one, so its usage counts from then.
16
+ // Additive: a workspace without leads has none of these rows.
17
+ const sessions = columnsOf(db, "sessions");
18
+ if (!sessions.includes("lead_kind"))
19
+ db.exec(`ALTER TABLE sessions ADD COLUMN lead_kind TEXT`);
20
+ if (!sessions.includes("lead_since"))
21
+ db.exec(`ALTER TABLE sessions ADD COLUMN lead_since TEXT`);
22
+ createSessionRowsView(db);
23
+ db.exec(
24
+ `CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_lead
25
+ ON sessions(workspace_id, lead_kind) WHERE role = 'lead'`
26
+ );
27
+ db.exec(
28
+ `CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_chief
29
+ ON sessions(workspace_id) WHERE role = 'chief'`
30
+ );
31
+
32
+ // A row means the workspace added leads. The chief's autonomy starts at
33
+ // acting only when asked; a later setting lifts it without code.
34
+ db.exec(`
35
+ CREATE TABLE IF NOT EXISTS workspace_leads (
36
+ workspace_id TEXT PRIMARY KEY,
37
+ enabled_at TEXT NOT NULL DEFAULT (datetime('now')),
38
+ chief_autonomy TEXT NOT NULL DEFAULT 'on_request'
39
+ CHECK (chief_autonomy IN ('on_request', 'acts'))
40
+ )
41
+ `);
42
+ db.exec(`
43
+ CREATE TABLE IF NOT EXISTS lead_charters (
44
+ workspace_id TEXT NOT NULL,
45
+ lead_kind TEXT NOT NULL,
46
+ title TEXT NOT NULL,
47
+ area_text TEXT NOT NULL DEFAULT '',
48
+ charter_md TEXT NOT NULL DEFAULT '',
49
+ goal TEXT NOT NULL DEFAULT '',
50
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
51
+ PRIMARY KEY (workspace_id, lead_kind)
52
+ )
53
+ `);
54
+ db.exec(`
55
+ CREATE TABLE IF NOT EXISTS lead_areas (
56
+ workspace_id TEXT NOT NULL,
57
+ lead_kind TEXT NOT NULL,
58
+ kind TEXT NOT NULL CHECK (kind IN ('project', 'path', 'board')),
59
+ value TEXT NOT NULL,
60
+ PRIMARY KEY (workspace_id, lead_kind, kind, value)
61
+ )
62
+ `);
63
+ // Pinned direction quotes a chat item the person wrote; a newer pin
64
+ // supersedes an older one and nothing is deleted.
65
+ db.exec(`
66
+ CREATE TABLE IF NOT EXISTS lead_direction (
67
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
68
+ workspace_id TEXT NOT NULL,
69
+ lead_kind TEXT NOT NULL,
70
+ session_id TEXT NOT NULL,
71
+ chat_item_id TEXT NOT NULL,
72
+ quote TEXT NOT NULL,
73
+ set_at TEXT NOT NULL DEFAULT (datetime('now')),
74
+ superseded_by INTEGER
75
+ )
76
+ `);
77
+ db.exec(
78
+ `CREATE INDEX IF NOT EXISTS idx_lead_direction
79
+ ON lead_direction(workspace_id, lead_kind, id)`
80
+ );
81
+ // Proposed pins (an adopted lead's, or a lead's suggestion) wait here
82
+ // until the person confirms or drops them.
83
+ db.exec(`
84
+ CREATE TABLE IF NOT EXISTS lead_pin_proposals (
85
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
86
+ workspace_id TEXT NOT NULL,
87
+ lead_kind TEXT NOT NULL,
88
+ session_id TEXT NOT NULL,
89
+ chat_item_id TEXT NOT NULL,
90
+ quote TEXT NOT NULL,
91
+ status TEXT NOT NULL DEFAULT 'open'
92
+ CHECK (status IN ('open', 'pinned', 'dropped')),
93
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
94
+ resolved_at TEXT
95
+ )
96
+ `);
97
+ db.exec(
98
+ `CREATE UNIQUE INDEX IF NOT EXISTS idx_lead_pin_proposals_open
99
+ ON lead_pin_proposals(session_id, chat_item_id) WHERE status = 'open'`
100
+ );
101
+ db.exec(`
102
+ CREATE TABLE IF NOT EXISTS lead_memos (
103
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
104
+ workspace_id TEXT NOT NULL,
105
+ lead_kind TEXT NOT NULL,
106
+ text TEXT NOT NULL,
107
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
108
+ )
109
+ `);
110
+ db.exec(
111
+ `CREATE INDEX IF NOT EXISTS idx_lead_memos
112
+ ON lead_memos(workspace_id, lead_kind, id)`
113
+ );
114
+ db.exec(`
115
+ CREATE TABLE IF NOT EXISTS lead_messages (
116
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
117
+ workspace_id TEXT NOT NULL,
118
+ from_kind TEXT NOT NULL,
119
+ to_kind TEXT NOT NULL,
120
+ thread TEXT NOT NULL,
121
+ text TEXT NOT NULL,
122
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
123
+ read_at TEXT
124
+ )
125
+ `);
126
+ db.exec(
127
+ `CREATE INDEX IF NOT EXISTS idx_lead_messages_to
128
+ ON lead_messages(workspace_id, to_kind, id)`
129
+ );
130
+ db.exec(
131
+ `CREATE INDEX IF NOT EXISTS idx_lead_messages_thread
132
+ ON lead_messages(workspace_id, thread, id)`
133
+ );
134
+
135
+ // The decision log and asks per lead; every existing row is Tech's.
136
+ for (const table of ["orchestrator_notes", "orchestrator_asks"])
137
+ if (!columnsOf(db, table).includes("lead_kind"))
138
+ db.exec(
139
+ `ALTER TABLE ${table} ADD COLUMN lead_kind TEXT NOT NULL DEFAULT 'tech'`
140
+ );
141
+ },
142
+ } satisfies Migration;
@@ -0,0 +1,24 @@
1
+ import type { Migration } from "../migration-types";
2
+
3
+ export default {
4
+ key: "20261102T2300_needs_you_chat_per_session",
5
+ up: (db) => {
6
+ // The Needs-you "waiting in chat" item was keyed per workspace
7
+ // (approval:<workspace id>), so one lead's waiting chat would hide
8
+ // another's. It is keyed per session now; a hidden one stays hidden on
9
+ // the workspace's orchestrator, the only chat that showed it. Safe to
10
+ // repeat: a renamed key no longer matches.
11
+ db.exec(`
12
+ UPDATE OR IGNORE needs_you_dismissed
13
+ SET key = 'approval:' || (
14
+ SELECT s.id FROM sessions s
15
+ WHERE s.role = 'orchestrator'
16
+ AND s.workspace_id = needs_you_dismissed.workspace_id)
17
+ WHERE key = 'approval:' || workspace_id
18
+ AND EXISTS (
19
+ SELECT 1 FROM sessions s
20
+ WHERE s.role = 'orchestrator'
21
+ AND s.workspace_id = needs_you_dismissed.workspace_id)
22
+ `);
23
+ },
24
+ } satisfies Migration;
@@ -186,4 +186,6 @@ export const SESSION_ROW_COLUMNS: readonly string[] = [
186
186
  "session_group_id",
187
187
  "agent_role",
188
188
  "crew_id",
189
+ "lead_kind",
190
+ "lead_since",
189
191
  ];
package/lib/db/types.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  import type { AgentType } from "../providers";
2
2
  import type { ChatAccess } from "../chat/events";
3
3
 
4
+ // The standing chats a workspace can have (lib/leads/roles.ts).
5
+ export type SessionRole = "orchestrator" | "lead" | "chief";
6
+
4
7
  export interface Session {
5
8
  id: string;
6
9
  name: string;
@@ -118,8 +121,13 @@ export interface Session {
118
121
  // The agent role it was started in (lib/models/agent-roles) and its crew.
119
122
  agent_role?: string | null;
120
123
  crew_id?: string | null;
121
- // A workspace's standing orchestrator chat (and that workspace), or null.
122
- role: "orchestrator" | null;
124
+ // A workspace's standing chat (and that workspace), or null: its
125
+ // orchestrator, and with Leads its other leads and chief (lib/leads).
126
+ role: SessionRole | null;
127
+ // A lead's department ('tech' for the orchestrator once leads are added)
128
+ // and when it became one.
129
+ lead_kind?: string | null;
130
+ lead_since?: string | null;
123
131
  // An orchestrator's tool-call secret.
124
132
  orch_token?: string | null;
125
133
  workspace_id: string | null;
@@ -1,6 +1,7 @@
1
1
  // Archived sessions: out of the sidebar, the needs-you count and the
2
2
  // orchestrator's view, but kept with their history and chat items.
3
3
 
4
+ import { isStandingRole, roleNoun } from "../leads/roles";
4
5
  import fs from "fs";
5
6
  import { db, queries, type Session } from "../db";
6
7
  import { statusOf } from "../orchestrator/facts";
@@ -102,9 +103,9 @@ export async function archiveChecked(
102
103
  const s = queries.getSession(db).get(id) as Session | undefined;
103
104
  if (!s) throw new Error("Session not found");
104
105
  if (s.archived_at) throw new Error(`${s.name} is already archived`);
105
- if (s.role === "orchestrator")
106
+ if (isStandingRole(s.role))
106
107
  throw new Error(
107
- `${s.name} is a workspace's orchestrator; it can't be archived`
108
+ `${s.name} is a workspace's ${roleNoun(s.role)}; it can't be archived`
108
109
  );
109
110
  if (peerSessionLink(s))
110
111
  throw new Error(`${s.name} runs on another machine: archive it there`);
@@ -3,6 +3,7 @@
3
3
  // session, an orchestrator, one that needs input or is working, or one with
4
4
  // an open PR. Mirrors the sidebar's Done placement (lib/sidebar/shelves).
5
5
 
6
+ import { sqlNoRole } from "../leads/roles";
6
7
  import { db, type Session } from "../db";
7
8
  import { fromSqliteTime } from "../session-meta";
8
9
  import { statusOf } from "../orchestrator/facts";
@@ -34,7 +35,7 @@ export async function archiveStaleDone(now = Date.now()): Promise<string[]> {
34
35
  const rows = db
35
36
  .prepare(
36
37
  `SELECT * FROM session_rows
37
- WHERE archived_at IS NULL AND pinned = 0 AND role IS NULL
38
+ WHERE archived_at IS NULL AND pinned = 0 AND ${sqlNoRole()}
38
39
  AND peer_mirror = 0 AND quick_of IS NULL
39
40
  AND (pr_status IS NULL OR pr_status != 'open')`
40
41
  )
package/lib/done/bulk.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  * The preview says what will happen to each before anything does.
6
6
  */
7
7
 
8
+ import { sqlNoRole, isStandingRole, roleLabel } from "../leads/roles";
8
9
  import { db, type Session } from "../db";
9
10
  import { getProject } from "../projects";
10
11
  import { workspaceProjects } from "../orchestrator/brief";
@@ -52,7 +53,7 @@ function scopeSessions(scope: DoneScope): Session[] {
52
53
  .prepare(
53
54
  `SELECT * FROM session_rows
54
55
  WHERE project_id IN (${projectIds.map(() => "?").join(", ")})
55
- AND role IS NULL AND archived_at IS NULL
56
+ AND ${sqlNoRole()} AND archived_at IS NULL
56
57
  -- A linked machine's own sessions are cleaned up there.
57
58
  AND peer_mirror = 0
58
59
  -- A task another machine's orchestrator moved here is its to finish.
@@ -80,9 +81,9 @@ export async function idleSessions(
80
81
  export function scopeOf(session: Session): DoneScope {
81
82
  // An orchestrator acts through its tools, which Pause holds and which are
82
83
  // logged; aos would reach the same sessions past both.
83
- if (session.role === "orchestrator")
84
+ if (isStandingRole(session.role))
84
85
  throw new Error(
85
- `${session.name} is an orchestrator: it moves, finishes and stops sessions with its move, done and stop tools, not aos`
86
+ `${session.name} is ${roleLabel(session.role)}: it moves, finishes and stops sessions with its move, done and stop tools, not aos`
86
87
  );
87
88
  const project = session.project_id ? getProject(session.project_id) : null;
88
89
  if (!project || project.is_uncategorized)