@saadnvd1/agent-os 0.3.715 → 0.3.728

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 (242) hide show
  1. package/README.md +2 -0
  2. package/app/api/agent-roles/route.test.ts +67 -0
  3. package/app/api/agent-roles/route.ts +61 -0
  4. package/app/api/analyst/[id]/recommendations/[recId]/again/route.ts +15 -0
  5. package/app/api/analyst/[id]/recommendations/[recId]/dismiss/route.ts +13 -0
  6. package/app/api/analyst/[id]/recommendations/[recId]/send/route.ts +13 -0
  7. package/app/api/analyst/[id]/review/route.ts +22 -0
  8. package/app/api/analyst/[id]/route.ts +44 -0
  9. package/app/api/analyst/route.ts +10 -0
  10. package/app/api/bus/spawn/route.ts +22 -1
  11. package/app/api/loops/route.ts +17 -0
  12. package/app/api/models/availability/route.ts +9 -0
  13. package/app/api/models/choices/route.ts +9 -0
  14. package/app/api/sessions/[id]/analyst/route.ts +11 -0
  15. package/app/api/sessions/[id]/crew/route.ts +38 -0
  16. package/app/api/sessions/[id]/export/estimate/route.ts +23 -0
  17. package/app/api/sessions/[id]/export/route.test.ts +125 -0
  18. package/app/api/sessions/[id]/export/route.ts +34 -0
  19. package/app/api/sessions/[id]/model/route.ts +49 -0
  20. package/app/api/sessions/route.ts +16 -0
  21. package/app/api/tasks/[id]/loop/cycles/[n]/log/route.ts +29 -0
  22. package/app/api/tasks/[id]/loop/draft-criteria/route.ts +17 -0
  23. package/app/api/tasks/[id]/loop/next/route.ts +25 -0
  24. package/app/api/tasks/[id]/loop/route.ts +72 -0
  25. package/app/api/tasks/[id]/loop/run/route.ts +12 -0
  26. package/app/api/tasks/[id]/loop/stop/route.ts +12 -0
  27. package/app/api/tasks/[id]/mode/route.ts +29 -0
  28. package/app/api/tasks/[id]/report/draft/route.ts +54 -0
  29. package/app/api/tasks/[id]/report/evidence/[evId]/route.ts +18 -0
  30. package/app/api/tasks/[id]/report/route.ts +39 -0
  31. package/app/api/workspaces/[id]/mode/route.ts +23 -0
  32. package/app/api/workspaces/[id]/reports/route.ts +17 -0
  33. package/app/page.tsx +13 -0
  34. package/components/Analyst/PairChip.tsx +131 -0
  35. package/components/Analyst/PairSwitch.tsx +76 -0
  36. package/components/Analyst/ReviewCard.tsx +272 -0
  37. package/components/Analyst/SignalChips.tsx +39 -0
  38. package/components/Analyst/StartAnalystSheet.tsx +109 -0
  39. package/components/Analyst/cardState.test.ts +109 -0
  40. package/components/Analyst/cardState.ts +95 -0
  41. package/components/Analyst/pair.ts +81 -0
  42. package/components/Chat/Code.tsx +3 -0
  43. package/components/Chat/Messages.tsx +110 -11
  44. package/components/Chat/itemContext.ts +14 -0
  45. package/components/Draft/DraftChips.tsx +16 -1
  46. package/components/Draft/DraftPanel.tsx +58 -2
  47. package/components/Draft/ModelUnavailableDialog.tsx +78 -0
  48. package/components/Draft/RoleChips.tsx +110 -0
  49. package/components/Export/ExportActions.tsx +136 -0
  50. package/components/Export/ExportSheet.tsx +217 -0
  51. package/components/Export/RangePicker.tsx +144 -0
  52. package/components/Export/Seg.tsx +38 -0
  53. package/components/Export/TurnSelectBar.tsx +45 -0
  54. package/components/Models/AgentRoleCard.tsx +114 -0
  55. package/components/Models/AgentRolesSection.tsx +132 -0
  56. package/components/Models/ModelBadges.tsx +48 -0
  57. package/components/Models/ModelsSection.tsx +4 -2
  58. package/components/Models/RoleModelSelect.tsx +87 -0
  59. package/components/Pane/DesktopTabBar.tsx +8 -0
  60. package/components/Pane/MobileTabBar.tsx +29 -0
  61. package/components/Pane/MobileViewPicker.tsx +4 -0
  62. package/components/Pane/PaneMenu.test.ts +21 -0
  63. package/components/Pane/PaneMenu.tsx +26 -0
  64. package/components/Pane/PaneViewToggle.tsx +12 -0
  65. package/components/Pane/index.tsx +19 -0
  66. package/components/Report/DraftCard.tsx +89 -0
  67. package/components/Report/EstimateBar.tsx +51 -0
  68. package/components/Report/EvidenceSheet.tsx +98 -0
  69. package/components/Report/ModeChooser.tsx +43 -0
  70. package/components/Report/ReportView.tsx +289 -0
  71. package/components/SessionList/SessionRow.tsx +18 -7
  72. package/components/SessionList/SessionRowMenu.tsx +12 -0
  73. package/components/Sessions/RolePill.tsx +203 -0
  74. package/components/Settings/SettingsDialog.tsx +9 -0
  75. package/components/Share/ShareDialog.tsx +13 -0
  76. package/components/Tasks/Loop/LoopButton.tsx +74 -0
  77. package/components/Tasks/Loop/LoopSetup.tsx +146 -0
  78. package/components/Tasks/Loop/LoopView.tsx +350 -0
  79. package/components/Tasks/Loop/labels.ts +17 -0
  80. package/components/Tasks/TaskRow.tsx +2 -0
  81. package/components/Workspaces/WorkspaceModeForm.tsx +22 -0
  82. package/components/Workspaces/WorkspaceSettings.tsx +2 -0
  83. package/components/ui/segmented.tsx +55 -0
  84. package/components/ui/status-pill.tsx +3 -0
  85. package/contexts/PaneContext.tsx +21 -1
  86. package/data/analyst/index.ts +3 -0
  87. package/data/analyst/keys.ts +6 -0
  88. package/data/analyst/mutations.ts +116 -0
  89. package/data/analyst/queries.ts +35 -0
  90. package/data/exports/index.ts +2 -0
  91. package/data/exports/keys.ts +3 -0
  92. package/data/exports/queries.ts +51 -0
  93. package/data/loops/index.ts +3 -0
  94. package/data/loops/keys.ts +4 -0
  95. package/data/loops/mutations.ts +68 -0
  96. package/data/loops/queries.ts +40 -0
  97. package/data/models/index.ts +98 -0
  98. package/data/push/topics.ts +11 -0
  99. package/data/reports/index.ts +115 -0
  100. package/data/sessions/queries.ts +26 -0
  101. package/lib/agents/brief.ts +1 -0
  102. package/lib/analyst/__fixtures__/items.ts +57 -0
  103. package/lib/analyst/__fixtures__/mocks.ts +49 -0
  104. package/lib/analyst/brief.ts +17 -0
  105. package/lib/analyst/buildDigest.ts +204 -0
  106. package/lib/analyst/createLink.ts +98 -0
  107. package/lib/analyst/endLink.ts +31 -0
  108. package/lib/analyst/forward.ts +109 -0
  109. package/lib/analyst/index.ts +16 -0
  110. package/lib/analyst/linkView.ts +38 -0
  111. package/lib/analyst/onTurnEnd.ts +119 -0
  112. package/lib/analyst/parseReview.ts +41 -0
  113. package/lib/analyst/pushDigest.ts +143 -0
  114. package/lib/analyst/resume.ts +35 -0
  115. package/lib/analyst/route.ts +50 -0
  116. package/lib/analyst/signals.ts +121 -0
  117. package/lib/analyst/store.ts +303 -0
  118. package/lib/analyst/types.ts +140 -0
  119. package/lib/bus/condense.ts +5 -1
  120. package/lib/bus/index.ts +5 -0
  121. package/lib/bus/read-scope.ts +12 -0
  122. package/lib/bus/role-alias.ts +70 -0
  123. package/lib/bus/targets.ts +3 -0
  124. package/lib/chat/drivers/opencode-mapper.ts +55 -1
  125. package/lib/chat/drivers/pi-approvals.ts +7 -4
  126. package/lib/chat/drivers/pi-mapper.ts +38 -0
  127. package/lib/chat/drivers/pi-text-tools.ts +104 -0
  128. package/lib/chat/drivers/pi.ts +8 -3
  129. package/lib/chat/drivers/turn-items.ts +36 -11
  130. package/lib/chat/drivers/turn-outcome.ts +108 -0
  131. package/lib/chat/events.ts +8 -0
  132. package/lib/chat/registry.ts +13 -0
  133. package/lib/chat/store.ts +19 -0
  134. package/lib/chat/turns.ts +1 -0
  135. package/lib/chat/worker/extras.ts +4 -2
  136. package/lib/claims/collectLocal.ts +3 -1
  137. package/lib/db/changes.ts +13 -0
  138. package/lib/db/migrations/20261021T1400_session_agent_roles.ts +20 -0
  139. package/lib/db/migrations/20261021T1430_analyst_links.ts +52 -0
  140. package/lib/db/migrations/20261021T1600_task_loops.ts +81 -0
  141. package/lib/db/migrations/20261021T1800_health_reports.ts +38 -0
  142. package/lib/db/session-parts/columns.ts +2 -0
  143. package/lib/db/types.ts +3 -0
  144. package/lib/done/archive.ts +2 -0
  145. package/lib/done/index.ts +2 -0
  146. package/lib/drafts.ts +6 -0
  147. package/lib/export/build.ts +470 -0
  148. package/lib/export/compact.ts +156 -0
  149. package/lib/export/estimate.ts +83 -0
  150. package/lib/export/fixtures.ts +45 -0
  151. package/lib/export/html.tsx +195 -0
  152. package/lib/export/json.ts +39 -0
  153. package/lib/export/load.ts +64 -0
  154. package/lib/export/markdown.ts +127 -0
  155. package/lib/export/request.ts +36 -0
  156. package/lib/export/service.ts +142 -0
  157. package/lib/export/summary.ts +86 -0
  158. package/lib/export/turns.ts +63 -0
  159. package/lib/export/view.ts +173 -0
  160. package/lib/loops/analyse.ts +46 -0
  161. package/lib/loops/analyst-prompt.ts +203 -0
  162. package/lib/loops/checkCriteria.ts +114 -0
  163. package/lib/loops/compactEvidence.ts +140 -0
  164. package/lib/loops/controls.ts +317 -0
  165. package/lib/loops/decideStop.ts +113 -0
  166. package/lib/loops/deps.ts +128 -0
  167. package/lib/loops/diffMap.ts +39 -0
  168. package/lib/loops/draftCriteria.ts +71 -0
  169. package/lib/loops/gatherEvidence.ts +265 -0
  170. package/lib/loops/hooks.ts +40 -0
  171. package/lib/loops/index.ts +7 -0
  172. package/lib/loops/route.ts +83 -0
  173. package/lib/loops/runCycle.ts +323 -0
  174. package/lib/loops/screenInstruction.ts +59 -0
  175. package/lib/loops/sendInstruction.ts +83 -0
  176. package/lib/loops/snapshot.ts +38 -0
  177. package/lib/loops/stopLoop.ts +86 -0
  178. package/lib/loops/store.ts +481 -0
  179. package/lib/loops/summary.ts +27 -0
  180. package/lib/loops/tickLoops.ts +141 -0
  181. package/lib/loops/types.ts +223 -0
  182. package/lib/loops/world.test-helpers.ts +223 -0
  183. package/lib/models/agent-binding.ts +102 -0
  184. package/lib/models/agent-roles-view.ts +157 -0
  185. package/lib/models/agent-roles.ts +87 -0
  186. package/lib/models/availability-refs.ts +17 -0
  187. package/lib/models/availability.ts +91 -0
  188. package/lib/models/catalog.ts +156 -0
  189. package/lib/models/config.ts +27 -0
  190. package/lib/models/locality.ts +33 -0
  191. package/lib/models/plan-step.ts +4 -1
  192. package/lib/models/providers.ts +11 -0
  193. package/lib/models/resolve.ts +50 -12
  194. package/lib/models/roles.ts +12 -0
  195. package/lib/needs-you.ts +1 -1
  196. package/lib/orchestrator/digest.ts +2 -1
  197. package/lib/orchestrator/facts.ts +7 -2
  198. package/lib/orchestrator/loop-guard.ts +5 -2
  199. package/lib/orchestrator/pr-wake.ts +4 -0
  200. package/lib/orchestrator/read.ts +5 -1
  201. package/lib/orchestrator/untrusted.ts +5 -0
  202. package/lib/orchestrator/watcher.ts +8 -1
  203. package/lib/panes.ts +52 -0
  204. package/lib/plans/acceptance.ts +23 -0
  205. package/lib/projects.ts +1 -0
  206. package/lib/reports/drafts.ts +87 -0
  207. package/lib/reports/end-hook.ts +17 -0
  208. package/lib/reports/estimate.ts +107 -0
  209. package/lib/reports/evidence/build.ts +193 -0
  210. package/lib/reports/evidence/gather.ts +229 -0
  211. package/lib/reports/fixtures.ts +17 -0
  212. package/lib/reports/generate.ts +159 -0
  213. package/lib/reports/hooks.ts +30 -0
  214. package/lib/reports/mode.ts +65 -0
  215. package/lib/reports/model.ts +115 -0
  216. package/lib/reports/read.ts +26 -0
  217. package/lib/reports/store.ts +180 -0
  218. package/lib/reports/template.ts +117 -0
  219. package/lib/reports/types.ts +135 -0
  220. package/lib/reports/validate.ts +142 -0
  221. package/lib/session-artifacts/agentos-files.ts +94 -0
  222. package/lib/session-artifacts/collect.ts +17 -0
  223. package/lib/sessions/crew.ts +72 -0
  224. package/lib/sessions/launch.ts +49 -9
  225. package/lib/settings-url.ts +1 -0
  226. package/lib/share/markdown.ts +2 -2
  227. package/lib/share/redact.ts +16 -10
  228. package/lib/share/render.tsx +1 -1
  229. package/lib/share/snapshot.ts +2 -2
  230. package/lib/sidebar/row-menu.ts +15 -1
  231. package/lib/tasks/contract/routes.ts +5 -0
  232. package/lib/tasks/contract/types.ts +3 -0
  233. package/lib/tasks/finish.ts +2 -0
  234. package/lib/tasks/index.ts +6 -0
  235. package/lib/tasks/move-carry.ts +1 -0
  236. package/lib/worktree-deps/caches.ts +3 -2
  237. package/lib/worktree-deps/clone.ts +3 -1
  238. package/lib/worktree-deps/paths.ts +26 -0
  239. package/package.json +2 -2
  240. package/server.ts +9 -1
  241. package/stores/analystUi.ts +19 -0
  242. package/stores/exportUi.ts +87 -0
@@ -0,0 +1,204 @@
1
+ import type { ChatItem } from "../chat/events";
2
+ import { lineDiff } from "../chat/diff";
3
+ import { toolLine } from "../bus/condense";
4
+ import { redact, untrusted } from "../orchestrator/untrusted";
5
+ import { commandOf, isCheckCommand, signalLine, turnSignals } from "./signals";
6
+ import type { DigestSignals } from "./types";
7
+
8
+ // About 4k tokens for the whole digest.
9
+ export const DIGEST_CAP = 16_000;
10
+ const PROMPT_CAP = 2_000;
11
+ const REPLY_CAP = 4_000;
12
+ const OUTPUT_LINES = 40;
13
+ const OUTPUT_CAP = 2_000;
14
+ const DIFFS_CAP = 6_000;
15
+ const DIFF_CONTEXT = 1;
16
+
17
+ type Tool = Extract<ChatItem, { kind: "tool" }>;
18
+
19
+ const clip = (text: string, cap: number) =>
20
+ text.length > cap
21
+ ? `${text.slice(0, cap)}\n[… ${text.length - cap} more chars]`
22
+ : text;
23
+
24
+ const tail = (text: string) => {
25
+ const lines = text.trimEnd().split("\n");
26
+ const kept = lines.slice(-OUTPUT_LINES).join("\n");
27
+ const cut = kept.length > OUTPUT_CAP ? kept.slice(-OUTPUT_CAP) : kept;
28
+ return lines.length > OUTPUT_LINES || cut.length < kept.length
29
+ ? `[… earlier output left out]\n${cut}`
30
+ : cut;
31
+ };
32
+
33
+ // The changed lines and one line around each, as a unified diff body.
34
+ export function hunks(before: string, after: string): string {
35
+ const lines = lineDiff(before, after);
36
+ const keep = new Set<number>();
37
+ lines.forEach((l, i) => {
38
+ if (l.op === " ") return;
39
+ for (let k = i - DIFF_CONTEXT; k <= i + DIFF_CONTEXT; k++) keep.add(k);
40
+ });
41
+ const out: string[] = [];
42
+ let last = -2;
43
+ lines.forEach((l, i) => {
44
+ if (!keep.has(i)) return;
45
+ if (i > last + 1 && out.length) out.push("…");
46
+ out.push(`${l.op}${l.text}`);
47
+ last = i;
48
+ });
49
+ return out.join("\n");
50
+ }
51
+
52
+ // Output worth the analyst's context: a check's, or a call that failed.
53
+ const showsOutput = (t: Tool) => {
54
+ const command = commandOf(t);
55
+ return (
56
+ !!t.output &&
57
+ (t.status === "error" || (command !== null && isCheckCommand(command)))
58
+ );
59
+ };
60
+
61
+ interface Section {
62
+ prompt: string;
63
+ reply: string[];
64
+ tools: string[];
65
+ diffs: { path: string; body: string }[];
66
+ }
67
+
68
+ function sections(items: ChatItem[]): Section[] {
69
+ const out: Section[] = [];
70
+ let cur: Section | null = null;
71
+ const start = (prompt: string) => {
72
+ cur = { prompt, reply: [], tools: [], diffs: [] };
73
+ out.push(cur);
74
+ };
75
+ for (const item of items) {
76
+ if (item.kind === "user") {
77
+ // A message sent mid-turn is read inside the turn already running.
78
+ if (item.midTurn && cur) {
79
+ (cur as Section).reply.push(`[message during the turn] ${item.text}`);
80
+ continue;
81
+ }
82
+ start(item.text);
83
+ continue;
84
+ }
85
+ if (!cur) start("(earlier prompt, not in this batch)");
86
+ const c = cur as unknown as Section;
87
+ if (item.kind === "assistant" && item.text.trim()) c.reply.push(item.text);
88
+ else if (item.kind === "error") c.reply.push(`[error] ${item.message}`);
89
+ else if (item.kind === "plan") c.reply.push(`[plan] ${item.plan}`);
90
+ else if (item.kind === "approval") c.tools.push(toolLine(item));
91
+ else if (item.kind === "tool") {
92
+ c.tools.push(toolLine(item));
93
+ if (showsOutput(item))
94
+ c.tools.push(
95
+ tail(item.output!)
96
+ .split("\n")
97
+ .map((l) => ` ${l}`)
98
+ .join("\n")
99
+ );
100
+ if (item.diff && item.status !== "error")
101
+ c.diffs.push({
102
+ path: item.diff.path,
103
+ body: hunks(item.diff.before, item.diff.after),
104
+ });
105
+ } else if (item.kind === "turn_end" && item.interrupted)
106
+ c.reply.push("[turn stopped by the user]");
107
+ else if (item.kind === "turn_end" && item.ended)
108
+ c.reply.push(`[turn ended early: ${item.ended.notice}]`);
109
+ }
110
+ return out;
111
+ }
112
+
113
+ function renderDiffs(diffs: Section["diffs"]): string {
114
+ const parts: string[] = [];
115
+ let used = 0;
116
+ const left: string[] = [];
117
+ for (const d of diffs) {
118
+ const block = `--- ${d.path}\n${d.body}`;
119
+ if (used + block.length > DIFFS_CAP) {
120
+ left.push(d.path);
121
+ continue;
122
+ }
123
+ parts.push(block);
124
+ used += block.length;
125
+ }
126
+ if (left.length)
127
+ parts.push(`+${left.length} more files: ${[...new Set(left)].join(", ")}`);
128
+ return parts.join("\n");
129
+ }
130
+
131
+ function renderSection(s: Section, n: number, of: number): string {
132
+ const label = of > 1 ? `Turn ${n} of ${of}` : "Turn";
133
+ return [
134
+ `## ${label}: prompt`,
135
+ clip(s.prompt, PROMPT_CAP),
136
+ `## ${label}: reply`,
137
+ clip(s.reply.join("\n\n") || "(no text)", REPLY_CAP),
138
+ s.tools.length ? `## Tools\n${s.tools.join("\n")}` : "## Tools\n(none)",
139
+ s.diffs.length ? `## Changes\n${renderDiffs(s.diffs)}` : "",
140
+ ]
141
+ .filter(Boolean)
142
+ .join("\n");
143
+ }
144
+
145
+ export interface Digest {
146
+ text: string;
147
+ signals: DigestSignals;
148
+ // What the chat shows in its place.
149
+ label: string;
150
+ }
151
+
152
+ /**
153
+ * The coding agent's turns as the analyst reads them: signals counted here
154
+ * first, then each turn's prompt, reply, tools, failing or check output and
155
+ * diffs, fenced as untrusted. Oldest turns are dropped first to fit the cap.
156
+ */
157
+ export function buildDigest(
158
+ items: ChatItem[],
159
+ opts: { coderName: string; through: string; brief?: string }
160
+ ): Digest {
161
+ const signals = turnSignals(items);
162
+ const all = sections(items);
163
+ const head = [
164
+ opts.brief ?? "",
165
+ `The coding agent "${opts.coderName}" finished ${
166
+ signals.turns === 1 ? "a turn" : `${signals.turns} turns`
167
+ }. Review it and end with your agentos-review block.`,
168
+ `Signals (counted by AgentOS): ${redact(signalLine(signals))}`,
169
+ ]
170
+ .filter(Boolean)
171
+ .join("\n");
172
+ let kept = all;
173
+ let body = "";
174
+ let dropped = 0;
175
+ for (;;) {
176
+ body = kept
177
+ .map((s, i) => renderSection(s, i + 1, kept.length))
178
+ .join("\n\n");
179
+ if (dropped)
180
+ body = `[${dropped} earlier turn(s) left out to fit]\n\n${body}`;
181
+ const text = `${head}\n\n${untrusted(`coding agent ${opts.coderName}`, body)}`;
182
+ if (text.length <= DIGEST_CAP || kept.length <= 1)
183
+ return {
184
+ text:
185
+ text.length > DIGEST_CAP
186
+ ? clipDigest(head, opts.coderName, body)
187
+ : text,
188
+ signals,
189
+ label: `${opts.coderName} · ${
190
+ signals.turns === 1 ? "1 turn" : `${signals.turns} turns`
191
+ } to review`,
192
+ };
193
+ kept = kept.slice(1);
194
+ dropped++;
195
+ }
196
+ }
197
+
198
+ // A single turn still over the cap: its tail end is what matters most.
199
+ function clipDigest(head: string, coderName: string, body: string): string {
200
+ const room = DIGEST_CAP - head.length - 200;
201
+ const cut =
202
+ body.length > room ? `[… cut to fit]\n${body.slice(-room)}` : body;
203
+ return `${head}\n\n${untrusted(`coding agent ${coderName}`, cut)}`;
204
+ }
@@ -0,0 +1,98 @@
1
+ import { db, type Session } from "../db";
2
+ import { lastItems } from "../chat/store";
3
+ import { launchSession } from "../sessions/launch";
4
+ import { joinCrew } from "../sessions/crew";
5
+ import { inheritGroup } from "../session-groups";
6
+ import { isValidAgentType } from "../providers";
7
+ import { archiveSession } from "../done/archive";
8
+ import { ANALYST_BRIEF } from "./brief";
9
+ import { pushDigest } from "./pushDigest";
10
+ import { getLink, insertLink, liveLinkFor, linkOfAnalyst } from "./store";
11
+ import { AnalystError, type AnalystLink, type CreateLinkInput } from "./types";
12
+
13
+ const sessionRow = (id: string) =>
14
+ db.prepare(`SELECT * FROM session_rows WHERE id = ?`).get(id) as
15
+ Session | undefined;
16
+
17
+ // Why a session can't have an analyst, or null when it can.
18
+ export function coderRefusal(s: Session | undefined): string | null {
19
+ if (!s) return "Session not found";
20
+ if (s.archived_at) return `${s.name} is archived`;
21
+ if (s.role === "orchestrator") return "An orchestrator can't have an analyst";
22
+ if (s.view !== "chat")
23
+ return "Only a chat session can have an analyst: a terminal has no turns to review";
24
+ if ((s.host_id || "local") !== "local")
25
+ return "Only a session on this machine can have an analyst for now";
26
+ if (linkOfAnalyst(s.id)) return `${s.name} is an analyst itself`;
27
+ if (liveLinkFor(s.id)) return `${s.name} already has an analyst`;
28
+ return null;
29
+ }
30
+
31
+ const lastTurnEnd = (sessionId: string) =>
32
+ lastItems(sessionId, 1, "turn_end")[0]?.id ?? null;
33
+
34
+ async function newCoder(
35
+ input: NonNullable<CreateLinkInput["newCoder"]>
36
+ ): Promise<Session> {
37
+ if (!isValidAgentType(input.agentType))
38
+ throw new AnalystError(`Unknown agent: ${input.agentType}`);
39
+ const { session } = await launchSession({
40
+ projectId: input.projectId,
41
+ agentType: input.agentType,
42
+ model: input.model ?? null,
43
+ prompt: input.prompt ?? null,
44
+ useWorktree: input.useWorktree,
45
+ view: "chat",
46
+ });
47
+ return sessionRow(session.id) ?? session;
48
+ }
49
+
50
+ /**
51
+ * Pairs an analyst with a coding chat: the analyst is a new chat in the
52
+ * coder's folder (no worktree of its own, so it reads exactly what the
53
+ * coder changed), in its crew and group. It reviews from the next turn on,
54
+ * or the last one now with `reviewLast`.
55
+ */
56
+ export async function createLink(input: CreateLinkInput): Promise<AnalystLink> {
57
+ let coder: Session | undefined;
58
+ if (input.coderSessionId) {
59
+ coder = sessionRow(input.coderSessionId);
60
+ const refusal = coderRefusal(coder);
61
+ if (refusal) throw new AnalystError(refusal, coder ? 409 : 404);
62
+ } else coder = await newCoder(input.newCoder!);
63
+ if (!coder) throw new AnalystError("Session not found", 404);
64
+
65
+ const crewId = coder.crew_id || coder.id;
66
+ if (!coder.agent_role) joinCrew(coder.id, "developer", { crewId });
67
+ const { session: analyst } = await launchSession({
68
+ projectId: coder.project_id,
69
+ workingDirectory: coder.working_directory,
70
+ workspaceId: coder.workspace_id,
71
+ agentType: "claude",
72
+ model: input.analystModel ?? null,
73
+ agentRole: "analyst",
74
+ crewId,
75
+ parentSessionId: coder.id,
76
+ systemPrompt: ANALYST_BRIEF,
77
+ access: "ask",
78
+ useWorktree: false,
79
+ view: "chat",
80
+ name: `Analyst · ${coder.name}`,
81
+ });
82
+ inheritGroup(coder.id, analyst.id);
83
+
84
+ const review = !!input.reviewLast;
85
+ let link: AnalystLink;
86
+ try {
87
+ link = insertLink(
88
+ coder.id,
89
+ analyst.id,
90
+ review ? null : lastTurnEnd(coder.id)
91
+ );
92
+ } catch {
93
+ archiveSession(analyst.id);
94
+ throw new AnalystError(`${coder.name} already has an analyst`, 409);
95
+ }
96
+ if (review && lastTurnEnd(coder.id)) await pushDigest(link.id);
97
+ return getLink(link.id)!;
98
+ }
@@ -0,0 +1,31 @@
1
+ import { settleSession } from "../sidebar/shelf-state";
2
+ import { endLinkRow, getLink, liveLinkFor, setPausedRow } from "./store";
3
+ import { AnalystError, type AnalystLink } from "./types";
4
+
5
+ // Unlinks: the rows stay. The analyst is settled too unless kept.
6
+ export function endLink(
7
+ linkId: string,
8
+ opts: { endAnalyst?: boolean } = {}
9
+ ): AnalystLink {
10
+ const link = getLink(linkId);
11
+ if (!link) throw new AnalystError("Pairing not found", 404);
12
+ if (endLinkRow(link.id) && opts.endAnalyst !== false)
13
+ settleSession(link.analystSessionId);
14
+ return getLink(link.id)!;
15
+ }
16
+
17
+ // A session archived or done: its pairing ends. The analyst of a finished
18
+ // coder is settled with it; a finished analyst leaves the coder alone.
19
+ export function endLinkOf(sessionId: string): void {
20
+ const link = liveLinkFor(sessionId);
21
+ if (!link) return;
22
+ endLink(link.id, { endAnalyst: link.coderSessionId === sessionId });
23
+ }
24
+
25
+ export function setPaused(linkId: string, paused: boolean): AnalystLink {
26
+ const link = getLink(linkId);
27
+ if (!link) throw new AnalystError("Pairing not found", 404);
28
+ if (link.endedAt) throw new AnalystError("This pairing has ended", 409);
29
+ setPausedRow(link.id, paused);
30
+ return getLink(link.id)!;
31
+ }
@@ -0,0 +1,109 @@
1
+ import { sendChat } from "../chat/runner";
2
+ import { ASK_AGAIN } from "./brief";
3
+ import { digestPrefix } from "./pushDigest";
4
+ import {
5
+ decideRecommendation,
6
+ getLink,
7
+ getRecommendation,
8
+ markDigestPending,
9
+ reopenRecommendation,
10
+ } from "./store";
11
+ import { AnalystError, type AnalystLink, type Recommendation } from "./types";
12
+
13
+ // A prompt into the coding agent's chat as the reader's decision, by id,
14
+ // so a repeat is a no-op. It waits in the queue while a turn runs.
15
+ export async function forwardToCoder(
16
+ coderSessionId: string,
17
+ input: { text: string; id: string; label: string; fromSessionId?: string }
18
+ ): Promise<void> {
19
+ await sendChat(coderSessionId, {
20
+ text: input.text,
21
+ id: input.id,
22
+ queue: true,
23
+ origin: {
24
+ kind: "decision",
25
+ label: input.label,
26
+ sessionId: input.fromSessionId,
27
+ },
28
+ });
29
+ }
30
+
31
+ function liveLink(linkId: string): AnalystLink {
32
+ const link = getLink(linkId);
33
+ if (!link) throw new AnalystError("Pairing not found", 404);
34
+ if (link.endedAt) throw new AnalystError("This pairing has ended", 409);
35
+ return link;
36
+ }
37
+
38
+ function cardOf(link: AnalystLink, recId: string): Recommendation {
39
+ const rec = getRecommendation(recId);
40
+ if (!rec || rec.linkId !== link.id)
41
+ throw new AnalystError("Recommendation not found", 404);
42
+ return rec;
43
+ }
44
+
45
+ export const sentItemId = (recId: string) => `user-analyst-rec-${recId}`;
46
+
47
+ // Send to Coding Agent: the analyst's prompt, or the reader's edit of it.
48
+ export async function sendRecommendation(
49
+ linkId: string,
50
+ recId: string,
51
+ edited?: string
52
+ ): Promise<Recommendation> {
53
+ const link = liveLink(linkId);
54
+ const rec = cardOf(link, recId);
55
+ const text = (edited ?? rec.prompt ?? "").trim();
56
+ if (!text) throw new AnalystError("There is no prompt to send", 400);
57
+ const id = sentItemId(rec.id);
58
+ const isEdit = edited !== undefined && edited.trim() !== rec.prompt?.trim();
59
+ // Claimed first, so two presses send once.
60
+ if (
61
+ !decideRecommendation(rec.id, "sent", { text, itemId: id, edited: isEdit })
62
+ )
63
+ throw new AnalystError(`This recommendation was already ${rec.state}`, 409);
64
+ try {
65
+ await forwardToCoder(link.coderSessionId, {
66
+ text,
67
+ id,
68
+ label: "Analyst",
69
+ fromSessionId: link.analystSessionId,
70
+ });
71
+ } catch (error) {
72
+ reopenRecommendation(rec.id);
73
+ throw error;
74
+ }
75
+ return getRecommendation(rec.id)!;
76
+ }
77
+
78
+ export function dismissRecommendation(
79
+ linkId: string,
80
+ recId: string
81
+ ): Recommendation {
82
+ const link = liveLink(linkId);
83
+ const rec = cardOf(link, recId);
84
+ if (!decideRecommendation(rec.id, "dismissed"))
85
+ throw new AnalystError(`This recommendation was already ${rec.state}`, 409);
86
+ return getRecommendation(rec.id)!;
87
+ }
88
+
89
+ // Ask again, on a card whose reply had no valid block: the analyst gets a
90
+ // short correction, answered like the digest it replaces.
91
+ export async function askAgain(linkId: string, recId: string): Promise<void> {
92
+ const link = liveLink(linkId);
93
+ const rec = cardOf(link, recId);
94
+ if (rec.state !== "invalid")
95
+ throw new AnalystError(
96
+ "Only a reply with no recommendation is asked again",
97
+ 409
98
+ );
99
+ const id = `${digestPrefix(link.id)}again-${rec.id}`;
100
+ const through = rec.coderThrough ?? link.reviewedThrough;
101
+ if (through && link.digestSignals)
102
+ markDigestPending(link.id, id, through, link.digestSignals);
103
+ await sendChat(link.analystSessionId, {
104
+ text: ASK_AGAIN,
105
+ id,
106
+ queue: true,
107
+ origin: { kind: "system", label: "AgentOS" },
108
+ });
109
+ }
@@ -0,0 +1,16 @@
1
+ export * from "./types";
2
+ export { createLink, coderRefusal } from "./createLink";
3
+ export { endLink, endLinkOf, setPaused } from "./endLink";
4
+ export { linkView, sessionLinkView } from "./linkView";
5
+ export { pushDigest } from "./pushDigest";
6
+ export {
7
+ askAgain,
8
+ dismissRecommendation,
9
+ forwardToCoder,
10
+ sendRecommendation,
11
+ } from "./forward";
12
+ export { analystTurnEndHook, resumeAnalystLinks } from "./resume";
13
+ export { buildDigest } from "./buildDigest";
14
+ export { turnSignals, signalLine, latestChecks } from "./signals";
15
+ export { parseReview, REVIEW_FENCE } from "./parseReview";
16
+ export { getLink, getLinkOrThrow } from "./store";
@@ -0,0 +1,38 @@
1
+ import { db } from "../db";
2
+ import { lastItems } from "../chat/store";
3
+ import { getLink, listRecommendations, liveLinkFor } from "./store";
4
+ import type { AnalystLinkView, PaneSession } from "./types";
5
+
6
+ function pane(id: string): PaneSession {
7
+ const r = db
8
+ .prepare(
9
+ `SELECT id, name, agent_type, model FROM session_rows WHERE id = ?`
10
+ )
11
+ .get(id) as
12
+ | { id: string; name: string; agent_type: string; model: string | null }
13
+ | undefined;
14
+ return {
15
+ id,
16
+ name: r?.name ?? "Ended session",
17
+ agentType: r?.agent_type ?? "claude",
18
+ model: r?.model ?? null,
19
+ };
20
+ }
21
+
22
+ export function linkView(linkId: string): AnalystLinkView | null {
23
+ const link = getLink(linkId);
24
+ if (!link) return null;
25
+ return {
26
+ ...link,
27
+ coder: pane(link.coderSessionId),
28
+ analyst: pane(link.analystSessionId),
29
+ coderLastTurn: lastItems(link.coderSessionId, 1, "turn_end")[0]?.id ?? null,
30
+ recommendations: listRecommendations(link.id),
31
+ };
32
+ }
33
+
34
+ // The live pairing a session is in, as either side.
35
+ export function sessionLinkView(sessionId: string): AnalystLinkView | null {
36
+ const link = liveLinkFor(sessionId);
37
+ return link ? linkView(link.id) : null;
38
+ }
@@ -0,0 +1,119 @@
1
+ import type { ChatItem } from "../chat/events";
2
+ import { lastItems } from "../chat/store";
3
+ import { parseReview, REVIEW_FENCE } from "./parseReview";
4
+ import { digestPrefix, pushDigest } from "./pushDigest";
5
+ import {
6
+ getLink,
7
+ insertRecommendation,
8
+ liveLinkFor,
9
+ recommendationFor,
10
+ settleDigest,
11
+ } from "./store";
12
+ import type { AnalystLink, Recommendation } from "./types";
13
+
14
+ // Enough of the analyst's chat to hold one reply and the digest before it.
15
+ const REPLY_ITEMS = 80;
16
+
17
+ // The coding agent finished a turn: the analyst gets it, unless it's busy
18
+ // with a digest (then it comes with the next one).
19
+ export async function onCoderTurnEnd(sessionId: string): Promise<void> {
20
+ const link = liveLinkFor(sessionId);
21
+ if (link?.coderSessionId !== sessionId) return;
22
+ await pushDigest(link.id);
23
+ }
24
+
25
+ interface Reply {
26
+ // The message the turn answered.
27
+ prompt: Extract<ChatItem, { kind: "user" }> | null;
28
+ text: string;
29
+ // The item the review block is in (its card renders there), else the
30
+ // turn's end.
31
+ itemId: string;
32
+ }
33
+
34
+ function lastReply(sessionId: string, turnEndId: string): Reply | null {
35
+ const items = lastItems(sessionId, REPLY_ITEMS);
36
+ const end = items.findLastIndex((i) => i.id === turnEndId);
37
+ if (end < 0) return null;
38
+ const start = items.slice(0, end).findLastIndex((i) => i.kind === "user");
39
+ const turn = items.slice(start + 1, end);
40
+ const said = turn.filter(
41
+ (i): i is Extract<ChatItem, { kind: "assistant" }> => i.kind === "assistant"
42
+ );
43
+ const prompt = start >= 0 ? items[start] : null;
44
+ return {
45
+ prompt: prompt?.kind === "user" ? prompt : null,
46
+ text: said.map((i) => i.text).join("\n\n"),
47
+ itemId:
48
+ said.findLast((i) => i.text.includes(REVIEW_FENCE))?.id ??
49
+ said[said.length - 1]?.id ??
50
+ turnEndId,
51
+ };
52
+ }
53
+
54
+ // Turns the analyst's reply into a card: a recommendation when it carries a
55
+ // valid block, an invalid one when it answered a digest without one.
56
+ export function recordReply(
57
+ link: AnalystLink,
58
+ reply: Reply
59
+ ): Recommendation | null {
60
+ const digest =
61
+ !!reply.prompt?.id.startsWith(digestPrefix(link.id)) ||
62
+ reply.prompt?.id === link.pendingDigestId;
63
+ const parsed = parseReview(reply.text);
64
+ if (!parsed.ok && !digest) return null;
65
+ if (recommendationFor(link.id, reply.itemId)) return null;
66
+ const about = {
67
+ linkId: link.id,
68
+ analystItemId: reply.itemId,
69
+ coderThrough: link.pendingThrough ?? link.reviewedThrough,
70
+ signals: link.digestSignals,
71
+ };
72
+ return parsed.ok
73
+ ? insertRecommendation({
74
+ ...about,
75
+ verdict: parsed.review.verdict,
76
+ summary: parsed.review.summary,
77
+ issues: parsed.review.issues,
78
+ prompt: parsed.review.prompt ?? null,
79
+ state: "open",
80
+ })
81
+ : insertRecommendation({
82
+ ...about,
83
+ verdict: null,
84
+ summary: "",
85
+ issues: [],
86
+ prompt: null,
87
+ state: "invalid",
88
+ error: parsed.error,
89
+ });
90
+ }
91
+
92
+ // The analyst finished a turn: its review becomes a card, the digest it
93
+ // answered counts as reviewed, and turns that ended meanwhile go next.
94
+ export async function onAnalystTurnEnd(
95
+ sessionId: string,
96
+ turnEnd: Extract<ChatItem, { kind: "turn_end" }>
97
+ ): Promise<void> {
98
+ const link = liveLinkFor(sessionId);
99
+ if (link?.analystSessionId !== sessionId) return;
100
+ const reply = lastReply(sessionId, turnEnd.id);
101
+ if (!reply) return;
102
+ recordReply(link, reply);
103
+ const answered = reply.prompt?.id;
104
+ if (answered && answered === link.pendingDigestId)
105
+ settleDigest(link.id, answered);
106
+ if (getLink(link.id)?.pendingDigestId) return;
107
+ await pushDigest(link.id);
108
+ }
109
+
110
+ // What the chat hook calls for every finished turn.
111
+ export async function onTurnEnd(
112
+ sessionId: string,
113
+ item: Extract<ChatItem, { kind: "turn_end" }>
114
+ ): Promise<void> {
115
+ const link = liveLinkFor(sessionId);
116
+ if (!link) return;
117
+ if (link.coderSessionId === sessionId) await onCoderTurnEnd(sessionId);
118
+ else await onAnalystTurnEnd(sessionId, item);
119
+ }
@@ -0,0 +1,41 @@
1
+ import { reviewSchema, type Review } from "./types";
2
+
3
+ export const REVIEW_FENCE = "agentos-review";
4
+
5
+ const FENCE = new RegExp(
6
+ "(`{3,}|~{3,})[ \\t]*" + REVIEW_FENCE + "[^\\n]*\\n([\\s\\S]*?)\\n?\\1",
7
+ "g"
8
+ );
9
+
10
+ export type ParsedReview =
11
+ { ok: true; review: Review } | { ok: false; error: string };
12
+
13
+ // The last agentos-review block in the analyst's reply, validated.
14
+ export function parseReview(text: string): ParsedReview {
15
+ const blocks = [...text.matchAll(FENCE)];
16
+ if (!blocks.length)
17
+ return { ok: false, error: "The reply had no agentos-review block" };
18
+ return parseReviewJson(blocks[blocks.length - 1][2]);
19
+ }
20
+
21
+ export function parseReviewJson(raw: string): ParsedReview {
22
+ let json: unknown;
23
+ try {
24
+ json = JSON.parse(raw.trim());
25
+ } catch {
26
+ return { ok: false, error: "The agentos-review block isn't valid JSON" };
27
+ }
28
+ const got = reviewSchema.safeParse(json);
29
+ if (!got.success) {
30
+ const issue = got.error.issues[0];
31
+ return {
32
+ ok: false,
33
+ error: `The agentos-review block is invalid: ${issue.path.join(".") || "block"} ${issue.message}`,
34
+ };
35
+ }
36
+ const review = got.data;
37
+ return {
38
+ ok: true,
39
+ review: { ...review, prompt: review.prompt?.trim() || null },
40
+ };
41
+ }