@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,223 @@
1
+ // The developer-analyst loop: types shared by the engine, the routes and
2
+ // the browser (no Node modules here).
3
+
4
+ export const CRITERION_STATUSES = ["open", "failing", "met", "waived"] as const;
5
+ export type CriterionStatus = (typeof CRITERION_STATUSES)[number];
6
+
7
+ export const CRITERION_SOURCES = ["card", "brief", "plan", "user"] as const;
8
+ export type CriterionSource = (typeof CRITERION_SOURCES)[number];
9
+
10
+ export const EVIDENCE_KINDS = ["test", "diff", "typecheck", "ci"] as const;
11
+ export type EvidenceKind = (typeof EVIDENCE_KINDS)[number];
12
+
13
+ export interface Evidence {
14
+ kind: EvidenceKind;
15
+ ref: string;
16
+ note: string;
17
+ }
18
+
19
+ export interface Criterion {
20
+ id: number;
21
+ loopId: string;
22
+ position: number;
23
+ text: string;
24
+ source: CriterionSource;
25
+ evidenceKind: EvidenceKind | null;
26
+ status: CriterionStatus;
27
+ evidence: Evidence | null;
28
+ // False when the analyst said "met" but the citation didn't resolve.
29
+ verified: boolean;
30
+ metCycle: number | null;
31
+ waivedBy: string | null;
32
+ }
33
+
34
+ export interface CriterionDraft {
35
+ text: string;
36
+ source?: CriterionSource;
37
+ evidenceKind?: EvidenceKind | null;
38
+ }
39
+
40
+ export type LoopState = "running" | "stopped";
41
+
42
+ export const STOP_REASONS = [
43
+ "stopped",
44
+ "blocked",
45
+ "done",
46
+ "repeat",
47
+ "no_progress",
48
+ "limit:cycles",
49
+ "limit:cost",
50
+ "limit:time",
51
+ ] as const;
52
+ export type StopReason = (typeof STOP_REASONS)[number];
53
+
54
+ export interface Limits {
55
+ maxCycles: number;
56
+ maxCostUsd: number;
57
+ maxMinutes: number;
58
+ graceSeconds: number;
59
+ stallCycles: number;
60
+ repeatCycles: number;
61
+ }
62
+
63
+ export const DEFAULT_LIMITS: Limits = {
64
+ maxCycles: 8,
65
+ maxCostUsd: 15,
66
+ maxMinutes: 180,
67
+ graceSeconds: 30,
68
+ stallCycles: 3,
69
+ repeatCycles: 3,
70
+ };
71
+
72
+ export const LIMIT_BOUNDS = {
73
+ maxCycles: [1, 30],
74
+ maxCostUsd: [1, 200],
75
+ maxMinutes: [10, 24 * 60],
76
+ graceSeconds: [0, 300],
77
+ stallCycles: [2, 10],
78
+ repeatCycles: [2, 10],
79
+ } as const satisfies Record<keyof Limits, readonly [number, number]>;
80
+
81
+ export interface LoopRow extends Limits {
82
+ id: string;
83
+ sessionId: string;
84
+ workspaceId: string | null;
85
+ state: LoopState;
86
+ stopReason: StopReason | null;
87
+ stopDetail: string | null;
88
+ goal: string;
89
+ costBaseUsd: number;
90
+ startedAt: number;
91
+ endedAt: number | null;
92
+ lastCycleAt: number | null;
93
+ runNow: boolean;
94
+ }
95
+
96
+ export type CycleTrigger = "turn_end" | "ci" | "review" | "manual";
97
+
98
+ // Where a cycle is. The steps before "decided" are redone after a restart;
99
+ // "sending" is safe to redo because the message goes by a fixed id.
100
+ export type CycleState =
101
+ | "claimed"
102
+ | "decided"
103
+ | "counting"
104
+ | "sending"
105
+ | "sent"
106
+ | "skipped"
107
+ | "stopped"
108
+ | "superseded"
109
+ | "error";
110
+
111
+ export type Sender = "analyst" | "user";
112
+
113
+ export interface Finding {
114
+ severity: "blocking" | "minor";
115
+ file?: string;
116
+ line?: number;
117
+ text: string;
118
+ }
119
+
120
+ export interface Analysis {
121
+ summary: string;
122
+ findings: Finding[];
123
+ criteria: {
124
+ id: number;
125
+ status: "met" | "open" | "failing";
126
+ evidence?: Evidence;
127
+ }[];
128
+ next: { instruction: string; focus: number[] } | null;
129
+ blocker: {
130
+ kind:
131
+ | "decision"
132
+ | "credential"
133
+ | "hard-line"
134
+ | "unclear-criteria"
135
+ | "environment";
136
+ text: string;
137
+ } | null;
138
+ }
139
+
140
+ export interface CommandResult {
141
+ name: "typecheck" | "test";
142
+ command: string;
143
+ exitCode: number | null;
144
+ durationMs: number;
145
+ passed: string[];
146
+ failed: string[];
147
+ errors: string[];
148
+ excerpt: string;
149
+ skipped?: string;
150
+ }
151
+
152
+ export interface EvidenceSummary {
153
+ green: boolean;
154
+ commands: CommandResult[];
155
+ ci: { name: string; state: string; sha?: string | null }[];
156
+ note?: string;
157
+ }
158
+
159
+ // The numbers "progress" is judged by: more met criteria, fewer failing
160
+ // tests, fewer type errors.
161
+ export interface Score {
162
+ met: number;
163
+ failingTests: number;
164
+ typeErrors: number;
165
+ }
166
+
167
+ export interface CycleRow {
168
+ id: number;
169
+ loopId: string;
170
+ n: number;
171
+ trigger: CycleTrigger;
172
+ state: CycleState;
173
+ snapshotSha: string | null;
174
+ tree: string | null;
175
+ changed: boolean;
176
+ evidence: EvidenceSummary | null;
177
+ logPath: string | null;
178
+ analysis: Analysis | null;
179
+ signature: string | null;
180
+ score: Score | null;
181
+ instruction: string | null;
182
+ instructionSent: string | null;
183
+ sender: Sender | null;
184
+ delivery: string | null;
185
+ sendAt: number | null;
186
+ input: string | null;
187
+ costUsd: number;
188
+ error: string | null;
189
+ startedAt: number;
190
+ endedAt: number | null;
191
+ }
192
+
193
+ // What the loop view shows.
194
+ export interface LoopView {
195
+ loop: LoopRow;
196
+ // Why nothing is moving right now, when something is holding it.
197
+ hold: "paused" | "usage-window" | null;
198
+ holdReason: string | null;
199
+ spentUsd: number;
200
+ elapsedMs: number;
201
+ criteria: Criterion[];
202
+ cycles: CycleRow[];
203
+ }
204
+
205
+ // What the pill on a task row shows.
206
+ export interface LoopSummary {
207
+ state: LoopState;
208
+ stopReason: StopReason | null;
209
+ cycle: number;
210
+ maxCycles: number;
211
+ met: number;
212
+ total: number;
213
+ hold: "paused" | "usage-window" | null;
214
+ }
215
+
216
+ export class LoopError extends Error {
217
+ constructor(
218
+ message: string,
219
+ readonly status = 409
220
+ ) {
221
+ super(message);
222
+ }
223
+ }
@@ -0,0 +1,223 @@
1
+ // A stubbed world for the loop: a coder that ends turns and changes files, an
2
+ // analyst that answers from a script, and a checks run that reports what the
3
+ // scenario says, on a clock the test moves. No model, shell or chat.
4
+ import { db, updateSessionRow } from "@/lib/db";
5
+ import { createProject } from "@/lib/projects";
6
+ import { createWorkspace, setProjectWorkspace } from "@/lib/workspaces";
7
+ import { ensureOrchestrator } from "@/lib/orchestrator/home";
8
+ import { seedSession } from "@/lib/orchestrator/testing";
9
+ import type { SessionFacts } from "@/lib/orchestrator/facts";
10
+ import type { LoopDeps } from "./deps";
11
+ import { resetLoopMemory, tickLoops } from "./tickLoops";
12
+ import { startLoop } from "./controls";
13
+ import { latestLoopFor } from "./store";
14
+ import type { Analysis, CommandResult, Limits } from "./types";
15
+
16
+ export interface Scenario {
17
+ // The failing tests (and so the signature) of cycle n; [] is green.
18
+ failing: (n: number) => string[];
19
+ // How many criteria the analyst marks met in cycle n (citing the diff).
20
+ met: (n: number) => number;
21
+ // Spend by the end of cycle n, in dollars.
22
+ spent?: (n: number) => number;
23
+ // The coder changed nothing in this cycle's turn.
24
+ unchanged?: (n: number) => boolean;
25
+ // Words the analyst gives as the next instruction.
26
+ instruction?: (n: number) => string | null;
27
+ blocker?: (n: number) => Analysis["blocker"];
28
+ }
29
+
30
+ export const CRITERIA = Array.from({ length: 10 }, (_, i) => ({
31
+ text: `Criterion ${i + 1}`,
32
+ }));
33
+
34
+ export async function makeWorld(
35
+ scenario: Scenario,
36
+ limits: Partial<Limits> = {}
37
+ ) {
38
+ const ws = createWorkspace(
39
+ `loop-ws-${Math.random().toString(36).slice(2, 8)}`
40
+ );
41
+ const project = createProject({
42
+ name: `loop-app-${Math.random().toString(36).slice(2, 8)}`,
43
+ workingDirectory: "/tmp/loop-app",
44
+ });
45
+ setProjectWorkspace(project.id, ws.id);
46
+ ensureOrchestrator(ws.id);
47
+ const taskId = seedSession({
48
+ projectId: project.id,
49
+ name: "loop-task",
50
+ task: true,
51
+ view: "chat",
52
+ branch: "feature/x",
53
+ });
54
+ updateSessionRow(db, taskId, {
55
+ base_branch: "main",
56
+ task_prompt: "Build the thing",
57
+ worktree_path: "/tmp/loop-app",
58
+ workspace_id: ws.id,
59
+ });
60
+
61
+ const world = {
62
+ ws: ws.id,
63
+ taskId,
64
+ t: Date.parse("2026-10-21T12:00:00Z"),
65
+ rev: 1,
66
+ cycle: 0,
67
+ spent: 0,
68
+ sent: [] as string[],
69
+ running: false,
70
+ prompts: [] as string[],
71
+ fact: {
72
+ id: taskId,
73
+ name: "loop-task",
74
+ project: project.id,
75
+ view: "chat",
76
+ status: "idle",
77
+ activity: null,
78
+ needsInput: false,
79
+ lastActive: 0,
80
+ branch: "feature/x",
81
+ task: { state: "working", pr: null, blocked: null },
82
+ stack: null,
83
+ } as unknown as SessionFacts,
84
+ };
85
+ world.fact.lastActive = world.t - 10 * 60_000;
86
+
87
+ const deps: LoopDeps = {
88
+ now: () => world.t,
89
+ task: (id) =>
90
+ id === taskId
91
+ ? (db
92
+ .prepare(`SELECT * FROM session_rows WHERE id = ?`)
93
+ .get(id) as never)
94
+ : undefined,
95
+ prepare: async () => ({
96
+ repo: "/tmp/loop-app",
97
+ base: "origin/main",
98
+ dir: "/tmp/loop-app",
99
+ }),
100
+ snapshot: async () => ({
101
+ sha: `sha${world.rev}`,
102
+ tree: `tree${world.rev}`,
103
+ }),
104
+ commands: async () => ({ typecheck: null, test: "npm test" }),
105
+ testFiles: async () => [],
106
+ gather: async () => {
107
+ const n = world.cycle;
108
+ const failing = scenario.failing(n);
109
+ const test: CommandResult = {
110
+ name: "test",
111
+ command: "npm test",
112
+ exitCode: failing.length ? 1 : 0,
113
+ durationMs: 1000,
114
+ passed: failing.length ? [] : ["all.test.ts"],
115
+ failed: failing,
116
+ errors: [],
117
+ excerpt: failing.length ? failing.join("\n") : "",
118
+ };
119
+ return {
120
+ summary: { green: !failing.length, commands: [test], ci: [] },
121
+ signature: failing.length ? `test: ${failing.join("; ")}` : null,
122
+ logPath: null,
123
+ };
124
+ },
125
+ diff: async () => ({
126
+ map: "diff",
127
+ hunks: new Map([["a.ts", [[1, 100] as [number, number]]]]),
128
+ }),
129
+ analyse: async (input) => {
130
+ const n = world.cycle;
131
+ world.prompts.push(input.prompt.goal);
132
+ world.spent = scenario.spent?.(n) ?? world.spent;
133
+ const met = scenario.met(n);
134
+ const criteria = input.prompt.criteria;
135
+ return {
136
+ costUsd: 0,
137
+ analysis: {
138
+ summary: `Cycle ${n} summary`,
139
+ findings: [],
140
+ criteria: criteria.map((c, i) => ({
141
+ id: c.id,
142
+ status: i < met ? ("met" as const) : ("open" as const),
143
+ ...(i < met
144
+ ? {
145
+ evidence: {
146
+ kind: "diff" as const,
147
+ ref: "a.ts:5",
148
+ note: "changed",
149
+ },
150
+ }
151
+ : {}),
152
+ })),
153
+ next:
154
+ scenario.instruction?.(n) === null
155
+ ? null
156
+ : {
157
+ instruction:
158
+ scenario.instruction?.(n) ??
159
+ `Fix the failures from cycle ${n}`,
160
+ focus: [],
161
+ },
162
+ blocker: scenario.blocker?.(n) ?? null,
163
+ },
164
+ };
165
+ },
166
+ send: async (_task, _loopId, _n, text) => {
167
+ world.sent.push(text);
168
+ world.running = true;
169
+ return "sent";
170
+ },
171
+ coderReply: () => null,
172
+ coderRunning: async () => false,
173
+ amendments: () => [],
174
+ spent: () => world.spent,
175
+ hold: () => null,
176
+ waitingOnPerson: () => null,
177
+ };
178
+ // The cycle number gather and analyse see is the one being claimed.
179
+ const real = deps.snapshot;
180
+ deps.snapshot = async (dir) => {
181
+ world.cycle = latestCycleN() ?? 0;
182
+ return real(dir);
183
+ };
184
+ const latestCycleN = () => {
185
+ const loop = latestLoopFor(taskId);
186
+ return loop
187
+ ? (
188
+ db
189
+ .prepare(
190
+ `SELECT MAX(n) AS n FROM task_loop_cycles WHERE loop_id = ?`
191
+ )
192
+ .get(loop.id) as { n: number | null }
193
+ ).n
194
+ : null;
195
+ };
196
+
197
+ resetLoopMemory();
198
+ await startLoop(
199
+ taskId,
200
+ { criteria: CRITERIA, limits: { graceSeconds: 0, ...limits } },
201
+ deps
202
+ );
203
+
204
+ const tick = () => tickLoops(world.ws, [world.fact], deps);
205
+ const state = () => latestLoopFor(taskId)!;
206
+ // The coder takes a turn (runs, then goes quiet), then the loop looks.
207
+ async function round() {
208
+ world.fact.status = "running";
209
+ await tick();
210
+ world.t += 60_000;
211
+ world.fact.status = "idle";
212
+ world.fact.lastActive = world.t - 30_000;
213
+ if (!scenario.unchanged?.(world.cycle)) world.rev++;
214
+ await tick();
215
+ }
216
+ async function run(maxRounds = 40) {
217
+ await tick();
218
+ for (let i = 0; i < maxRounds && state().state === "running"; i++)
219
+ await round();
220
+ return state();
221
+ }
222
+ return { world, deps, tick, round, run, state };
223
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Starting a session in an agent role: which model its binding gives it,
3
+ * whether that model's server answers, and what to run it on. A local model
4
+ * that's down never turns into a cloud one by itself: only a fallback the
5
+ * user set for the role, and only when the start asks for it.
6
+ */
7
+
8
+ import { AGENT_ROLE_DEFS, type AgentRole } from "./agent-roles";
9
+ import { modelAvailability, choiceFor } from "./catalog";
10
+ import { savedModelSettings } from "./config";
11
+ import { PI } from "./providers";
12
+ import { resolveRole, type RoleSource, type RoutedModel } from "./resolve";
13
+
14
+ export interface RoleBinding {
15
+ role: AgentRole;
16
+ routed: RoutedModel;
17
+ // The user's fallback for the role, if set.
18
+ fallback?: string;
19
+ }
20
+
21
+ export function bindingFor(
22
+ role: AgentRole,
23
+ opts: {
24
+ workspaceId?: string | null;
25
+ projectId?: string | null;
26
+ // The model picked for this one session.
27
+ override?: string | null;
28
+ } = {}
29
+ ): RoleBinding {
30
+ const modelRole = AGENT_ROLE_DEFS[role].modelRole;
31
+ const routed = resolveRole(modelRole, { ...opts, allowPi: true });
32
+ return {
33
+ role,
34
+ routed,
35
+ fallback: savedModelSettings().fallbacks?.[modelRole],
36
+ };
37
+ }
38
+
39
+ export class ModelUnavailableError extends Error {
40
+ readonly code = "model_unavailable";
41
+ constructor(
42
+ readonly role: AgentRole,
43
+ readonly ref: string,
44
+ readonly detail: string,
45
+ readonly fallback?: string
46
+ ) {
47
+ super(
48
+ `${AGENT_ROLE_DEFS[role].label}'s model ${ref} isn't answering${detail ? ` (${detail})` : ""}`
49
+ );
50
+ }
51
+ }
52
+
53
+ export interface RoleStart {
54
+ role: AgentRole;
55
+ routed: RoutedModel;
56
+ // "pi" runs the Pi agent on the model; "claude" runs Claude Code.
57
+ agentType: "claude" | "pi";
58
+ // What the agent CLI is given as its model.
59
+ model: string;
60
+ source: RoleSource | "fallback";
61
+ }
62
+
63
+ const startFrom = (
64
+ role: AgentRole,
65
+ routed: RoutedModel,
66
+ source: RoleStart["source"]
67
+ ): RoleStart =>
68
+ routed.provider.id === PI
69
+ ? {
70
+ role,
71
+ routed,
72
+ agentType: "pi",
73
+ model: routed.ref.slice(PI.length + 1),
74
+ source,
75
+ }
76
+ : { role, routed, agentType: "claude", model: routed.model, source };
77
+
78
+ // The model a session started in `role` runs on, after checking its server.
79
+ export async function prepareRoleStart(
80
+ role: AgentRole,
81
+ opts: Parameters<typeof bindingFor>[1] & { useFallback?: boolean }
82
+ ): Promise<RoleStart> {
83
+ const binding = bindingFor(role, opts);
84
+ const state = await modelAvailability(binding.routed.ref);
85
+ if (state.state !== "down")
86
+ return startFrom(role, binding.routed, binding.routed.source);
87
+ if (opts.useFallback && binding.fallback) {
88
+ const alt = bindingFor(role, { ...opts, override: binding.fallback });
89
+ const altState = await modelAvailability(alt.routed.ref);
90
+ if (altState.state !== "down")
91
+ return startFrom(role, alt.routed, "fallback");
92
+ }
93
+ throw new ModelUnavailableError(
94
+ role,
95
+ binding.routed.ref,
96
+ state.error ?? "",
97
+ binding.fallback &&
98
+ choiceFor(binding.fallback)?.availability.state !== "down"
99
+ ? binding.fallback
100
+ : undefined
101
+ );
102
+ }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * What the Agent roles screen and the native app show: each role, the model
3
+ * saved at a scope, the model in force there and where it came from, and
4
+ * what the server knows of that model (local or cloud, answering or not).
5
+ * Saving checks what a role may run on.
6
+ */
7
+
8
+ import {
9
+ AGENT_ROLES,
10
+ AGENT_ROLE_DEFS,
11
+ INTERNAL_MODEL_ROLES,
12
+ type AgentRole,
13
+ } from "./agent-roles";
14
+ import { choiceFor, type ModelChoice } from "./catalog";
15
+ import {
16
+ effectiveModels,
17
+ projectModels,
18
+ savedModelSettings,
19
+ setModelSettings,
20
+ setProjectModels,
21
+ setWorkspaceModels,
22
+ workspaceModels,
23
+ type RoleMap,
24
+ } from "./config";
25
+ import { PI } from "./providers";
26
+ import { resolveRole, type RoleSource } from "./resolve";
27
+ import {
28
+ DEFAULT_ROLE_MODELS,
29
+ GATE_ROLES,
30
+ MODEL_ROLES,
31
+ ROLE_LABELS,
32
+ parseModelRef,
33
+ type ModelRole,
34
+ } from "./roles";
35
+
36
+ export type RoleScope = "settings" | "workspace" | "project";
37
+ export interface ScopeRef {
38
+ scope: RoleScope;
39
+ id?: string | null;
40
+ }
41
+
42
+ export interface RoleView {
43
+ modelRole: ModelRole;
44
+ label: string;
45
+ hint: string;
46
+ // Saved at this scope, or null when it inherits.
47
+ value: string | null;
48
+ binding: { ref: string; source: RoleSource } | null;
49
+ fallback: string | null;
50
+ choice: ModelChoice | null;
51
+ // Why the binding can't be used (a provider turned off).
52
+ error?: string;
53
+ default: string;
54
+ }
55
+
56
+ export interface AgentRoleView extends RoleView {
57
+ id: AgentRole;
58
+ purpose: string;
59
+ brief: string;
60
+ edits: boolean;
61
+ }
62
+
63
+ function savedAt({ scope, id }: ScopeRef): RoleMap {
64
+ if (scope === "workspace" && id) return workspaceModels(id).roles ?? {};
65
+ if (scope === "project" && id) return projectModels(id).roles ?? {};
66
+ return savedModelSettings().roles ?? {};
67
+ }
68
+
69
+ function viewOf(modelRole: ModelRole, at: ScopeRef, ctx: ContextIds): RoleView {
70
+ const effective = effectiveModels();
71
+ let binding: RoleView["binding"] = null;
72
+ let error: string | undefined;
73
+ try {
74
+ const r = resolveRole(modelRole, { ...ctx, allowPi: true }, effective);
75
+ binding = { ref: r.ref, source: r.source };
76
+ } catch (e) {
77
+ error = e instanceof Error ? e.message : String(e);
78
+ }
79
+ return {
80
+ modelRole,
81
+ label: ROLE_LABELS[modelRole].label,
82
+ hint: ROLE_LABELS[modelRole].hint,
83
+ value: savedAt(at)[modelRole] ?? null,
84
+ binding,
85
+ fallback: savedModelSettings().fallbacks?.[modelRole] ?? null,
86
+ choice: binding ? (choiceFor(binding.ref, effective) ?? null) : null,
87
+ ...(error ? { error } : {}),
88
+ default: DEFAULT_ROLE_MODELS[modelRole],
89
+ };
90
+ }
91
+
92
+ interface ContextIds {
93
+ workspaceId?: string | null;
94
+ projectId?: string | null;
95
+ }
96
+
97
+ const contextOf = ({ scope, id }: ScopeRef): ContextIds =>
98
+ scope === "workspace"
99
+ ? { workspaceId: id }
100
+ : scope === "project"
101
+ ? { projectId: id }
102
+ : {};
103
+
104
+ export function agentRolesView(at: ScopeRef, ctx?: ContextIds) {
105
+ const ids = ctx ?? contextOf(at);
106
+ return {
107
+ roles: AGENT_ROLES.map((id): AgentRoleView => ({
108
+ ...viewOf(AGENT_ROLE_DEFS[id].modelRole, at, ids),
109
+ id,
110
+ label: AGENT_ROLE_DEFS[id].label,
111
+ hint: AGENT_ROLE_DEFS[id].purpose,
112
+ purpose: AGENT_ROLE_DEFS[id].purpose,
113
+ brief: AGENT_ROLE_DEFS[id].brief,
114
+ edits: AGENT_ROLE_DEFS[id].edits,
115
+ })),
116
+ internal: INTERNAL_MODEL_ROLES(MODEL_ROLES).map((r) => viewOf(r, at, ids)),
117
+ };
118
+ }
119
+
120
+ // The models a role can be bound to: a Pi model runs a role session only,
121
+ // never a task, a gate or the orchestrator.
122
+ const PI_ROLES: readonly ModelRole[] = ["implement", "analyze", "advise"];
123
+
124
+ function checkRoles(roles: RoleMap): void {
125
+ for (const [role, ref] of Object.entries(roles) as [ModelRole, string][]) {
126
+ if (parseModelRef(ref).provider !== PI) continue;
127
+ if (!PI_ROLES.includes(role))
128
+ throw new Error(
129
+ `${ROLE_LABELS[role].label} can't run on a Pi model (${GATE_ROLES.includes(role) ? "a merge gate needs Claude Code" : "only the Developer, Analyst and Reviewer can"})`
130
+ );
131
+ }
132
+ }
133
+
134
+ export function saveAgentRoles(
135
+ at: ScopeRef,
136
+ roles: RoleMap,
137
+ fallbacks?: RoleMap
138
+ ): void {
139
+ checkRoles(roles);
140
+ if (fallbacks) {
141
+ for (const ref of Object.values(fallbacks))
142
+ if (ref && parseModelRef(ref).provider === PI)
143
+ throw new Error("A fallback must be a cloud or Claude Code model");
144
+ }
145
+ if (at.scope === "settings") {
146
+ const saved = savedModelSettings();
147
+ setModelSettings({
148
+ ...saved,
149
+ roles,
150
+ ...(fallbacks ? { fallbacks } : {}),
151
+ });
152
+ return;
153
+ }
154
+ if (!at.id) throw new Error(`${at.scope} needs an id`);
155
+ if (at.scope === "workspace") setWorkspaceModels(at.id, { roles });
156
+ else setProjectModels(at.id, { roles });
157
+ }