@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
package/README.md CHANGED
@@ -19,6 +19,7 @@ and check on all of it from your phone.
19
19
  - **Other machines in the same list.** Link another machine that runs AgentOS and start or move work there. [More](docs/reference/machines.md)
20
20
  - **Built for your phone.** The whole app works on a phone; pair it with a QR code. [More](docs/guides/phone.md)
21
21
  - **Phone to orchestrator.** A paired phone can send a shake report or a shared page or file to a workspace's orchestrator (`POST /api/orchestrators/:workspaceId/intake`, saved under `intake/` beside the database, shared content marked untrusted), and fetch names to improve dictation (`GET /api/voice/vocab?workspace=`). [More](docs/guides/phone.md)
22
+ - **An analyst beside your coding agent.** Pair a chat with a second one that reviews each turn, explains it in plain English, flags issues and drafts the next prompt, which you send, edit or dismiss. Side by side on a desktop, a switch on a phone. [More](docs/decisions/2026-10-21-analyst-sessions.md)
22
23
  - **Agents that talk to each other.** Sessions message each other with `aos`, and you see every conversation in Messages. [More](docs/guides/agent-network.md)
23
24
  - **Chat or terminal, same conversation.** Streaming replies, steps and diffs, plan mode, slash commands, `@file`, undo. [More](docs/guides/chat.md)
24
25
  - **Bookmarks.** Star any message, tool call or turn, then find it again from the chat or across every chat, with a search. [More](docs/guides/chat.md)
@@ -28,6 +29,7 @@ and check on all of it from your phone.
28
29
  - **Project config.** `agentos.json` gives each session its own ports, environment, files and a private Postgres copy. [More](docs/reference/project-config.md)
29
30
  - **Schedules and recipes.** Start work on a timer, or save a job that comes up again. [More](docs/guides/schedules.md)
30
31
  - **Plans in pieces and stacks.** A plan splits into several PRs only for a real reason; a board can run as stacked PRs. [More](docs/guides/tasks.md#plans-in-pieces)
32
+ - **Export a session for AI.** A whole chat or a range of its turns as Markdown, JSON or HTML, with a compact mode, the same masking as Share, a size and token estimate, and every cut labelled. [More](docs/guides/export.md)
31
33
  - **Machine load and status.** A load gauge, each session's share of CPU and memory, and Needs you, Working, In review and Done as it changes. [More](docs/guides/sessions.md)
32
34
  - **A health watcher.** Zero-token checks on the orchestrator's tick catch a red machine load, a held queue, a quiet background job, a restart loop, slow CI, a PR armed for hours and a piece started twice. Each trip wakes one short, tool-less Sonnet call that judges it and posts one line to the orchestrator; only a thing you alone can clear reaches your phone. Settings → Health lists what is open, the history, the notes on what normal looks like and the thresholds. Report only: it never acts. [More](docs/reference/health.md)
33
35
  - **Workspaces.** Group projects, finish work with Done, and get a log of every merged task. [More](docs/reference/workspaces.md)
@@ -0,0 +1,67 @@
1
+ import { NextRequest } from "next/server";
2
+ import { beforeEach, describe, expect, it } from "vitest";
3
+ import { db } from "@/lib/db";
4
+ import { GET, PUT } from "./route";
5
+ import { TRUST_HEADER } from "@/lib/security/auth";
6
+
7
+ const url = "http://localhost/api/agent-roles";
8
+ const put = (body: unknown, trusted = true) =>
9
+ PUT(
10
+ new NextRequest(url, {
11
+ method: "PUT",
12
+ body: JSON.stringify(body),
13
+ headers: trusted ? { [TRUST_HEADER]: "loopback" } : {},
14
+ })
15
+ );
16
+ const get = (q = "") => GET(new NextRequest(`${url}${q}`));
17
+
18
+ beforeEach(() => {
19
+ db.prepare(`DELETE FROM settings WHERE key LIKE 'models%'`).run();
20
+ });
21
+
22
+ describe("/api/agent-roles", () => {
23
+ it("lists the four roles with the model in force", async () => {
24
+ const { roles } = await (await get()).json();
25
+ expect(roles.map((r: { id: string }) => r.id)).toEqual([
26
+ "developer",
27
+ "analyst",
28
+ "reviewer",
29
+ "supervisor",
30
+ ]);
31
+ expect(roles[0].binding).toMatchObject({ source: "default" });
32
+ });
33
+
34
+ it("saves a role and shows it back", async () => {
35
+ const res = await put({
36
+ scope: "settings",
37
+ roles: { analyze: "anthropic/claude-sonnet-5-5" },
38
+ });
39
+ expect(res.status).toBe(200);
40
+ const { roles } = await res.json();
41
+ expect(roles.find((r: { id: string }) => r.id === "analyst")).toMatchObject(
42
+ {
43
+ value: "anthropic/claude-sonnet-5-5",
44
+ binding: { source: "settings" },
45
+ }
46
+ );
47
+ });
48
+
49
+ it("refuses a request that isn't from this machine or the tailnet", async () => {
50
+ expect((await put({ scope: "settings", roles: {} }, false)).status).toBe(
51
+ 403
52
+ );
53
+ });
54
+
55
+ it("refuses a Pi model for a role that can't run it", async () => {
56
+ const res = await put({
57
+ scope: "settings",
58
+ roles: { orchestrator: "pi/local/qwen3-27b" },
59
+ });
60
+ expect(res.status).toBe(400);
61
+ });
62
+
63
+ it("404s an unknown project and 400s a bad scope", async () => {
64
+ expect((await get("?scope=project&id=nope")).status).toBe(404);
65
+ expect((await get("?scope=galaxy")).status).toBe(400);
66
+ });
67
+ });
@@ -0,0 +1,61 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { z } from "zod";
3
+ import { agentRolesView, saveAgentRoles } from "@/lib/models/agent-roles-view";
4
+ import { roleMapSchema } from "@/lib/models/config";
5
+ import { getProject } from "@/lib/projects";
6
+ import { getWorkspace } from "@/lib/workspaces";
7
+ import { requireLocalTrust } from "@/lib/security/route-guard";
8
+
9
+ const scopeQuery = z.object({
10
+ scope: z.enum(["settings", "workspace", "project"]).default("settings"),
11
+ id: z.string().min(1).optional(),
12
+ });
13
+
14
+ function known({ scope, id }: z.infer<typeof scopeQuery>): string | null {
15
+ if (scope === "settings") return null;
16
+ if (!id) return `${scope} needs an id`;
17
+ if (scope === "workspace" && !getWorkspace(id)) return "Workspace not found";
18
+ if (scope === "project" && !getProject(id)) return "Project not found";
19
+ return null;
20
+ }
21
+
22
+ // GET /api/agent-roles?scope=settings|workspace|project&id= - the four
23
+ // agent roles and the internal ones: what's saved at the scope, the model in
24
+ // force and where it came from, and the model's locality and availability.
25
+ export async function GET(request: NextRequest) {
26
+ const q = scopeQuery.safeParse(
27
+ Object.fromEntries(request.nextUrl.searchParams)
28
+ );
29
+ if (!q.success)
30
+ return NextResponse.json({ error: "Bad scope" }, { status: 400 });
31
+ const missing = known(q.data);
32
+ if (missing) return NextResponse.json({ error: missing }, { status: 404 });
33
+ return NextResponse.json(agentRolesView(q.data));
34
+ }
35
+
36
+ const body = scopeQuery.extend({
37
+ roles: roleMapSchema,
38
+ fallbacks: roleMapSchema.optional(),
39
+ });
40
+
41
+ // PUT /api/agent-roles {scope, id?, roles, fallbacks?} - replaces the roles
42
+ // saved at the scope. Only from this machine or the tailnet, like Settings.
43
+ export async function PUT(request: NextRequest) {
44
+ const refused = requireLocalTrust(request);
45
+ if (refused) return refused;
46
+ try {
47
+ const b = body.parse(await request.json());
48
+ const missing = known(b);
49
+ if (missing) return NextResponse.json({ error: missing }, { status: 404 });
50
+ saveAgentRoles(b, b.roles, b.fallbacks);
51
+ return NextResponse.json(agentRolesView(b));
52
+ } catch (error) {
53
+ const message =
54
+ error instanceof z.ZodError
55
+ ? error.issues.map((i) => i.message).join("; ")
56
+ : error instanceof Error
57
+ ? error.message
58
+ : String(error);
59
+ return NextResponse.json({ error: message }, { status: 400 });
60
+ }
61
+ }
@@ -0,0 +1,15 @@
1
+ import { z } from "zod";
2
+ import { askAgain } from "@/lib/analyst";
3
+ import { analystRoute } from "@/lib/analyst/route";
4
+
5
+ type Ctx = { params: Promise<{ id: string; recId: string }> };
6
+
7
+ // POST /api/analyst/:id/recommendations/:recId/again - Ask again, for a
8
+ // reply that carried no valid recommendation.
9
+ export async function POST(request: Request, { params }: Ctx) {
10
+ const { id, recId } = await params;
11
+ return analystRoute(request, z.object({}), async () => {
12
+ await askAgain(id, recId);
13
+ return { ok: true };
14
+ });
15
+ }
@@ -0,0 +1,13 @@
1
+ import { z } from "zod";
2
+ import { dismissRecommendation } from "@/lib/analyst";
3
+ import { analystRoute } from "@/lib/analyst/route";
4
+
5
+ type Ctx = { params: Promise<{ id: string; recId: string }> };
6
+
7
+ // POST /api/analyst/:id/recommendations/:recId/dismiss - 409 once decided.
8
+ export async function POST(request: Request, { params }: Ctx) {
9
+ const { id, recId } = await params;
10
+ return analystRoute(request, z.object({}), () => ({
11
+ recommendation: dismissRecommendation(id, recId),
12
+ }));
13
+ }
@@ -0,0 +1,13 @@
1
+ import { sendRecommendation, sendSchema } from "@/lib/analyst";
2
+ import { analystRoute } from "@/lib/analyst/route";
3
+
4
+ type Ctx = { params: Promise<{ id: string; recId: string }> };
5
+
6
+ // POST /api/analyst/:id/recommendations/:recId/send { text? } - Send to
7
+ // Coding Agent, edited when text is given. 409 once decided.
8
+ export async function POST(request: Request, { params }: Ctx) {
9
+ const { id, recId } = await params;
10
+ return analystRoute(request, sendSchema, async (b) => ({
11
+ recommendation: await sendRecommendation(id, recId, b.text),
12
+ }));
13
+ }
@@ -0,0 +1,22 @@
1
+ import { z } from "zod";
2
+ import { AnalystError, getLinkOrThrow, pushDigest } from "@/lib/analyst";
3
+ import { analystRoute } from "@/lib/analyst/route";
4
+
5
+ type Ctx = { params: Promise<{ id: string }> };
6
+
7
+ // POST /api/analyst/:id/review - Review now: the turns since the last
8
+ // review, or the last turn again.
9
+ export async function POST(request: Request, { params }: Ctx) {
10
+ const { id } = await params;
11
+ return analystRoute(request, z.object({}), async () => {
12
+ const link = getLinkOrThrow(id);
13
+ if (link.endedAt) throw new AnalystError("This pairing has ended", 409);
14
+ const result = await pushDigest(id, { force: true });
15
+ if (result === "nothing")
16
+ throw new AnalystError(
17
+ "The coding agent hasn't finished a turn yet",
18
+ 409
19
+ );
20
+ return { result };
21
+ });
22
+ }
@@ -0,0 +1,44 @@
1
+ import { z } from "zod";
2
+ import {
3
+ AnalystError,
4
+ endLink,
5
+ linkView,
6
+ patchLinkSchema,
7
+ setPaused,
8
+ } from "@/lib/analyst";
9
+ import { analystRoute, run } from "@/lib/analyst/route";
10
+
11
+ type Ctx = { params: Promise<{ id: string }> };
12
+
13
+ const view = (id: string) => {
14
+ const link = linkView(id);
15
+ if (!link) throw new AnalystError("Pairing not found", 404);
16
+ return { link };
17
+ };
18
+
19
+ // GET /api/analyst/:id - the pairing, both sessions and its latest cards.
20
+ export async function GET(_request: Request, { params }: Ctx) {
21
+ const { id } = await params;
22
+ return run(() => view(id));
23
+ }
24
+
25
+ // PATCH /api/analyst/:id { paused } - stop or resume sending turns.
26
+ export async function PATCH(request: Request, { params }: Ctx) {
27
+ const { id } = await params;
28
+ return analystRoute(request, patchLinkSchema, (b) => {
29
+ setPaused(id, b.paused);
30
+ return view(id);
31
+ });
32
+ }
33
+
34
+ const unlink = z.object({ endAnalyst: z.boolean().optional() });
35
+
36
+ // DELETE /api/analyst/:id { endAnalyst? } - unlink; the analyst is settled
37
+ // unless endAnalyst is false. History is kept.
38
+ export async function DELETE(request: Request, { params }: Ctx) {
39
+ const { id } = await params;
40
+ return analystRoute(request, unlink, (b) => {
41
+ endLink(id, b);
42
+ return view(id);
43
+ });
44
+ }
@@ -0,0 +1,10 @@
1
+ import { createLink, createLinkSchema, linkView } from "@/lib/analyst";
2
+ import { analystRoute } from "@/lib/analyst/route";
3
+
4
+ // POST /api/analyst { coderSessionId | newCoder, analystModel?, reviewLast? }
5
+ // - pairs an analyst chat with a coding chat (a new one with newCoder).
6
+ export async function POST(request: Request) {
7
+ return analystRoute(request, createLinkSchema, async (b) => ({
8
+ link: linkView((await createLink(b)).id),
9
+ }));
10
+ }
@@ -5,7 +5,8 @@ import { startOrQueue, stampPriority } from "@/lib/tasks/queue";
5
5
  import { isPriority } from "@/lib/tasks/schedule-types";
6
6
  import { hostIdNamed } from "@/lib/hosts";
7
7
  import { resolveGroup } from "@/lib/session-groups";
8
- import { startView } from "@/lib/sessions/launch";
8
+ import { launchSession, startView } from "@/lib/sessions/launch";
9
+ import { isAgentRole } from "@/lib/models/agent-roles";
9
10
  import { callerSession, starterOf } from "@/lib/tasks/starter";
10
11
  import { callingDevice } from "@/lib/security/device-me";
11
12
  import {
@@ -35,6 +36,7 @@ export async function POST(request: NextRequest) {
35
36
  cwd,
36
37
  piece,
37
38
  group,
39
+ agentRole,
38
40
  } = await request.json();
39
41
  // Whoever called: a session (by `from`, else the worktree it runs in) or a person.
40
42
  const starter = starterOf(
@@ -103,6 +105,25 @@ export async function POST(request: NextRequest) {
103
105
  ? NextResponse.json({ queued: out.queued, builds }, { status: 202 })
104
106
  : NextResponse.json({ session: out.started, builds }, { status: 201 });
105
107
  }
108
+ if (agentRole !== undefined) {
109
+ if (mode !== "session") throw new Error("--role works for spawn only");
110
+ if (!isAgentRole(agentRole))
111
+ throw new Error("--role is developer, analyst, reviewer or supervisor");
112
+ const { session } = await launchSession({
113
+ projectId: target.id,
114
+ agentType: "claude",
115
+ prompt,
116
+ name: given,
117
+ model: typeof model === "string" ? model : null,
118
+ autoApprove: true,
119
+ startedBy,
120
+ startedVia: starter.startedVia,
121
+ parentSessionId: starter.parentSessionId,
122
+ groupId,
123
+ agentRole,
124
+ });
125
+ return NextResponse.json({ session }, { status: 201 });
126
+ }
106
127
  const session = await spawnSession({
107
128
  project: String(project ?? ""),
108
129
  prompt,
@@ -0,0 +1,17 @@
1
+ import { NextResponse } from "next/server";
2
+ import { db } from "@/lib/db";
3
+ import { loopSummary } from "@/lib/loops";
4
+
5
+ // GET /api/loops - the pill for every task that has had a loop in the last
6
+ // day, by task id.
7
+ export async function GET() {
8
+ const since = Date.now() - 24 * 60 * 60 * 1000;
9
+ const ids = db
10
+ .prepare(
11
+ `SELECT DISTINCT session_id FROM task_loops WHERE state = 'running' OR COALESCE(ended_at, 0) >= ?`
12
+ )
13
+ .all(since) as { session_id: string }[];
14
+ const loops: Record<string, ReturnType<typeof loopSummary>> = {};
15
+ for (const { session_id } of ids) loops[session_id] = loopSummary(session_id);
16
+ return NextResponse.json({ loops });
17
+ }
@@ -0,0 +1,9 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { modelAvailability } from "@/lib/models/availability-refs";
3
+
4
+ // GET /api/models/availability?refresh=1 - whether each local model's
5
+ // server answers now (ref to state). Cloud models aren't probed.
6
+ export async function GET(request: NextRequest) {
7
+ const refresh = request.nextUrl.searchParams.has("refresh");
8
+ return NextResponse.json(await modelAvailability(refresh));
9
+ }
@@ -0,0 +1,9 @@
1
+ import { NextResponse } from "next/server";
2
+ import { modelCatalog } from "@/lib/models/catalog";
3
+
4
+ // GET /api/models/choices - every model a picker offers, from the server:
5
+ // the enabled providers' models and the ones Pi reaches, each with its
6
+ // locality (local or cloud) and the last availability check.
7
+ export async function GET() {
8
+ return NextResponse.json({ choices: modelCatalog() });
9
+ }
@@ -0,0 +1,11 @@
1
+ import { sessionLinkView } from "@/lib/analyst";
2
+ import { run } from "@/lib/analyst/route";
3
+
4
+ type Ctx = { params: Promise<{ id: string }> };
5
+
6
+ // GET /api/sessions/:id/analyst - the live pairing this session is in, as
7
+ // coder or analyst, or null.
8
+ export async function GET(_request: Request, { params }: Ctx) {
9
+ const { id } = await params;
10
+ return run(() => ({ link: sessionLinkView(id) }));
11
+ }
@@ -0,0 +1,38 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { db } from "@/lib/db";
3
+ import { crewMembers } from "@/lib/sessions/crew";
4
+ import { modelCatalog } from "@/lib/models/catalog";
5
+
6
+ type RouteParams = { params: Promise<{ id: string }> };
7
+
8
+ // GET /api/sessions/:id/crew - the session's role and the sessions it works
9
+ // with (same crew), each with its model and whether that is local or cloud.
10
+ export async function GET(_request: NextRequest, { params }: RouteParams) {
11
+ const { id } = await params;
12
+ const row = db
13
+ .prepare(`SELECT agent_role, crew_id FROM session_rows WHERE id = ?`)
14
+ .get(id) as
15
+ { agent_role: string | null; crew_id: string | null } | undefined;
16
+ if (!row)
17
+ return NextResponse.json({ error: "Session not found" }, { status: 404 });
18
+ const choices = new Map(modelCatalog().map((c) => [c.ref, c]));
19
+ const members = row.crew_id
20
+ ? crewMembers(row.crew_id).map((m) => {
21
+ const ref = m.model
22
+ ? `${m.agentType === "pi" ? "pi" : (m.modelProvider ?? "anthropic")}/${m.model}`
23
+ : null;
24
+ const choice = ref ? choices.get(ref) : undefined;
25
+ return {
26
+ ...m,
27
+ ref,
28
+ locality: choice?.locality ?? "cloud",
29
+ availability: choice?.availability.state ?? "unknown",
30
+ };
31
+ })
32
+ : [];
33
+ return NextResponse.json({
34
+ agentRole: row.agent_role,
35
+ crewId: row.crew_id,
36
+ members,
37
+ });
38
+ }
@@ -0,0 +1,23 @@
1
+ import { NextResponse } from "next/server";
2
+ import { parseExportRequest } from "@/lib/export/request";
3
+ import { estimate } from "@/lib/export/service";
4
+ import { readJson, refuseDemo, toResponse } from "@/lib/share/route";
5
+
6
+ // POST /api/sessions/:id/export/estimate - what an export would hold: size,
7
+ // tokens, the turns to pick from, and warnings. No file text.
8
+ export async function POST(
9
+ request: Request,
10
+ { params }: { params: Promise<{ id: string }> }
11
+ ) {
12
+ const refused = refuseDemo();
13
+ if (refused) return refused;
14
+ const read = await readJson(request);
15
+ if ("refused" in read) return read.refused;
16
+ try {
17
+ const { id } = await params;
18
+ const req = parseExportRequest(read.body);
19
+ return NextResponse.json(await estimate(id, req));
20
+ } catch (err) {
21
+ return toResponse(err);
22
+ }
23
+ }
@@ -0,0 +1,125 @@
1
+ import { randomUUID } from "crypto";
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+ import { tempDir } from "@/lib/test-temp";
4
+
5
+ process.env.AGENTOS_ARTIFACTS_DIR = tempDir("agentos-export-art-");
6
+ const file = await import("./route");
7
+ const estimate = await import("./estimate/route");
8
+ const { saveItem } = await import("@/lib/chat/store");
9
+ const { seedSession } = await import("@/lib/orchestrator/testing");
10
+ const { createProject } = await import("@/lib/projects");
11
+
12
+ const secret = ["gh", "p_", "c".repeat(36)].join("");
13
+ const body = {
14
+ format: "md",
15
+ mode: "compact",
16
+ redact: "all",
17
+ };
18
+
19
+ const post = (b: unknown) =>
20
+ new Request("http://localhost/x", {
21
+ method: "POST",
22
+ headers: { "content-type": "application/json" },
23
+ body: JSON.stringify(b),
24
+ });
25
+ const params = (id: string) => ({ params: Promise.resolve({ id }) });
26
+
27
+ function session(view: "chat" | "terminal" = "chat") {
28
+ const project = createProject({
29
+ name: `p-${randomUUID().slice(0, 6)}`,
30
+ workingDirectory: "/tmp/p",
31
+ });
32
+ const id = seedSession({ projectId: project.id, name: "Export me", view });
33
+ let t = 1;
34
+ const add = (i: Record<string, unknown>) =>
35
+ saveItem(id, { id: `x${t}`, createdAt: t++, ...i } as never);
36
+ add({ kind: "user", text: `key ${secret}` });
37
+ add({ kind: "assistant", text: "ok" });
38
+ add({ kind: "user", text: "second" });
39
+ add({ kind: "assistant", text: "done" });
40
+ return id;
41
+ }
42
+
43
+ afterEach(() => vi.unstubAllEnvs());
44
+
45
+ describe("POST /api/sessions/:id/export", () => {
46
+ it("returns each format as a named download with size headers", async () => {
47
+ const id = session();
48
+ for (const [format, type, ext] of [
49
+ ["md", "text/markdown", "md"],
50
+ ["json", "application/json", "json"],
51
+ ["html", "text/html", "html"],
52
+ ]) {
53
+ const res = await file.POST(post({ ...body, format }), params(id));
54
+ expect(res.status).toBe(200);
55
+ expect(res.headers.get("content-type")).toContain(type);
56
+ expect(res.headers.get("content-disposition")).toBe(
57
+ `attachment; filename="export-me-all.${ext}"`
58
+ );
59
+ expect(res.headers.get("cache-control")).toBe("no-store");
60
+ const text = await res.text();
61
+ expect(Number(res.headers.get("x-export-bytes"))).toBe(
62
+ Buffer.byteLength(text)
63
+ );
64
+ expect(Number(res.headers.get("x-export-tokens"))).toBeGreaterThan(0);
65
+ expect(text).not.toContain(secret);
66
+ }
67
+ });
68
+
69
+ it("exports a range and names it", async () => {
70
+ const id = session();
71
+ const res = await file.POST(
72
+ post({ ...body, range: { from: 2, to: 2 } }),
73
+ params(id)
74
+ );
75
+ expect(res.headers.get("content-disposition")).toContain("-turn-2.md");
76
+ const text = await res.text();
77
+ expect(text).toContain("second");
78
+ expect(text).not.toContain("## Turn 1");
79
+ });
80
+
81
+ it("404s an unknown session and 400s a terminal one", async () => {
82
+ expect((await file.POST(post(body), params("nope"))).status).toBe(404);
83
+ const res = await file.POST(post(body), params(session("terminal")));
84
+ expect(res.status).toBe(400);
85
+ });
86
+
87
+ it("400s a bad format, mode, redaction or range", async () => {
88
+ const id = session();
89
+ for (const bad of [
90
+ { ...body, format: "pdf" },
91
+ { ...body, mode: "tiny" },
92
+ { ...body, redact: "none" },
93
+ { ...body, range: { from: 0, to: 2 } },
94
+ { ...body, range: { from: 9, to: 12 } },
95
+ { ...body, range: { last: -1 } },
96
+ ])
97
+ expect((await file.POST(post(bad), params(id))).status).toBe(400);
98
+ });
99
+
100
+ it("refuses in the demo", async () => {
101
+ vi.stubEnv("AGENTOS_DEMO", "1");
102
+ const res = await file.POST(post(body), params(session()));
103
+ expect(res.status).toBe(403);
104
+ });
105
+ });
106
+
107
+ describe("POST /api/sessions/:id/export/estimate", () => {
108
+ it("returns sizes, turns and warnings, and no body text", async () => {
109
+ const id = session();
110
+ const res = await estimate.POST(post(body), params(id));
111
+ expect(res.status).toBe(200);
112
+ const text = await res.text();
113
+ const j = JSON.parse(text);
114
+ expect(j.turns.map((t: { n: number }) => t.n)).toEqual([1, 2]);
115
+ expect(j.selected).toMatchObject({ from: 1, to: 2, of: 2, turns: 2 });
116
+ expect(j.estimate.compact.bytes).toBeGreaterThan(0);
117
+ expect(j.estimate.full.bytes).toBeGreaterThan(0);
118
+ expect(j.hits.secret).toBe(1);
119
+ expect(j.warnings.map((w: { code: string }) => w.code)).toContain(
120
+ "sensitive"
121
+ );
122
+ expect(text).not.toContain("done");
123
+ expect(text).not.toContain(secret);
124
+ });
125
+ });
@@ -0,0 +1,34 @@
1
+ import { NextResponse } from "next/server";
2
+ import { parseExportRequest } from "@/lib/export/request";
3
+ import { buildFile } from "@/lib/export/service";
4
+ import { TOO_BIG, exportFileName } from "@/lib/export/view";
5
+ import { fail, readJson, refuseDemo, toResponse } from "@/lib/share/route";
6
+
7
+ // POST /api/sessions/:id/export - the session (or a range of its turns) as a
8
+ // Markdown, JSON or HTML file, redacted. Local work only.
9
+ export async function POST(
10
+ request: Request,
11
+ { params }: { params: Promise<{ id: string }> }
12
+ ) {
13
+ const refused = refuseDemo();
14
+ if (refused) return refused;
15
+ const read = await readJson(request);
16
+ if ("refused" in read) return read.refused;
17
+ try {
18
+ const { id } = await params;
19
+ const req = parseExportRequest(read.body);
20
+ const file = await buildFile(id, req);
21
+ if (file.tooBig) return fail(TOO_BIG, 413);
22
+ return new NextResponse(file.body, {
23
+ headers: {
24
+ "Content-Type": file.contentType,
25
+ "Content-Disposition": `attachment; filename="${exportFileName(file.title, file.slug, req.format)}"`,
26
+ "Cache-Control": "no-store",
27
+ "X-Export-Bytes": String(file.bytes),
28
+ "X-Export-Tokens": String(file.tokens),
29
+ },
30
+ });
31
+ } catch (err) {
32
+ return toResponse(err);
33
+ }
34
+ }
@@ -0,0 +1,49 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { db, type Session } from "@/lib/db";
3
+ import { setChatModel } from "@/lib/chat/settings";
4
+ import { providerFor, sessionRole } from "@/lib/models/resolve";
5
+ import { switchSessionModel } from "@/lib/models/session-model";
6
+ import { PI } from "@/lib/models/providers";
7
+ import { formatModelRef, parseModelRef } from "@/lib/models/roles";
8
+
9
+ // POST /api/sessions/:id/model {ref} - move a running session to another
10
+ // model ("provider/model", or "pi/<provider>/<model>" for a Pi session),
11
+ // keeping its conversation.
12
+ export async function POST(
13
+ request: NextRequest,
14
+ { params }: { params: Promise<{ id: string }> }
15
+ ) {
16
+ const { id } = await params;
17
+ const { ref } = (await request.json().catch(() => ({}))) as { ref?: unknown };
18
+ const session = db
19
+ .prepare(`SELECT * FROM session_rows WHERE id = ?`)
20
+ .get(id) as Session | undefined;
21
+ if (!session)
22
+ return NextResponse.json({ error: "Session not found" }, { status: 404 });
23
+ if (typeof ref !== "string" || !ref.trim())
24
+ return NextResponse.json({ error: "ref is required" }, { status: 400 });
25
+ try {
26
+ const parsed = parseModelRef(ref);
27
+ if (session.agent_type === "pi") {
28
+ if (parsed.provider !== PI)
29
+ throw new Error(
30
+ "A Pi session runs on a Pi model (pi/<provider>/<model>)"
31
+ );
32
+ await setChatModel(id, parsed.model);
33
+ } else {
34
+ if (parsed.provider === PI)
35
+ throw new Error("Only a Pi session can move to a Pi model");
36
+ await switchSessionModel(id, {
37
+ role: sessionRole(session),
38
+ ref: formatModelRef(parsed),
39
+ provider: providerFor(parsed.provider),
40
+ model: parsed.model,
41
+ source: "task",
42
+ });
43
+ }
44
+ return NextResponse.json({ ok: true });
45
+ } catch (error) {
46
+ const message = error instanceof Error ? error.message : String(error);
47
+ return NextResponse.json({ error: message }, { status: 400 });
48
+ }
49
+ }