@saadnvd1/agent-os 0.3.851 → 0.3.859

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/app/api/leads/adopt/route.ts +35 -0
  2. package/app/api/leads/proposals/route.ts +52 -0
  3. package/app/api/leads/route.test.ts +95 -0
  4. package/app/api/leads/route.ts +60 -0
  5. package/app/api/sessions/[id]/fresh/route.ts +2 -1
  6. package/app/api/sessions/[id]/route.ts +9 -1
  7. package/app/api/tmux/kill-all/route.ts +2 -1
  8. package/app/api/workspaces/[id]/orchestrator/tools/route.test.ts +43 -0
  9. package/app/api/workspaces/[id]/orchestrator/tools/route.ts +14 -11
  10. package/components/Analyst/pair.ts +2 -1
  11. package/components/Chat/ChatPanel.tsx +1 -0
  12. package/components/Chat/Forks.tsx +24 -2
  13. package/components/Orchestrator/AskCard.tsx +6 -0
  14. package/components/Orchestrator/NeedsYouStrip.tsx +7 -2
  15. package/components/Pane/index.tsx +5 -7
  16. package/components/Schedules/ScheduleForm.tsx +2 -1
  17. package/components/SessionList/RowSubtitle.tsx +2 -1
  18. package/components/SessionList/SessionRow.tsx +2 -1
  19. package/components/SessionList/useDoneAction.ts +8 -2
  20. package/components/SessionList/useSidebarData.ts +8 -3
  21. package/content/whats-new.json +24 -0
  22. package/data/done/index.ts +7 -1
  23. package/hooks/useNeedsYou.ts +10 -4
  24. package/hooks/useOrchestratorHeader.ts +2 -1
  25. package/lib/analyst/createLink.ts +3 -1
  26. package/lib/away/report.ts +2 -1
  27. package/lib/bus/format.ts +4 -1
  28. package/lib/bus/index.ts +9 -2
  29. package/lib/bus/read-scope.ts +4 -3
  30. package/lib/bus/resolve.ts +4 -1
  31. package/lib/bus/role-alias.ts +2 -1
  32. package/lib/bus/targets.ts +8 -3
  33. package/lib/bus/waits/tick.ts +2 -1
  34. package/lib/chat/compact-context.ts +2 -1
  35. package/lib/chat/mention-roots.ts +2 -1
  36. package/lib/chat/runner.ts +3 -2
  37. package/lib/chat/settings.ts +3 -2
  38. package/lib/chat/worker/extras.ts +4 -1
  39. package/lib/chat/worker/host.ts +6 -5
  40. package/lib/db/migrations/20261102T1944_leads.ts +142 -0
  41. package/lib/db/migrations/20261102T2300_needs_you_chat_per_session.ts +24 -0
  42. package/lib/db/session-parts/columns.ts +2 -0
  43. package/lib/db/types.ts +10 -2
  44. package/lib/done/archive.ts +3 -2
  45. package/lib/done/auto-archive.ts +2 -1
  46. package/lib/done/bulk.ts +4 -3
  47. package/lib/done/index.ts +3 -2
  48. package/lib/done/plan.ts +5 -2
  49. package/lib/experiments/registry.ts +11 -0
  50. package/lib/experiments/view.ts +3 -1
  51. package/lib/fork/brief.ts +2 -2
  52. package/lib/fork/extras.ts +4 -2
  53. package/lib/fork/guard.ts +4 -2
  54. package/lib/fork/scratch.ts +102 -12
  55. package/lib/fork/settle.ts +31 -7
  56. package/lib/fork/start.ts +5 -4
  57. package/lib/fork/tick.ts +19 -0
  58. package/lib/fork/types.ts +2 -0
  59. package/lib/fork/view.ts +10 -0
  60. package/lib/fork/worktree.ts +7 -3
  61. package/lib/health/env/sendFixNotes.ts +3 -2
  62. package/lib/hosts/peer-actions.ts +2 -1
  63. package/lib/hosts/peer-sessions.ts +3 -1
  64. package/lib/leads/actor.ts +16 -0
  65. package/lib/leads/adopt.ts +150 -0
  66. package/lib/leads/area.ts +77 -0
  67. package/lib/leads/brief-common.ts +122 -0
  68. package/lib/leads/brief.ts +155 -0
  69. package/lib/leads/charters.ts +99 -0
  70. package/lib/leads/deliver.ts +24 -0
  71. package/lib/leads/digest.ts +166 -0
  72. package/lib/leads/direction.ts +202 -0
  73. package/lib/leads/home.ts +196 -0
  74. package/lib/leads/index.ts +8 -0
  75. package/lib/leads/memos.ts +70 -0
  76. package/lib/leads/messages.ts +112 -0
  77. package/lib/leads/profiles.ts +83 -0
  78. package/lib/leads/roles.ts +114 -0
  79. package/lib/leads/settings.ts +55 -0
  80. package/lib/leads/templates.ts +51 -0
  81. package/lib/leads/tools.ts +159 -0
  82. package/lib/leads/types.ts +88 -0
  83. package/lib/leads/usage.ts +46 -0
  84. package/lib/learning-cards/input.ts +2 -1
  85. package/lib/live/drive.ts +2 -1
  86. package/lib/models/launch.ts +2 -1
  87. package/lib/models/session-role.ts +2 -1
  88. package/lib/needs-you.ts +12 -6
  89. package/lib/notify/index.ts +2 -1
  90. package/lib/orchestrator/act.ts +3 -1
  91. package/lib/orchestrator/ask-tool.ts +10 -0
  92. package/lib/orchestrator/ask-view.ts +2 -0
  93. package/lib/orchestrator/asks.ts +48 -8
  94. package/lib/orchestrator/brief.ts +32 -67
  95. package/lib/orchestrator/digest.ts +3 -2
  96. package/lib/orchestrator/facts.ts +2 -1
  97. package/lib/orchestrator/full-access.ts +2 -1
  98. package/lib/orchestrator/home.ts +8 -7
  99. package/lib/orchestrator/mcp-servers.ts +7 -6
  100. package/lib/orchestrator/notes.ts +15 -4
  101. package/lib/orchestrator/overview.ts +3 -1
  102. package/lib/orchestrator/recipe-tools.ts +2 -1
  103. package/lib/orchestrator/reminders.ts +2 -1
  104. package/lib/orchestrator/tools.ts +43 -13
  105. package/lib/orchestrator/worker.ts +45 -13
  106. package/lib/orchestrator/write-guard.ts +2 -2
  107. package/lib/projects.ts +3 -2
  108. package/lib/resurrect/find.ts +2 -1
  109. package/lib/resurrect/index.ts +3 -2
  110. package/lib/schedules/index.ts +3 -2
  111. package/lib/schedules/start.ts +2 -1
  112. package/lib/schedules/store.ts +3 -2
  113. package/lib/session-audit/sources.ts +4 -3
  114. package/lib/session-groups/workspace.ts +2 -1
  115. package/lib/sessions/launch.ts +2 -1
  116. package/lib/sidebar/machines.ts +2 -1
  117. package/lib/sidebar/row-actions.ts +2 -1
  118. package/lib/sidebar/row-badge.ts +3 -1
  119. package/lib/sidebar/row-menu.ts +6 -3
  120. package/lib/sidebar/shelves.ts +3 -3
  121. package/lib/status/collect.ts +4 -3
  122. package/lib/tasks/held-replies.ts +2 -1
  123. package/lib/tasks/move-targets.ts +2 -1
  124. package/lib/tasks/pause.ts +2 -1
  125. package/lib/tasks/starter.ts +3 -2
  126. package/lib/workspaces.ts +4 -3
  127. package/lib/worktree-sweep/findSettled.ts +2 -1
  128. package/package.json +1 -1
@@ -0,0 +1,159 @@
1
+ // The tool calls of a workspace's standing sessions, as the server answers
2
+ // them: the caller's profile decides which tools it has, a lead's starts
3
+ // and stops stay inside its area, and what it does is recorded as its own.
4
+
5
+ import { z } from "zod";
6
+ import type { Session } from "@/lib/db/types";
7
+ import { isToolName, runTool } from "../orchestrator/serve";
8
+ import { owner } from "../owner";
9
+ import { asActor } from "./actor";
10
+ import { areaRefusal } from "./area";
11
+ import { currentDirection, openProposals, proposePin } from "./direction";
12
+ import { leadTitle, listLeads } from "./home";
13
+ import { latestMemo, writeMemo } from "./memos";
14
+ import {
15
+ isLeadToolName,
16
+ profileFor,
17
+ profileHas,
18
+ type LeadToolName,
19
+ type Profile,
20
+ } from "./profiles";
21
+ import { isLeadKind, leadKindOf, roleNoun } from "./roles";
22
+ import { hasLeads } from "./settings";
23
+ import type { LeadKind } from "./types";
24
+
25
+ export const LEAD_TOOL_SHAPES = {
26
+ update_memo: {
27
+ text: z
28
+ .string()
29
+ .trim()
30
+ .min(1)
31
+ .max(6000)
32
+ .describe(
33
+ "The whole memo, rewritten: what you're working on, what's decided, what's next"
34
+ ),
35
+ },
36
+ propose_pin: {
37
+ item: z
38
+ .string()
39
+ .trim()
40
+ .min(1)
41
+ .max(200)
42
+ .describe("The chat item id of the owner's message to pin"),
43
+ },
44
+ direction: {
45
+ lead: z
46
+ .string()
47
+ .trim()
48
+ .max(32)
49
+ .optional()
50
+ .describe(
51
+ "Another lead's kind (marketing, product, tech); yours if left out"
52
+ ),
53
+ },
54
+ } satisfies Record<LeadToolName, z.ZodRawShape>;
55
+
56
+ export const LEAD_TOOL_DESCRIPTIONS: Record<LeadToolName, string> = {
57
+ update_memo:
58
+ "Rewrite your memo: the short note (about 1.5k tokens) you carry across a fresh start, with your pinned direction. Every version is kept.",
59
+ propose_pin:
60
+ "Propose one of the owner's own messages in your chat as pinned direction. The quote is copied by the server, and it is direction only once he confirms it with a tap.",
61
+ direction:
62
+ "The pinned direction in force for you or another lead of this workspace (newest first), with your open proposals and memo.",
63
+ };
64
+
65
+ function parseLeadArgs<T extends LeadToolName>(tool: T, args: unknown) {
66
+ const parsed = z
67
+ .object(LEAD_TOOL_SHAPES[tool])
68
+ .strict()
69
+ .safeParse(args ?? {});
70
+ if (!parsed.success)
71
+ throw new Error(
72
+ `Bad arguments for ${tool}: ${parsed.error.issues.map((i) => `${i.path.join(".") || "args"} ${i.message}`).join("; ")}`
73
+ );
74
+ return parsed.data as unknown as z.infer<
75
+ z.ZodObject<(typeof LEAD_TOOL_SHAPES)[T]>
76
+ >;
77
+ }
78
+
79
+ function directionText(w: string, kind: LeadKind, me: LeadKind): string {
80
+ const pins = currentDirection(w, kind);
81
+ const title = leadTitle(w, kind);
82
+ const lines = [
83
+ pins.length
84
+ ? `Pinned direction for the ${title}, from ${owner()}, newest first:\n${pins.map((p) => `- [pin ${p.id}, ${p.setAt.slice(0, 10)}] "${p.quote}"`).join("\n")}`
85
+ : `The ${title} has no pinned direction.`,
86
+ ];
87
+ if (kind === me) {
88
+ const open = openProposals(w, kind);
89
+ if (open.length)
90
+ lines.push(
91
+ `Proposed, waiting for ${owner()}'s tap:\n${open.map((p) => `- [proposal ${p.id}] "${p.quote}"`).join("\n")}`
92
+ );
93
+ const memo = latestMemo(w, kind);
94
+ lines.push(memo ? `Your memo:\n${memo.text}` : "You have no memo yet.");
95
+ }
96
+ return lines.join("\n\n");
97
+ }
98
+
99
+ function runLeadTool(
100
+ w: string,
101
+ caller: Session,
102
+ kind: LeadKind,
103
+ tool: LeadToolName,
104
+ raw: unknown
105
+ ): string {
106
+ switch (tool) {
107
+ case "update_memo": {
108
+ const memo = writeMemo(w, kind, parseLeadArgs(tool, raw).text);
109
+ return `Memo saved (${memo.text.length} characters). A fresh start reads it with your pinned direction.`;
110
+ }
111
+ case "propose_pin": {
112
+ const out = proposePin({
113
+ workspaceId: w,
114
+ leadKind: kind,
115
+ sessionId: caller.id,
116
+ chatItemId: parseLeadArgs(tool, raw).item,
117
+ });
118
+ if ("error" in out) throw new Error(out.error);
119
+ return `Proposed as pin ${out.id}: "${out.quote}". It's direction once ${owner()} confirms it.`;
120
+ }
121
+ case "direction": {
122
+ const lead = parseLeadArgs(tool, raw).lead ?? kind;
123
+ if (!isLeadKind(lead)) throw new Error(`No lead "${lead}"`);
124
+ if (!listLeads(w).some((s) => leadKindOf(s) === lead))
125
+ throw new Error(`This workspace has no ${lead} lead`);
126
+ return directionText(w, lead, kind);
127
+ }
128
+ }
129
+ }
130
+
131
+ export class NotYours extends Error {}
132
+ export class UnknownTool extends Error {}
133
+
134
+ // One tool call from a standing session. Unknown names stay "unknown", as
135
+ // they always were; a known tool outside the caller's profile is refused.
136
+ export async function runStandingTool(
137
+ w: string,
138
+ caller: Session,
139
+ tool: string,
140
+ args: unknown
141
+ ): Promise<string> {
142
+ const profile: Profile | null = profileFor(caller, hasLeads(w));
143
+ if (!profile)
144
+ throw new NotYours(`The ${roleNoun(caller.role)} has no tools here yet`);
145
+ if (!isToolName(tool) && !isLeadToolName(tool))
146
+ throw new UnknownTool("Unknown tool");
147
+ if (!profileHas(profile, tool))
148
+ throw new NotYours(
149
+ `${tool} isn't one of your tools as ${leadTitle(w, profile.kind)}: it's the Tech lead's. Hand it over with send.`
150
+ );
151
+ if (isLeadToolName(tool))
152
+ return runLeadTool(w, caller, profile.kind, tool, args);
153
+ if (!isToolName(tool)) throw new UnknownTool("Unknown tool");
154
+ const refusal = profile.inArea
155
+ ? areaRefusal(w, profile.kind, tool, args)
156
+ : null;
157
+ if (refusal) throw new Error(refusal);
158
+ return asActor(caller, () => runTool(w, tool, args ?? {}));
159
+ }
@@ -0,0 +1,88 @@
1
+ // A lead's department: the shipped kinds, or a custom slug.
2
+ export type LeadKind = string;
3
+
4
+ export const TECH: LeadKind = "tech";
5
+ export const BUILT_IN_KINDS = ["tech", "marketing", "product"] as const;
6
+
7
+ // How far a workspace's chief of staff may go without being asked. V1 acts
8
+ // only on request; its sweep reads and raises asks.
9
+ export type ChiefAutonomy = "on_request" | "acts";
10
+
11
+ export interface WorkspaceLeads {
12
+ workspaceId: string;
13
+ enabledAt: string;
14
+ chiefAutonomy: ChiefAutonomy;
15
+ }
16
+
17
+ export interface LeadCharter {
18
+ workspaceId: string;
19
+ leadKind: LeadKind;
20
+ title: string;
21
+ areaText: string;
22
+ charterMd: string;
23
+ goal: string;
24
+ updatedAt: string;
25
+ }
26
+
27
+ export type LeadAreaKind = "project" | "path" | "board";
28
+
29
+ export interface LeadArea {
30
+ kind: LeadAreaKind;
31
+ value: string;
32
+ }
33
+
34
+ export interface DirectionPin {
35
+ id: number;
36
+ workspaceId: string;
37
+ leadKind: LeadKind;
38
+ sessionId: string;
39
+ chatItemId: string;
40
+ quote: string;
41
+ setAt: string;
42
+ supersededBy: number | null;
43
+ }
44
+
45
+ export type ProposalStatus = "open" | "pinned" | "dropped";
46
+
47
+ export interface PinProposal {
48
+ id: number;
49
+ workspaceId: string;
50
+ leadKind: LeadKind;
51
+ sessionId: string;
52
+ chatItemId: string;
53
+ quote: string;
54
+ status: ProposalStatus;
55
+ createdAt: string;
56
+ resolvedAt: string | null;
57
+ }
58
+
59
+ export interface LeadMemo {
60
+ id: number;
61
+ workspaceId: string;
62
+ leadKind: LeadKind;
63
+ text: string;
64
+ createdAt: string;
65
+ }
66
+
67
+ export interface LeadMessage {
68
+ id: number;
69
+ workspaceId: string;
70
+ fromKind: LeadKind;
71
+ toKind: LeadKind;
72
+ thread: string;
73
+ text: string;
74
+ createdAt: string;
75
+ readAt: string | null;
76
+ }
77
+
78
+ export interface LeadUsage {
79
+ sessionId: string;
80
+ leadKind: LeadKind;
81
+ role: string;
82
+ // Model calls (one per assistant response).
83
+ calls: number;
84
+ inputTokens: number;
85
+ cacheReadTokens: number;
86
+ cacheWriteTokens: number;
87
+ outputTokens: number;
88
+ }
@@ -0,0 +1,46 @@
1
+ import { db } from "@/lib/db";
2
+ import { leadKindOf, sqlStanding } from "./roles";
3
+ import type { LeadUsage } from "./types";
4
+
5
+ // What each standing chat in a workspace spent since `sinceMs`, from the
6
+ // calls every chat worker already records (lib/usage/calls). An adopted
7
+ // lead counts only from when it became one, so week one is measured, not
8
+ // guessed.
9
+ export function leadUsage(workspaceId: string, sinceMs: number): LeadUsage[] {
10
+ const rows = db
11
+ .prepare(
12
+ `SELECT s.id AS session_id, s.role, s.lead_kind,
13
+ COUNT(c.id) AS calls,
14
+ COALESCE(SUM(c.input_tokens), 0) AS input,
15
+ COALESCE(SUM(c.cache_read_tokens), 0) AS cache_read,
16
+ COALESCE(SUM(c.cache_write_5m_tokens + c.cache_write_1h_tokens), 0) AS cache_write,
17
+ COALESCE(SUM(c.output_tokens), 0) AS output
18
+ FROM sessions s
19
+ LEFT JOIN usage_calls c ON c.session_id = s.id
20
+ AND c.at >= MAX(?, COALESCE(
21
+ CAST(strftime('%s', s.lead_since) AS INTEGER) * 1000, 0))
22
+ WHERE s.workspace_id = ? AND ${sqlStanding("s.role")}
23
+ GROUP BY s.id
24
+ ORDER BY s.role, s.lead_kind`
25
+ )
26
+ .all(sinceMs, workspaceId) as {
27
+ session_id: string;
28
+ role: string;
29
+ lead_kind: string | null;
30
+ calls: number;
31
+ input: number;
32
+ cache_read: number;
33
+ cache_write: number;
34
+ output: number;
35
+ }[];
36
+ return rows.map((r) => ({
37
+ sessionId: r.session_id,
38
+ leadKind: leadKindOf(r) ?? r.role,
39
+ role: r.role,
40
+ calls: r.calls,
41
+ inputTokens: r.input,
42
+ cacheReadTokens: r.cache_read,
43
+ cacheWriteTokens: r.cache_write,
44
+ outputTokens: r.output,
45
+ }));
46
+ }
@@ -2,6 +2,7 @@
2
2
  // brief, the final report and the tool and user steps, each cut down.
3
3
  // Never the transcript.
4
4
 
5
+ import { isStandingRole, roleNoun } from "../leads/roles";
5
6
  import { db, queries, type Session } from "../db";
6
7
  import { itemsOfKind, itemsOfKinds } from "../chat/store";
7
8
  import { isGitRepo } from "../git";
@@ -61,7 +62,7 @@ export async function readSession(id: string): Promise<SessionInput | null> {
61
62
  BRIEF
62
63
  );
63
64
  let skip: string | null = null;
64
- if (s.role === "orchestrator") skip = "orchestrator";
65
+ if (isStandingRole(s.role)) skip = roleNoun(s.role);
65
66
  else if (s.quick_of) skip = "quick change";
66
67
  else if (!steps.length && !final) skip = "no conversation";
67
68
 
package/lib/live/drive.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  * worker's guest gate (lib/chat/worker/guest-gate).
7
7
  */
8
8
 
9
+ import { isStandingRole, roleLabel } from "../leads/roles";
9
10
  import os from "os";
10
11
  import path from "path";
11
12
  import { randomBytes } from "crypto";
@@ -69,7 +70,7 @@ const home = (p: string) => path.resolve(p.replace(/^~(?=$|\/)/, os.homedir()));
69
70
  export function driveRefusal(s: Session): string | null {
70
71
  if (s.view !== "chat" || s.agent_type !== "claude")
71
72
  return "Claude chat sessions only";
72
- if (s.role) return "Not for an orchestrator";
73
+ if (isStandingRole(s.role)) return `Not for ${roleLabel(s.role)}`;
73
74
  if (!s.task_prompt) return "Task sessions only";
74
75
  if (!s.worktree_path) return "Task sessions with their own worktree only";
75
76
  const project = s.project_id ? getProject(s.project_id) : undefined;
@@ -3,6 +3,7 @@
3
3
  * name for its CLI, and the provider's endpoint and key in its environment.
4
4
  */
5
5
 
6
+ import { isStandingRole } from "../leads/roles";
6
7
  import type { Session } from "../db";
7
8
  import {
8
9
  defaultRoute,
@@ -72,7 +73,7 @@ export async function routeOrchestrator(
72
73
  session: Session,
73
74
  exec?: SecretCommand
74
75
  ): Promise<Session> {
75
- if (session.role !== "orchestrator") return session;
76
+ if (!isStandingRole(session.role)) return session;
76
77
  let routed;
77
78
  try {
78
79
  routed = resolveRole("orchestrator", { workspaceId: session.workspace_id });
@@ -1,3 +1,4 @@
1
+ import { isStandingRole } from "../leads/roles";
1
2
  import { orchestratorOf } from "../tasks/starter";
2
3
  import type { ModelRole } from "./roles";
3
4
 
@@ -9,7 +10,7 @@ export function sessionRole(session: {
9
10
  plan_phase?: string | null;
10
11
  started_by?: string | null;
11
12
  }): ModelRole {
12
- if (session.role === "orchestrator") return "orchestrator";
13
+ if (isStandingRole(session.role)) return "orchestrator";
13
14
  // Work an orchestrator started is implementation, not a person's session.
14
15
  if (!session.task_status)
15
16
  return orchestratorOf(session.started_by) ? "implement" : "interactive";
package/lib/needs-you.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import { getDb, type Session } from "./db";
2
2
  import type { ChatItem, ChatState } from "./chat/events";
3
3
  import { originOf } from "./chat/origin";
4
- import { openAskCount } from "./orchestrator/asks";
4
+ import { standingAskCount } from "./orchestrator/asks";
5
+ import { isStandingRole } from "./leads/roles";
5
6
  import { notifyStatusChanged } from "./status/hub";
6
7
  import type { SessionNeed } from "./sidebar/shelves";
7
8
  import type { ProgramSummary } from "./program-status/records";
@@ -36,7 +37,12 @@ export function needsYou(
36
37
  Partial<
37
38
  Pick<
38
39
  Session,
39
- "role" | "workspace_id" | "task_status" | "settled_at" | "archived_at"
40
+ | "role"
41
+ | "lead_kind"
42
+ | "workspace_id"
43
+ | "task_status"
44
+ | "settled_at"
45
+ | "archived_at"
40
46
  >
41
47
  >,
42
48
  chatState: ChatState | null
@@ -46,8 +52,8 @@ export function needsYou(
46
52
  // A finished task's agent is stopped: its last turn is history, not news.
47
53
  if (session.task_status && session.task_status !== "running") return false;
48
54
  if (chatState === "waiting") return true;
49
- if (session.role === "orchestrator")
50
- return openAskCount(session.workspace_id) > 0;
55
+ if (isStandingRole(session.role))
56
+ return standingAskCount({ ...session, role: session.role }) > 0;
51
57
  if (session.archived_at) return false;
52
58
  const latest = latestChatItem(session.id);
53
59
  // Put on the Done shelf after its last turn: that turn is history, not
@@ -89,7 +95,7 @@ export function chatNeed(
89
95
  state: ChatState | null,
90
96
  asks = 0
91
97
  ): SessionNeed | null {
92
- if (session.role === "orchestrator")
98
+ if (isStandingRole(session.role))
93
99
  return asks > 0 || state === "waiting" ? "answer" : null;
94
100
  if (state === "waiting")
95
101
  return pendingApproval(session.id)?.questions?.length
@@ -135,7 +141,7 @@ export function isUnread(
135
141
  const seen = sqliteMs(session.last_seen_at);
136
142
  if (session.view === "chat") {
137
143
  if (chatState === "running" || chatState === "waiting") return false;
138
- if (session.role === "orchestrator")
144
+ if (isStandingRole(session.role))
139
145
  return orchestratorReplied(session.id, seen);
140
146
  const latest = latestChatItem(session.id);
141
147
  return latest?.kind === "turn_end" && latest.createdAt > seen;
@@ -5,6 +5,7 @@
5
5
  * Needs-you list has those.
6
6
  */
7
7
 
8
+ import { isStandingRole } from "../leads/roles";
8
9
  import { db, type Session } from "../db";
9
10
  import { Limiter, type Outcome } from "./limiter";
10
11
  import { holdForBatch, msToNextSweep } from "../away/state";
@@ -167,7 +168,7 @@ export async function notifyFrom(
167
168
  const body = text.trim();
168
169
  if (!body) throw new Error("Nothing to send");
169
170
  if (
170
- session?.role === "orchestrator" &&
171
+ isStandingRole(session?.role) &&
171
172
  session.workspace_id &&
172
173
  holdForBatch(session.workspace_id, session.id, body)
173
174
  )
@@ -44,6 +44,7 @@ import { findPiece, pieceLabel, workspacePieces } from "../plans/claim";
44
44
  import { approvePlanNow } from "../models/plan-step";
45
45
  import { braked } from "./brakes";
46
46
  import { getOrchestrator } from "./home";
47
+ import { actorIn } from "../leads/actor";
47
48
  import { addNote } from "./notes";
48
49
  import { findWorkspaceSession } from "./read";
49
50
  import { assignSessions, resolveGroup } from "../session-groups";
@@ -64,8 +65,9 @@ import {
64
65
  import { pauseUntil, type PausePreset } from "../sidebar/snooze";
65
66
  import type { Priority } from "../tasks/schedule-types";
66
67
 
68
+ // Who is acting: the lead whose tool call this is, else the orchestrator.
67
69
  function orchestratorId(workspaceId: string): string {
68
- const o = getOrchestrator(workspaceId);
70
+ const o = actorIn(workspaceId) ?? getOrchestrator(workspaceId);
69
71
  if (!o) throw new Error("This workspace has no orchestrator yet");
70
72
  return o.id;
71
73
  }
@@ -1,3 +1,6 @@
1
+ import { actorIn } from "../leads/actor";
2
+ import { leadKindOf } from "../leads/roles";
3
+ import { TECH } from "../leads/types";
1
4
  import type { Session } from "../db";
2
5
  import { prFor } from "../tasks/session";
3
6
  import {
@@ -90,6 +93,12 @@ export async function askSaad(
90
93
  sha?: string;
91
94
  }
92
95
  ): Promise<string> {
96
+ const actor = actorIn(workspaceId);
97
+ const leadKind = (actor && leadKindOf(actor)) ?? TECH;
98
+ if (a.task && leadKind !== TECH)
99
+ throw new Error(
100
+ "Only the Tech lead asks to merge a PR; hand it over with send"
101
+ );
93
102
  if (!a.task !== !a.sha)
94
103
  throw new Error(
95
104
  "Give both task and sha to ask about merging a PR at a commit, or neither"
@@ -116,6 +125,7 @@ export async function askSaad(
116
125
  link: merge ? merge.url : (a.link ?? null),
117
126
  sha: merge?.sha ?? null,
118
127
  raisedBy: merge ? "orchestrator" : null,
128
+ leadKind,
119
129
  });
120
130
  } catch (error) {
121
131
  if (error instanceof AskRefused) return `Not asked: ${error.message}.`;
@@ -30,6 +30,8 @@ export interface AskView {
30
30
  binding: string;
31
31
  presence: boolean;
32
32
  createdAt: string;
33
+ // The lead it is for: "tech" (the orchestrator) unless a lead raised it.
34
+ leadKind: string;
33
35
  }
34
36
 
35
37
  // A question the orchestrator put to Saad in chat, open until answered.
@@ -18,8 +18,15 @@ import { addNote } from "./notes";
18
18
  import { cleanTitle, classifyKind, titleSubject } from "./ask-text";
19
19
  import { revokePasskey } from "../security/passkeys";
20
20
  import type { AskKind } from "./ask-view";
21
+ import { TECH, type LeadKind } from "../leads/types";
22
+ import { leadKindOf } from "../leads/roles";
21
23
  import { owner, ownerUi } from "../owner";
22
24
 
25
+ const tellLead = (workspaceId: string, kind: LeadKind, line: string) =>
26
+ import("../leads/deliver")
27
+ .then((m) => m.tellLead(workspaceId, kind, line))
28
+ .catch((e) => console.error(`Ask answer for the ${kind} lead:`, e));
29
+
23
30
  export { ASK_KINDS, type AskKind } from "./ask-view";
24
31
 
25
32
  // The kinds the orchestrator raises itself; gate and brake asks come from
@@ -79,6 +86,9 @@ export interface AskRow {
79
86
  // "orchestrator" for a merge ask it raised with ask_owner; null when the
80
87
  // gates, the brakes or AgentOS raised or last wrote it.
81
88
  raised_by: string | null;
89
+ // The lead it is for (lib/leads): "tech", the orchestrator, unless a lead
90
+ // raised it. Two leads on one subject share the first one's ask.
91
+ lead_kind: LeadKind;
82
92
  }
83
93
 
84
94
  export type AskAnswer =
@@ -165,6 +175,7 @@ export function raiseAsk(input: {
165
175
  sha?: string | null;
166
176
  brakeKey?: string | null;
167
177
  raisedBy?: "orchestrator" | null;
178
+ leadKind?: LeadKind;
168
179
  }): { ask: AskRow; created: boolean } {
169
180
  const title = cleanTitle(input.title);
170
181
  if (!title) throw new Error("An ask needs a title");
@@ -197,8 +208,8 @@ export function raiseAsk(input: {
197
208
  if (refused) throw new AskRefused(refused);
198
209
  const { lastInsertRowid } = db
199
210
  .prepare(
200
- `INSERT INTO orchestrator_asks (workspace_id, subject, kind, title, detail, link, sha, brake_key, raised_by)
201
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
211
+ `INSERT INTO orchestrator_asks (workspace_id, subject, kind, title, detail, link, sha, brake_key, raised_by, lead_kind)
212
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
202
213
  )
203
214
  .run(
204
215
  input.workspaceId,
@@ -209,7 +220,8 @@ export function raiseAsk(input: {
209
220
  link,
210
221
  sha,
211
222
  brakeKey,
212
- raisedBy
223
+ raisedBy,
224
+ input.leadKind ?? TECH
213
225
  );
214
226
  return {
215
227
  ask: getAsk(input.workspaceId, Number(lastInsertRowid))!,
@@ -254,17 +266,34 @@ export function openAsks(workspaceId: string): AskRow[] {
254
266
  .all(workspaceId) as AskRow[];
255
267
  }
256
268
 
257
- export function openAskCount(workspaceId: string | null | undefined): number {
269
+ export function openAskCount(
270
+ workspaceId: string | null | undefined,
271
+ leadKind?: LeadKind
272
+ ): number {
258
273
  if (!workspaceId) return 0;
274
+ const lead = leadKind === undefined ? "" : " AND lead_kind = ?";
259
275
  return (
260
276
  db
261
277
  .prepare(
262
- `SELECT COUNT(*) AS n FROM orchestrator_asks WHERE workspace_id = ? AND status = 'open'`
278
+ `SELECT COUNT(*) AS n FROM orchestrator_asks WHERE workspace_id = ? AND status = 'open'${lead}`
263
279
  )
264
- .get(workspaceId) as { n: number }
280
+ .get(workspaceId, ...(leadKind === undefined ? [] : [leadKind])) as {
281
+ n: number;
282
+ }
265
283
  ).n;
266
284
  }
267
285
 
286
+ // The open asks a standing session answers for: its own lead's. The chief
287
+ // heads no department, so none are its.
288
+ export function standingAskCount(s: {
289
+ role: string | null;
290
+ lead_kind?: string | null;
291
+ workspace_id?: string | null;
292
+ }): number {
293
+ const kind = leadKindOf(s);
294
+ return kind ? openAskCount(s.workspace_id, kind) : 0;
295
+ }
296
+
268
297
  const short = (sha: string | null) => (sha ? sha.slice(0, 7) : "its head");
269
298
 
270
299
  // What an approval lets the orchestrator do, said in the event so it can't
@@ -320,6 +349,10 @@ export function answerAsk(
320
349
  const text =
321
350
  answer.action === "reply" ? cap(answer.text, 4000) : answer.action;
322
351
  if (answer.action === "reply" && !text) throw new Error("The reply is empty");
352
+ // Another lead's ask is answered in its own chat; Tech's events stay
353
+ // exactly as they were.
354
+ const ownLead =
355
+ ask.lead_kind && ask.lead_kind !== TECH ? ask.lead_kind : null;
323
356
  const line = answerLine(
324
357
  ask,
325
358
  answer.action === "reply" ? { action: "reply", text } : answer,
@@ -330,11 +363,18 @@ export function answerAsk(
330
363
  `UPDATE orchestrator_asks SET status = ?, answer = ?, resolved_at = datetime('now') WHERE id = ?`
331
364
  ).run(STATUS_OF[answer.action], text, id);
332
365
  if (ask.kind === "passkey") settlePasskeyAsk(ask, answer.action);
333
- else queueEvent(workspaceId, answerKey(id), null, line);
366
+ else if (!ownLead) queueEvent(workspaceId, answerKey(id), null, line);
334
367
  })();
368
+ if (ownLead && ask.kind !== "passkey")
369
+ void tellLead(workspaceId, ownLead, line);
335
370
  // An auto-approval is noted once, by recordAutoApproval.
336
371
  if (by === "saad")
337
- addNote(workspaceId, `${ownerUi(true)} answered ${line}`, "ask");
372
+ addNote(
373
+ workspaceId,
374
+ `${ownerUi(true)} answered ${line}`,
375
+ "ask",
376
+ ownLead ?? undefined
377
+ );
338
378
  return getAsk(workspaceId, id)!;
339
379
  }
340
380