@saadnvd1/agent-os 0.3.853 → 0.3.859

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/app/api/leads/adopt/route.ts +35 -0
  2. package/app/api/leads/proposals/route.ts +52 -0
  3. package/app/api/leads/route.test.ts +95 -0
  4. package/app/api/leads/route.ts +60 -0
  5. package/app/api/sessions/[id]/fresh/route.ts +2 -1
  6. package/app/api/sessions/[id]/route.ts +9 -1
  7. package/app/api/tmux/kill-all/route.ts +2 -1
  8. package/app/api/workspaces/[id]/orchestrator/tools/route.test.ts +43 -0
  9. package/app/api/workspaces/[id]/orchestrator/tools/route.ts +14 -11
  10. package/components/Analyst/pair.ts +2 -1
  11. package/components/Chat/ChatPanel.tsx +1 -0
  12. package/components/Orchestrator/AskCard.tsx +6 -0
  13. package/components/Orchestrator/NeedsYouStrip.tsx +7 -2
  14. package/components/Pane/index.tsx +5 -7
  15. package/components/Schedules/ScheduleForm.tsx +2 -1
  16. package/components/SessionList/RowSubtitle.tsx +2 -1
  17. package/components/SessionList/SessionRow.tsx +2 -1
  18. package/components/SessionList/useSidebarData.ts +8 -3
  19. package/content/whats-new.json +16 -0
  20. package/hooks/useNeedsYou.ts +10 -4
  21. package/hooks/useOrchestratorHeader.ts +2 -1
  22. package/lib/analyst/createLink.ts +3 -1
  23. package/lib/away/report.ts +2 -1
  24. package/lib/bus/format.ts +4 -1
  25. package/lib/bus/index.ts +9 -2
  26. package/lib/bus/read-scope.ts +4 -3
  27. package/lib/bus/resolve.ts +4 -1
  28. package/lib/bus/role-alias.ts +2 -1
  29. package/lib/bus/targets.ts +8 -3
  30. package/lib/bus/waits/tick.ts +2 -1
  31. package/lib/chat/compact-context.ts +2 -1
  32. package/lib/chat/mention-roots.ts +2 -1
  33. package/lib/chat/runner.ts +3 -2
  34. package/lib/chat/settings.ts +3 -2
  35. package/lib/chat/worker/extras.ts +4 -1
  36. package/lib/chat/worker/host.ts +6 -5
  37. package/lib/db/migrations/20261102T1944_leads.ts +142 -0
  38. package/lib/db/migrations/20261102T2300_needs_you_chat_per_session.ts +24 -0
  39. package/lib/db/session-parts/columns.ts +2 -0
  40. package/lib/db/types.ts +10 -2
  41. package/lib/done/archive.ts +3 -2
  42. package/lib/done/auto-archive.ts +2 -1
  43. package/lib/done/bulk.ts +4 -3
  44. package/lib/done/index.ts +3 -2
  45. package/lib/done/plan.ts +5 -2
  46. package/lib/experiments/registry.ts +11 -0
  47. package/lib/experiments/view.ts +3 -1
  48. package/lib/fork/brief.ts +2 -2
  49. package/lib/fork/extras.ts +4 -2
  50. package/lib/fork/guard.ts +4 -2
  51. package/lib/fork/scratch.ts +102 -12
  52. package/lib/fork/settle.ts +31 -7
  53. package/lib/fork/start.ts +5 -4
  54. package/lib/fork/tick.ts +19 -0
  55. package/lib/fork/worktree.ts +7 -3
  56. package/lib/health/env/sendFixNotes.ts +3 -2
  57. package/lib/hosts/peer-actions.ts +2 -1
  58. package/lib/hosts/peer-sessions.ts +3 -1
  59. package/lib/leads/actor.ts +16 -0
  60. package/lib/leads/adopt.ts +150 -0
  61. package/lib/leads/area.ts +77 -0
  62. package/lib/leads/brief-common.ts +122 -0
  63. package/lib/leads/brief.ts +155 -0
  64. package/lib/leads/charters.ts +99 -0
  65. package/lib/leads/deliver.ts +24 -0
  66. package/lib/leads/digest.ts +166 -0
  67. package/lib/leads/direction.ts +202 -0
  68. package/lib/leads/home.ts +196 -0
  69. package/lib/leads/index.ts +8 -0
  70. package/lib/leads/memos.ts +70 -0
  71. package/lib/leads/messages.ts +112 -0
  72. package/lib/leads/profiles.ts +83 -0
  73. package/lib/leads/roles.ts +114 -0
  74. package/lib/leads/settings.ts +55 -0
  75. package/lib/leads/templates.ts +51 -0
  76. package/lib/leads/tools.ts +159 -0
  77. package/lib/leads/types.ts +88 -0
  78. package/lib/leads/usage.ts +46 -0
  79. package/lib/learning-cards/input.ts +2 -1
  80. package/lib/live/drive.ts +2 -1
  81. package/lib/models/launch.ts +2 -1
  82. package/lib/models/session-role.ts +2 -1
  83. package/lib/needs-you.ts +12 -6
  84. package/lib/notify/index.ts +2 -1
  85. package/lib/orchestrator/act.ts +3 -1
  86. package/lib/orchestrator/ask-tool.ts +10 -0
  87. package/lib/orchestrator/ask-view.ts +2 -0
  88. package/lib/orchestrator/asks.ts +48 -8
  89. package/lib/orchestrator/brief.ts +32 -67
  90. package/lib/orchestrator/digest.ts +3 -2
  91. package/lib/orchestrator/facts.ts +2 -1
  92. package/lib/orchestrator/full-access.ts +2 -1
  93. package/lib/orchestrator/home.ts +8 -7
  94. package/lib/orchestrator/mcp-servers.ts +7 -6
  95. package/lib/orchestrator/notes.ts +15 -4
  96. package/lib/orchestrator/overview.ts +3 -1
  97. package/lib/orchestrator/recipe-tools.ts +2 -1
  98. package/lib/orchestrator/reminders.ts +2 -1
  99. package/lib/orchestrator/tools.ts +43 -13
  100. package/lib/orchestrator/worker.ts +45 -13
  101. package/lib/orchestrator/write-guard.ts +2 -2
  102. package/lib/projects.ts +3 -2
  103. package/lib/resurrect/find.ts +2 -1
  104. package/lib/resurrect/index.ts +3 -2
  105. package/lib/schedules/index.ts +3 -2
  106. package/lib/schedules/start.ts +2 -1
  107. package/lib/schedules/store.ts +3 -2
  108. package/lib/session-audit/sources.ts +4 -3
  109. package/lib/session-groups/workspace.ts +2 -1
  110. package/lib/sessions/launch.ts +2 -1
  111. package/lib/sidebar/machines.ts +2 -1
  112. package/lib/sidebar/row-actions.ts +2 -1
  113. package/lib/sidebar/row-badge.ts +3 -1
  114. package/lib/sidebar/row-menu.ts +6 -3
  115. package/lib/sidebar/shelves.ts +3 -3
  116. package/lib/status/collect.ts +4 -3
  117. package/lib/tasks/held-replies.ts +2 -1
  118. package/lib/tasks/move-targets.ts +2 -1
  119. package/lib/tasks/pause.ts +2 -1
  120. package/lib/tasks/starter.ts +3 -2
  121. package/lib/workspaces.ts +4 -3
  122. package/lib/worktree-sweep/findSettled.ts +2 -1
  123. package/package.json +1 -1
@@ -0,0 +1,166 @@
1
+ /**
2
+ * What a lead carries across a fresh start: its pinned direction (all of it,
3
+ * newest first), its memo, the pins it proposed, unread mail from other
4
+ * leads, and its own asks, work and decision log. Read from the database at
5
+ * every start, no model involved, under the same token budget as the
6
+ * orchestrator's digest.
7
+ */
8
+
9
+ import { db } from "../db";
10
+ import { owner } from "../owner";
11
+ import {
12
+ CHARS_PER_TOKEN,
13
+ clip,
14
+ DIGEST_TOKENS,
15
+ renderDigest,
16
+ type Section,
17
+ } from "../orchestrator/digest";
18
+ import { openAsks } from "../orchestrator/asks";
19
+ import { shortId } from "../orchestrator/describe";
20
+ import { untrusted } from "../orchestrator/untrusted";
21
+ import { currentDirection, openProposals } from "./direction";
22
+ import { latestMemo } from "./memos";
23
+ import { unreadFor } from "./messages";
24
+ import { leadTitle } from "./home";
25
+ import type { LeadKind } from "./types";
26
+
27
+ const QUOTE_CAP = 600;
28
+ const NOTES_SCANNED = 100;
29
+ // What Tech's digest adds once its workspace has leads.
30
+ export const TECH_LEAD_TOKENS = 1500;
31
+
32
+ function directionSection(w: string, kind: LeadKind): Section {
33
+ return {
34
+ title: `Pinned direction, from ${owner()}, newest first`,
35
+ // The server copied each quote from his own message: it's his word.
36
+ lines: currentDirection(w, kind).map(
37
+ (p) =>
38
+ `- [pin ${p.id}, ${p.setAt.slice(0, 10)}] "${clip(p.quote, QUOTE_CAP)}"`
39
+ ),
40
+ empty: `- None yet. Only ${owner()} sets direction; propose a pin with propose_pin.`,
41
+ more: "direction",
42
+ };
43
+ }
44
+
45
+ function memoSection(w: string, kind: LeadKind): Section {
46
+ const memo = latestMemo(w, kind);
47
+ return {
48
+ title: "Your memo",
49
+ // Written by the lead from what it read: it informs, never authorizes.
50
+ lines: memo
51
+ ? [
52
+ `(${memo.createdAt.slice(0, 16)} UTC) ${untrusted("your memo", memo.text)}`,
53
+ ]
54
+ : [],
55
+ empty: "- None yet. Write one with update_memo.",
56
+ more: "direction",
57
+ };
58
+ }
59
+
60
+ function proposalsSection(w: string, kind: LeadKind): Section {
61
+ return {
62
+ title: `Pins you proposed, waiting for ${owner()}`,
63
+ lines: openProposals(w, kind).map(
64
+ (p) => `- [proposal ${p.id}] "${clip(p.quote, QUOTE_CAP)}"`
65
+ ),
66
+ more: "direction",
67
+ };
68
+ }
69
+
70
+ function mailSection(w: string, kind: LeadKind): Section {
71
+ return {
72
+ title: "Unread mail from other leads",
73
+ lines: unreadFor(w, kind).map(
74
+ (m) =>
75
+ `- [${m.createdAt.slice(0, 16)} UTC, from ${m.fromKind}, thread "${clip(m.thread, 80)}"] ${untrusted(`the ${m.fromKind} lead`, clip(m.text))}`
76
+ ),
77
+ more: "",
78
+ };
79
+ }
80
+
81
+ function asksSection(w: string, kind: LeadKind): Section {
82
+ return {
83
+ title: `Your open asks (waiting on ${owner()})`,
84
+ lines: openAsks(w)
85
+ .filter((a) => a.lead_kind === kind)
86
+ .map(
87
+ (a) =>
88
+ `- [ask ${a.id}] ${a.kind}: ${untrusted("ask title", clip(a.title, 160))}`
89
+ ),
90
+ empty: "- None.",
91
+ more: "",
92
+ };
93
+ }
94
+
95
+ function workSection(sessionId: string): Section {
96
+ const rows = db
97
+ .prepare(
98
+ `SELECT id, name, task_status, pr_number FROM session_rows
99
+ WHERE started_by = ? AND archived_at IS NULL
100
+ AND (task_status IS NULL OR task_status = 'running')
101
+ ORDER BY created_at DESC LIMIT 30`
102
+ )
103
+ .all(sessionId) as {
104
+ id: string;
105
+ name: string;
106
+ task_status: string | null;
107
+ pr_number: number | null;
108
+ }[];
109
+ return {
110
+ title: "Work you started, still open",
111
+ lines: rows.map(
112
+ (r) =>
113
+ `- ${shortId(r.id)} ${untrusted("session name", clip(r.name, 80))}${r.task_status ? " (task)" : ""}${r.pr_number ? ` PR #${r.pr_number}` : ""}`
114
+ ),
115
+ empty: "- None.",
116
+ more: "sessions",
117
+ };
118
+ }
119
+
120
+ function notesSection(w: string, kind: LeadKind): Section {
121
+ const rows = db
122
+ .prepare(
123
+ `SELECT kind, text, created_at FROM orchestrator_notes
124
+ WHERE workspace_id = ? AND lead_kind = ? ORDER BY id DESC LIMIT ?`
125
+ )
126
+ .all(w, kind, NOTES_SCANNED) as {
127
+ kind: string;
128
+ text: string;
129
+ created_at: string;
130
+ }[];
131
+ return {
132
+ title: "Your decision log, newest first",
133
+ lines: rows.map(
134
+ (n) =>
135
+ `- ${n.created_at.slice(0, 16)} UTC${n.kind === "note" ? "" : ` ${n.kind}`}: ${untrusted(n.kind === "note" ? "your note" : `${n.kind} log line`, clip(n.text))}`
136
+ ),
137
+ more: "",
138
+ };
139
+ }
140
+
141
+ // A lead's whole digest. Tech's (`tech`) is only what its own digest lacks:
142
+ // direction, memo, proposals and mail.
143
+ export function buildLeadDigest(
144
+ w: string,
145
+ kind: LeadKind,
146
+ opts: { sessionId: string; tech?: boolean },
147
+ budgetTokens = opts.tech ? TECH_LEAD_TOKENS : DIGEST_TOKENS
148
+ ): string {
149
+ const head = opts.tech
150
+ ? `## Your direction as ${leadTitle(w, kind)}`
151
+ : "## Where things stand (read from AgentOS's database as you started)";
152
+ const sections = [
153
+ directionSection(w, kind),
154
+ memoSection(w, kind),
155
+ proposalsSection(w, kind),
156
+ mailSection(w, kind),
157
+ ...(opts.tech
158
+ ? []
159
+ : [
160
+ asksSection(w, kind),
161
+ workSection(opts.sessionId),
162
+ notesSection(w, kind),
163
+ ]),
164
+ ];
165
+ return `${head}\n\n${renderDigest(sections, budgetTokens - Math.ceil((head.length + 2) / CHARS_PER_TOKEN))}`;
166
+ }
@@ -0,0 +1,202 @@
1
+ import { db } from "@/lib/db";
2
+ import type { Session } from "@/lib/db/types";
3
+ import { getItem } from "@/lib/chat/store";
4
+ import { originOf } from "@/lib/chat/origin";
5
+ import { leadKindOf } from "./roles";
6
+ import type { DirectionPin, LeadKind, PinProposal } from "./types";
7
+
8
+ const QUOTE_CAP = 2000;
9
+
10
+ interface PinRow {
11
+ id: number;
12
+ workspace_id: string;
13
+ lead_kind: string;
14
+ session_id: string;
15
+ chat_item_id: string;
16
+ quote: string;
17
+ set_at: string;
18
+ superseded_by: number | null;
19
+ }
20
+
21
+ interface ProposalRow {
22
+ id: number;
23
+ workspace_id: string;
24
+ lead_kind: string;
25
+ session_id: string;
26
+ chat_item_id: string;
27
+ quote: string;
28
+ status: PinProposal["status"];
29
+ created_at: string;
30
+ resolved_at: string | null;
31
+ }
32
+
33
+ const pinView = (r: PinRow): DirectionPin => ({
34
+ id: r.id,
35
+ workspaceId: r.workspace_id,
36
+ leadKind: r.lead_kind,
37
+ sessionId: r.session_id,
38
+ chatItemId: r.chat_item_id,
39
+ quote: r.quote,
40
+ setAt: r.set_at,
41
+ supersededBy: r.superseded_by,
42
+ });
43
+
44
+ const proposalView = (r: ProposalRow): PinProposal => ({
45
+ id: r.id,
46
+ workspaceId: r.workspace_id,
47
+ leadKind: r.lead_kind,
48
+ sessionId: r.session_id,
49
+ chatItemId: r.chat_item_id,
50
+ quote: r.quote,
51
+ status: r.status,
52
+ createdAt: r.created_at,
53
+ resolvedAt: r.resolved_at,
54
+ });
55
+
56
+ // The words a pin may quote: a message the person typed in this chat, read
57
+ // from the database. An event, a peer, a schedule or the agent's own text
58
+ // is never direction, whatever a lead says about it.
59
+ export function authoredQuote(
60
+ sessionId: string,
61
+ chatItemId: string
62
+ ): string | null {
63
+ const item = getItem(sessionId, chatItemId);
64
+ if (!item || item.kind !== "user" || originOf(item) !== null) return null;
65
+ const text = item.text.trim().slice(0, QUOTE_CAP);
66
+ return text || null;
67
+ }
68
+
69
+ export interface PinTarget {
70
+ workspaceId: string;
71
+ leadKind: LeadKind;
72
+ sessionId: string;
73
+ chatItemId: string;
74
+ }
75
+
76
+ // The quote for a pin in a lead's own chat, or why it can't be one.
77
+ function attest(t: PinTarget): { quote: string } | { error: string } {
78
+ const s = db
79
+ .prepare(
80
+ `SELECT role, lead_kind, workspace_id FROM session_rows WHERE id = ?`
81
+ )
82
+ .get(t.sessionId) as
83
+ Pick<Session, "role" | "lead_kind" | "workspace_id"> | undefined;
84
+ if (!s || s.workspace_id !== t.workspaceId || leadKindOf(s) !== t.leadKind)
85
+ return { error: "That chat isn't this lead's" };
86
+ const quote = authoredQuote(t.sessionId, t.chatItemId);
87
+ return quote ? { quote } : { error: "Only your own message can be pinned" };
88
+ }
89
+
90
+ export function pinDirection(
91
+ t: PinTarget & { supersedes?: number }
92
+ ): DirectionPin | { error: string } {
93
+ const a = attest(t);
94
+ if ("error" in a) return a;
95
+ return db.transaction(() => {
96
+ const { lastInsertRowid } = db
97
+ .prepare(
98
+ `INSERT INTO lead_direction (workspace_id, lead_kind, session_id, chat_item_id, quote)
99
+ VALUES (?, ?, ?, ?, ?)`
100
+ )
101
+ .run(t.workspaceId, t.leadKind, t.sessionId, t.chatItemId, a.quote);
102
+ const id = Number(lastInsertRowid);
103
+ if (t.supersedes)
104
+ db.prepare(
105
+ `UPDATE lead_direction SET superseded_by = ?
106
+ WHERE id = ? AND workspace_id = ? AND lead_kind = ? AND superseded_by IS NULL`
107
+ ).run(id, t.supersedes, t.workspaceId, t.leadKind);
108
+ return getPin(id)!;
109
+ })();
110
+ }
111
+
112
+ export function getPin(id: number): DirectionPin | null {
113
+ const r = db.prepare(`SELECT * FROM lead_direction WHERE id = ?`).get(id) as
114
+ PinRow | undefined;
115
+ return r ? pinView(r) : null;
116
+ }
117
+
118
+ // The pins in force, newest first.
119
+ export function currentDirection(
120
+ workspaceId: string,
121
+ leadKind: LeadKind
122
+ ): DirectionPin[] {
123
+ return (
124
+ db
125
+ .prepare(
126
+ `SELECT * FROM lead_direction
127
+ WHERE workspace_id = ? AND lead_kind = ? AND superseded_by IS NULL
128
+ ORDER BY id DESC`
129
+ )
130
+ .all(workspaceId, leadKind) as PinRow[]
131
+ ).map(pinView);
132
+ }
133
+
134
+ export function directionHistory(
135
+ workspaceId: string,
136
+ leadKind: LeadKind
137
+ ): DirectionPin[] {
138
+ return (
139
+ db
140
+ .prepare(
141
+ `SELECT * FROM lead_direction WHERE workspace_id = ? AND lead_kind = ?
142
+ ORDER BY id DESC`
143
+ )
144
+ .all(workspaceId, leadKind) as PinRow[]
145
+ ).map(pinView);
146
+ }
147
+
148
+ // A lead suggests a pin; the quote is still the server's, and nothing is
149
+ // direction until the person confirms it.
150
+ export function proposePin(t: PinTarget): PinProposal | { error: string } {
151
+ const a = attest(t);
152
+ if ("error" in a) return a;
153
+ db.prepare(
154
+ `INSERT INTO lead_pin_proposals (workspace_id, lead_kind, session_id, chat_item_id, quote)
155
+ VALUES (?, ?, ?, ?, ?)
156
+ ON CONFLICT(session_id, chat_item_id) WHERE status = 'open' DO NOTHING`
157
+ ).run(t.workspaceId, t.leadKind, t.sessionId, t.chatItemId, a.quote);
158
+ const r = db
159
+ .prepare(
160
+ `SELECT * FROM lead_pin_proposals
161
+ WHERE session_id = ? AND chat_item_id = ? AND status = 'open'`
162
+ )
163
+ .get(t.sessionId, t.chatItemId) as ProposalRow;
164
+ return proposalView(r);
165
+ }
166
+
167
+ export function openProposals(
168
+ workspaceId: string,
169
+ leadKind: LeadKind
170
+ ): PinProposal[] {
171
+ return (
172
+ db
173
+ .prepare(
174
+ `SELECT * FROM lead_pin_proposals
175
+ WHERE workspace_id = ? AND lead_kind = ? AND status = 'open' ORDER BY id`
176
+ )
177
+ .all(workspaceId, leadKind) as ProposalRow[]
178
+ ).map(proposalView);
179
+ }
180
+
181
+ // One tap: pins the confirmed proposals and drops the rest of the open
182
+ // ones. The quote is attested again, in case the item changed.
183
+ export function resolveProposals(
184
+ workspaceId: string,
185
+ leadKind: LeadKind,
186
+ confirm: readonly number[]
187
+ ): DirectionPin[] {
188
+ const keep = new Set(confirm);
189
+ return db.transaction(() => {
190
+ const pins: DirectionPin[] = [];
191
+ for (const p of openProposals(workspaceId, leadKind)) {
192
+ const pin = keep.has(p.id) ? pinDirection(p) : null;
193
+ const ok = !!pin && !("error" in pin);
194
+ if (ok) pins.push(pin);
195
+ db.prepare(
196
+ `UPDATE lead_pin_proposals SET status = ?, resolved_at = datetime('now')
197
+ WHERE id = ?`
198
+ ).run(ok ? "pinned" : "dropped", p.id);
199
+ }
200
+ return pins;
201
+ })();
202
+ }
@@ -0,0 +1,196 @@
1
+ /**
2
+ * A workspace's leads: durable chat sessions run like its orchestrator, each
3
+ * heading one department. The orchestrator is the Tech lead in place (same
4
+ * row, same data); the others are rows with the lead role, made by "Add leads"
5
+ * or adopted from an existing chat (./adopt).
6
+ */
7
+
8
+ import fs from "fs";
9
+ import { randomBytes, randomUUID, timingSafeEqual } from "crypto";
10
+ import { db, type Session } from "../db";
11
+ import { agentosHome } from "../agentos-home";
12
+ import { refuseRealStateUnderTest } from "../test-home-guard";
13
+ import { getWorkspace } from "../workspaces";
14
+ import { resolveRoleOrDefault } from "../models/resolve";
15
+ import {
16
+ ensureOrchestrator,
17
+ getOrchestrator,
18
+ orchestratorDir,
19
+ } from "../orchestrator/home";
20
+ import { isLeadKind, leadKindOf, sqlLead, sqlStanding } from "./roles";
21
+ import { TECH, type LeadKind } from "./types";
22
+ import { markLeadsEnabled } from "./settings";
23
+ import { getCharter, leadAreas, putCharter } from "./charters";
24
+ import { charterTemplate } from "./templates";
25
+
26
+ // Tech first, then the shipped kinds, then custom ones by name.
27
+ const ORDER = [TECH, "marketing", "product"];
28
+ const rank = (kind: string) => {
29
+ const i = ORDER.indexOf(kind);
30
+ return i < 0 ? ORDER.length : i;
31
+ };
32
+ export const byLeadOrder = (a: string, b: string) =>
33
+ rank(a) - rank(b) || a.localeCompare(b);
34
+
35
+ // Where a lead starts and may write freely: the Tech lead keeps the
36
+ // orchestrator's folder.
37
+ export const leadDir = (workspaceId: string, kind: LeadKind) =>
38
+ kind === TECH
39
+ ? orchestratorDir(workspaceId)
40
+ : refuseRealStateUnderTest(agentosHome("leads", workspaceId, kind));
41
+
42
+ // A standing session's own folder, whatever its role.
43
+ export const standingDir = (
44
+ s: Pick<Session, "role" | "lead_kind" | "workspace_id">
45
+ ) => leadDir(s.workspace_id ?? "", leadKindOf(s) ?? "chief");
46
+
47
+ export function getLead(workspaceId: string, kind: LeadKind): Session | null {
48
+ if (kind === TECH) return getOrchestrator(workspaceId);
49
+ return (
50
+ (db
51
+ .prepare(
52
+ `SELECT * FROM session_rows WHERE ${sqlLead()} AND workspace_id = ? AND lead_kind = ?`
53
+ )
54
+ .get(workspaceId, kind) as Session | undefined) ?? null
55
+ );
56
+ }
57
+
58
+ // The workspace's heads: its orchestrator (Tech) and its other leads.
59
+ export function listLeads(workspaceId: string): Session[] {
60
+ const rows = db
61
+ .prepare(
62
+ `SELECT * FROM session_rows
63
+ WHERE workspace_id = ? AND ${sqlLead()}`
64
+ )
65
+ .all(workspaceId) as Session[];
66
+ return rows.sort((a, b) =>
67
+ byLeadOrder(leadKindOf(a) ?? "", leadKindOf(b) ?? "")
68
+ );
69
+ }
70
+
71
+ export function leadTitle(workspaceId: string, kind: LeadKind): string {
72
+ return getCharter(workspaceId, kind)?.title || charterTemplate(kind).title;
73
+ }
74
+
75
+ // A lead starts from the generic charter for its kind; one already written
76
+ // (by the person, in their own database) is never overwritten.
77
+ export function seedCharter(workspaceId: string, kind: LeadKind): void {
78
+ if (getCharter(workspaceId, kind)) return;
79
+ const t = charterTemplate(kind);
80
+ putCharter({ workspaceId, leadKind: kind, ...t });
81
+ }
82
+
83
+ // Marks the orchestrator as the Tech lead, once.
84
+ function claimTech(workspaceId: string): Session {
85
+ const tech = ensureOrchestrator(workspaceId);
86
+ db.prepare(
87
+ `UPDATE sessions SET lead_kind = 'tech', lead_since = COALESCE(lead_since, datetime('now'))
88
+ WHERE id = ?`
89
+ ).run(tech.id);
90
+ seedCharter(workspaceId, TECH);
91
+ return getOrchestrator(workspaceId)!;
92
+ }
93
+
94
+ function createLead(workspaceId: string, kind: LeadKind): Session {
95
+ const existing = getLead(workspaceId, kind);
96
+ if (existing) return existing;
97
+ const workspace = getWorkspace(workspaceId)!;
98
+ seedCharter(workspaceId, kind);
99
+ const dir = leadDir(workspaceId, kind);
100
+ fs.mkdirSync(dir, { recursive: true });
101
+ const id = randomUUID();
102
+ // Two at once still make one: the unique (workspace, kind) index decides.
103
+ db.prepare(
104
+ `INSERT OR IGNORE INTO sessions (id, name, tmux_name, working_directory, model,
105
+ group_path, agent_type, auto_approve, project_id, host_id, view, chat_access,
106
+ role, lead_kind, lead_since, workspace_id, pinned)
107
+ VALUES (?, ?, ?, ?, ?, 'sessions', 'claude', 0, NULL, 'local', 'chat', 'ask',
108
+ 'lead', ?, datetime('now'), ?, 1)`
109
+ ).run(
110
+ id,
111
+ `${workspace.name} ${leadTitle(workspaceId, kind)}`,
112
+ `claude-${id}`,
113
+ dir,
114
+ resolveRoleOrDefault("orchestrator", { workspaceId }).model,
115
+ kind,
116
+ workspaceId
117
+ );
118
+ return getLead(workspaceId, kind)!;
119
+ }
120
+
121
+ export function checkKinds(kinds: unknown): LeadKind[] {
122
+ if (!Array.isArray(kinds) || !kinds.every(isLeadKind))
123
+ throw new Error(
124
+ "Each lead is a short lowercase name: letters, digits and dashes"
125
+ );
126
+ return [...new Set(kinds)].filter((k) => k !== TECH);
127
+ }
128
+
129
+ // "Add leads": the workspace gets its Tech lead (its orchestrator, made if
130
+ // it has none) and the leads picked. Safe to run again; nothing made twice.
131
+ export function addLeads(workspaceId: string, kinds: LeadKind[]): Session[] {
132
+ if (!getWorkspace(workspaceId)) throw new Error("Unknown workspace");
133
+ const picked = checkKinds(kinds);
134
+ markLeadsEnabled(workspaceId);
135
+ claimTech(workspaceId);
136
+ for (const kind of picked) createLead(workspaceId, kind);
137
+ return listLeads(workspaceId);
138
+ }
139
+
140
+ // The secret a standing session's worker sends with each tool call, so no
141
+ // other local process can act as it. The orchestrator's is the one it
142
+ // always had.
143
+ export function standingToken(sessionId: string): string {
144
+ db.prepare(
145
+ `UPDATE sessions SET orch_token = COALESCE(orch_token, ?) WHERE id = ?`
146
+ ).run(randomBytes(32).toString("hex"), sessionId);
147
+ const row = db
148
+ .prepare(`SELECT orch_token FROM sessions WHERE id = ?`)
149
+ .get(sessionId) as { orch_token: string | null } | undefined;
150
+ if (!row?.orch_token) throw new Error("No such session");
151
+ return row.orch_token;
152
+ }
153
+
154
+ // Which of the workspace's standing sessions holds this token, if any.
155
+ export function standingByToken(
156
+ workspaceId: string,
157
+ token: string | null
158
+ ): Session | null {
159
+ if (!token) return null;
160
+ const b = Buffer.from(token);
161
+ const rows = db
162
+ .prepare(
163
+ `SELECT * FROM session_rows
164
+ WHERE workspace_id = ? AND ${sqlStanding()} AND orch_token IS NOT NULL`
165
+ )
166
+ .all(workspaceId) as Session[];
167
+ let found: Session | null = null;
168
+ for (const s of rows) {
169
+ const a = Buffer.from(s.orch_token!);
170
+ if (a.length === b.length && timingSafeEqual(a, b)) found = s;
171
+ }
172
+ return found;
173
+ }
174
+
175
+ export interface LeadView {
176
+ kind: LeadKind;
177
+ title: string;
178
+ sessionId: string;
179
+ name: string;
180
+ since: string | null;
181
+ areas: { kind: string; value: string }[];
182
+ }
183
+
184
+ export function leadViews(workspaceId: string): LeadView[] {
185
+ return listLeads(workspaceId).map((s) => {
186
+ const kind = leadKindOf(s) ?? TECH;
187
+ return {
188
+ kind,
189
+ title: leadTitle(workspaceId, kind),
190
+ sessionId: s.id,
191
+ name: s.name,
192
+ since: s.lead_since ?? null,
193
+ areas: leadAreas(workspaceId, kind),
194
+ };
195
+ });
196
+ }
@@ -0,0 +1,8 @@
1
+ export * from "./types";
2
+ export * from "./roles";
3
+ export * from "./settings";
4
+ export * from "./charters";
5
+ export * from "./direction";
6
+ export * from "./memos";
7
+ export * from "./messages";
8
+ export * from "./usage";
@@ -0,0 +1,70 @@
1
+ import { db } from "@/lib/db";
2
+ import type { LeadKind, LeadMemo } from "./types";
3
+
4
+ // About 1.5k tokens: what a lead carries across a fresh start, with its
5
+ // pinned direction.
6
+ export const MEMO_CAP = 6000;
7
+
8
+ interface Row {
9
+ id: number;
10
+ workspace_id: string;
11
+ lead_kind: string;
12
+ text: string;
13
+ created_at: string;
14
+ }
15
+
16
+ const view = (r: Row): LeadMemo => ({
17
+ id: r.id,
18
+ workspaceId: r.workspace_id,
19
+ leadKind: r.lead_kind,
20
+ text: r.text,
21
+ createdAt: r.created_at,
22
+ });
23
+
24
+ // Every version is kept; the newest is the memo.
25
+ export function writeMemo(
26
+ workspaceId: string,
27
+ leadKind: LeadKind,
28
+ text: string
29
+ ): LeadMemo {
30
+ const body = text.trim().slice(0, MEMO_CAP);
31
+ if (!body) throw new Error("The memo is empty");
32
+ const { lastInsertRowid } = db
33
+ .prepare(
34
+ `INSERT INTO lead_memos (workspace_id, lead_kind, text) VALUES (?, ?, ?)`
35
+ )
36
+ .run(workspaceId, leadKind, body);
37
+ return view(
38
+ db
39
+ .prepare(`SELECT * FROM lead_memos WHERE id = ?`)
40
+ .get(Number(lastInsertRowid)) as Row
41
+ );
42
+ }
43
+
44
+ export function latestMemo(
45
+ workspaceId: string,
46
+ leadKind: LeadKind
47
+ ): LeadMemo | null {
48
+ const r = db
49
+ .prepare(
50
+ `SELECT * FROM lead_memos WHERE workspace_id = ? AND lead_kind = ?
51
+ ORDER BY id DESC LIMIT 1`
52
+ )
53
+ .get(workspaceId, leadKind) as Row | undefined;
54
+ return r ? view(r) : null;
55
+ }
56
+
57
+ export function memoHistory(
58
+ workspaceId: string,
59
+ leadKind: LeadKind,
60
+ limit = 20
61
+ ): LeadMemo[] {
62
+ return (
63
+ db
64
+ .prepare(
65
+ `SELECT * FROM lead_memos WHERE workspace_id = ? AND lead_kind = ?
66
+ ORDER BY id DESC LIMIT ?`
67
+ )
68
+ .all(workspaceId, leadKind, limit) as Row[]
69
+ ).map(view);
70
+ }