@saadnvd1/agent-os 0.3.723 → 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 (116) hide show
  1. package/README.md +1 -0
  2. package/app/api/loops/route.ts +17 -0
  3. package/app/api/tasks/[id]/loop/cycles/[n]/log/route.ts +29 -0
  4. package/app/api/tasks/[id]/loop/draft-criteria/route.ts +17 -0
  5. package/app/api/tasks/[id]/loop/next/route.ts +25 -0
  6. package/app/api/tasks/[id]/loop/route.ts +72 -0
  7. package/app/api/tasks/[id]/loop/run/route.ts +12 -0
  8. package/app/api/tasks/[id]/loop/stop/route.ts +12 -0
  9. package/app/api/tasks/[id]/mode/route.ts +29 -0
  10. package/app/api/tasks/[id]/report/draft/route.ts +54 -0
  11. package/app/api/tasks/[id]/report/evidence/[evId]/route.ts +18 -0
  12. package/app/api/tasks/[id]/report/route.ts +39 -0
  13. package/app/api/workspaces/[id]/mode/route.ts +23 -0
  14. package/app/api/workspaces/[id]/reports/route.ts +17 -0
  15. package/app/page.tsx +8 -0
  16. package/components/Analyst/PairChip.tsx +131 -0
  17. package/components/Analyst/PairSwitch.tsx +76 -0
  18. package/components/Analyst/ReviewCard.tsx +272 -0
  19. package/components/Analyst/SignalChips.tsx +39 -0
  20. package/components/Analyst/StartAnalystSheet.tsx +109 -0
  21. package/components/Analyst/cardState.test.ts +109 -0
  22. package/components/Analyst/cardState.ts +95 -0
  23. package/components/Analyst/pair.ts +81 -0
  24. package/components/Chat/Code.tsx +3 -0
  25. package/components/Chat/Messages.tsx +6 -1
  26. package/components/Chat/itemContext.ts +14 -0
  27. package/components/Draft/DraftChips.tsx +10 -0
  28. package/components/Draft/DraftPanel.tsx +7 -0
  29. package/components/Pane/DesktopTabBar.tsx +6 -0
  30. package/components/Pane/MobileTabBar.tsx +17 -0
  31. package/components/Pane/MobileViewPicker.tsx +4 -0
  32. package/components/Pane/PaneMenu.test.ts +21 -0
  33. package/components/Pane/PaneMenu.tsx +16 -0
  34. package/components/Pane/PaneViewToggle.tsx +12 -0
  35. package/components/Pane/index.tsx +19 -0
  36. package/components/Report/DraftCard.tsx +89 -0
  37. package/components/Report/EstimateBar.tsx +51 -0
  38. package/components/Report/EvidenceSheet.tsx +98 -0
  39. package/components/Report/ModeChooser.tsx +43 -0
  40. package/components/Report/ReportView.tsx +289 -0
  41. package/components/SessionList/SessionRowMenu.tsx +6 -0
  42. package/components/Sessions/RolePill.tsx +7 -0
  43. package/components/Tasks/Loop/LoopButton.tsx +74 -0
  44. package/components/Tasks/Loop/LoopSetup.tsx +146 -0
  45. package/components/Tasks/Loop/LoopView.tsx +350 -0
  46. package/components/Tasks/Loop/labels.ts +17 -0
  47. package/components/Tasks/TaskRow.tsx +2 -0
  48. package/components/Workspaces/WorkspaceModeForm.tsx +22 -0
  49. package/components/Workspaces/WorkspaceSettings.tsx +2 -0
  50. package/components/ui/segmented.tsx +55 -0
  51. package/components/ui/status-pill.tsx +3 -0
  52. package/contexts/PaneContext.tsx +21 -1
  53. package/data/loops/index.ts +3 -0
  54. package/data/loops/keys.ts +4 -0
  55. package/data/loops/mutations.ts +68 -0
  56. package/data/loops/queries.ts +40 -0
  57. package/data/push/topics.ts +6 -0
  58. package/data/reports/index.ts +115 -0
  59. package/lib/analyst/forward.ts +1 -1
  60. package/lib/db/changes.ts +7 -0
  61. package/lib/db/migrations/20261021T1600_task_loops.ts +81 -0
  62. package/lib/db/migrations/20261021T1800_health_reports.ts +38 -0
  63. package/lib/done/index.ts +2 -0
  64. package/lib/drafts.ts +2 -0
  65. package/lib/loops/analyse.ts +46 -0
  66. package/lib/loops/analyst-prompt.ts +203 -0
  67. package/lib/loops/checkCriteria.ts +114 -0
  68. package/lib/loops/compactEvidence.ts +140 -0
  69. package/lib/loops/controls.ts +317 -0
  70. package/lib/loops/decideStop.ts +113 -0
  71. package/lib/loops/deps.ts +128 -0
  72. package/lib/loops/diffMap.ts +39 -0
  73. package/lib/loops/draftCriteria.ts +71 -0
  74. package/lib/loops/gatherEvidence.ts +265 -0
  75. package/lib/loops/hooks.ts +40 -0
  76. package/lib/loops/index.ts +7 -0
  77. package/lib/loops/route.ts +83 -0
  78. package/lib/loops/runCycle.ts +323 -0
  79. package/lib/loops/screenInstruction.ts +59 -0
  80. package/lib/loops/sendInstruction.ts +83 -0
  81. package/lib/loops/snapshot.ts +38 -0
  82. package/lib/loops/stopLoop.ts +86 -0
  83. package/lib/loops/store.ts +481 -0
  84. package/lib/loops/summary.ts +27 -0
  85. package/lib/loops/tickLoops.ts +141 -0
  86. package/lib/loops/types.ts +223 -0
  87. package/lib/loops/world.test-helpers.ts +223 -0
  88. package/lib/models/plan-step.ts +4 -1
  89. package/lib/orchestrator/digest.ts +2 -1
  90. package/lib/orchestrator/loop-guard.ts +5 -2
  91. package/lib/orchestrator/pr-wake.ts +4 -0
  92. package/lib/orchestrator/watcher.ts +8 -1
  93. package/lib/panes.ts +52 -0
  94. package/lib/plans/acceptance.ts +23 -0
  95. package/lib/reports/drafts.ts +87 -0
  96. package/lib/reports/end-hook.ts +17 -0
  97. package/lib/reports/estimate.ts +107 -0
  98. package/lib/reports/evidence/build.ts +193 -0
  99. package/lib/reports/evidence/gather.ts +229 -0
  100. package/lib/reports/fixtures.ts +17 -0
  101. package/lib/reports/generate.ts +159 -0
  102. package/lib/reports/hooks.ts +30 -0
  103. package/lib/reports/mode.ts +65 -0
  104. package/lib/reports/model.ts +115 -0
  105. package/lib/reports/read.ts +26 -0
  106. package/lib/reports/store.ts +180 -0
  107. package/lib/reports/template.ts +117 -0
  108. package/lib/reports/types.ts +135 -0
  109. package/lib/reports/validate.ts +142 -0
  110. package/lib/sidebar/row-menu.ts +9 -0
  111. package/lib/tasks/contract/routes.ts +5 -0
  112. package/lib/tasks/contract/types.ts +3 -0
  113. package/lib/tasks/finish.ts +2 -0
  114. package/lib/tasks/move-carry.ts +1 -0
  115. package/package.json +2 -2
  116. package/stores/analystUi.ts +19 -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)
@@ -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,29 @@
1
+ import { readFile } from "fs/promises";
2
+ import { NextRequest } from "next/server";
3
+ import { loopLogPath } from "@/lib/loops/gatherEvidence";
4
+ import { latestLoopFor } from "@/lib/loops/store";
5
+ import { loopRoute } from "@/lib/loops/route";
6
+ import { LoopError } from "@/lib/loops/types";
7
+ import { redact } from "@/lib/models/secrets";
8
+
9
+ const MAX = 64 * 1024;
10
+
11
+ // GET /api/tasks/:id/loop/cycles/:n/log - the tail of that cycle's checks
12
+ // output, redacted and capped.
13
+ export async function GET(
14
+ request: NextRequest,
15
+ ctx: { params: Promise<{ id: string; n: string }> }
16
+ ) {
17
+ const n = Number((await ctx.params).n);
18
+ return loopRoute(request, ctx, {}, async (task) => {
19
+ if (!latestLoopFor(task.id) || !Number.isInteger(n) || n < 1)
20
+ throw new LoopError("No such cycle", 404);
21
+ let text = "";
22
+ try {
23
+ text = await readFile(loopLogPath(task.id, n), "utf8");
24
+ } catch {
25
+ throw new LoopError("No log for that cycle", 404);
26
+ }
27
+ return { log: redact(text.length > MAX ? text.slice(-MAX) : text) };
28
+ });
29
+ }
@@ -0,0 +1,17 @@
1
+ import { NextRequest } from "next/server";
2
+ import { draftCriteria } from "@/lib/loops";
3
+ import { loopRoute } from "@/lib/loops/route";
4
+ import { LoopError } from "@/lib/loops/types";
5
+
6
+ // POST /api/tasks/:id/loop/draft-criteria - a drafted checklist from the
7
+ // card or the task's text; nothing is saved.
8
+ export async function POST(
9
+ request: NextRequest,
10
+ ctx: { params: Promise<{ id: string }> }
11
+ ) {
12
+ return loopRoute(request, ctx, {}, async (task) => {
13
+ if (!task.worktree_path)
14
+ throw new LoopError("The task has no worktree", 409);
15
+ return { criteria: await draftCriteria(task, task.worktree_path) };
16
+ });
17
+ }
@@ -0,0 +1,25 @@
1
+ import { NextRequest } from "next/server";
2
+ import { z } from "zod";
3
+ import { actOnNext } from "@/lib/loops";
4
+ import { loopRoute } from "@/lib/loops/route";
5
+
6
+ const schema = z.object({
7
+ action: z.enum(["send", "hold", "skip"]),
8
+ instruction: z.string().max(8000).optional(),
9
+ });
10
+
11
+ // POST /api/tasks/:id/loop/next - what to do with the instruction in its
12
+ // countdown: send it (edited or not), hold the countdown, or skip the cycle.
13
+ export async function POST(
14
+ request: NextRequest,
15
+ ctx: { params: Promise<{ id: string }> }
16
+ ) {
17
+ return loopRoute(request, ctx, { schema }, async (task, body) => ({
18
+ view: await actOnNext(
19
+ task.id,
20
+ body.action === "send"
21
+ ? { action: "send", instruction: body.instruction }
22
+ : { action: body.action }
23
+ ),
24
+ }));
25
+ }
@@ -0,0 +1,72 @@
1
+ import { NextRequest } from "next/server";
2
+ import { z } from "zod";
3
+ import { editLoop, loopView, startLoop } from "@/lib/loops";
4
+ import { criterionSchema, limitsSchema, loopRoute } from "@/lib/loops/route";
5
+
6
+ type Ctx = { params: Promise<{ id: string }> };
7
+
8
+ // GET /api/tasks/:id/loop - the loop, its checklist and its newest cycles;
9
+ // ?before=<n> pages back.
10
+ export async function GET(request: NextRequest, ctx: Ctx) {
11
+ const before = Number(request.nextUrl.searchParams.get("before"));
12
+ return loopRoute(request, ctx, {}, (task) => ({
13
+ view: loopView(task.id, undefined, {
14
+ before: Number.isFinite(before) && before > 0 ? before : undefined,
15
+ }),
16
+ }));
17
+ }
18
+
19
+ const startSchema = z.object({
20
+ criteria: z.array(criterionSchema).max(60),
21
+ limits: limitsSchema.optional(),
22
+ });
23
+
24
+ // POST /api/tasks/:id/loop - start a loop with its criteria and limits.
25
+ export async function POST(request: NextRequest, ctx: Ctx) {
26
+ return loopRoute(
27
+ request,
28
+ ctx,
29
+ { schema: startSchema },
30
+ async (task, body) => ({
31
+ view: await startLoop(task.id, body),
32
+ })
33
+ );
34
+ }
35
+
36
+ const editSchema = z.object({
37
+ limits: limitsSchema.optional(),
38
+ extend: z
39
+ .object({
40
+ cycles: z.number().optional(),
41
+ costUsd: z.number().optional(),
42
+ minutes: z.number().optional(),
43
+ })
44
+ .optional(),
45
+ addCriteria: z.array(criterionSchema).optional(),
46
+ editCriteria: z
47
+ .array(z.object({ id: z.number(), text: z.string() }))
48
+ .optional(),
49
+ removeCriteria: z.array(z.number()).optional(),
50
+ waive: z.array(z.number()).optional(),
51
+ });
52
+
53
+ // PATCH /api/tasks/:id/loop - limits, an extension, or the checklist.
54
+ // Raising the spend and waiving a criterion need "Can approve".
55
+ export async function PATCH(request: NextRequest, ctx: Ctx) {
56
+ return loopRoute(
57
+ request,
58
+ ctx,
59
+ {
60
+ schema: editSchema,
61
+ approver: (b) =>
62
+ !!(
63
+ b.extend ||
64
+ b.waive?.length ||
65
+ (b.limits && b.limits.maxCostUsd !== undefined)
66
+ ),
67
+ },
68
+ (task, body, device) => ({
69
+ view: editLoop(task.id, body, device ? "a device" : "you"),
70
+ })
71
+ );
72
+ }
@@ -0,0 +1,12 @@
1
+ import { NextRequest } from "next/server";
2
+ import { runNow } from "@/lib/loops";
3
+ import { loopRoute } from "@/lib/loops/route";
4
+
5
+ // POST /api/tasks/:id/loop/run - a cycle at the next tick; resumes a loop
6
+ // that stopped for a reason other than done.
7
+ export async function POST(
8
+ request: NextRequest,
9
+ ctx: { params: Promise<{ id: string }> }
10
+ ) {
11
+ return loopRoute(request, ctx, {}, (task) => ({ view: runNow(task.id) }));
12
+ }
@@ -0,0 +1,12 @@
1
+ import { NextRequest } from "next/server";
2
+ import { stopByUser } from "@/lib/loops";
3
+ import { loopRoute } from "@/lib/loops/route";
4
+
5
+ // POST /api/tasks/:id/loop/stop - end the loop; the coder's turn, if one
6
+ // is running, is left alone.
7
+ export async function POST(
8
+ request: NextRequest,
9
+ ctx: { params: Promise<{ id: string }> }
10
+ ) {
11
+ return loopRoute(request, ctx, {}, (task) => ({ view: stopByUser(task.id) }));
12
+ }
@@ -0,0 +1,29 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { refuse } from "@/lib/tasks/contract/authorize";
3
+ import { modeOf, setTaskMode } from "@/lib/reports/mode";
4
+ import { isMode } from "@/lib/reports/types";
5
+
6
+ type Ctx = { params: Promise<{ id: string }> };
7
+
8
+ // GET /api/tasks/:id/mode - the operating mode in force and where it comes from.
9
+ export async function GET(request: NextRequest, { params }: Ctx) {
10
+ const { id } = await params;
11
+ const refused = refuse(request, id, "open");
12
+ if (refused) return refused;
13
+ return NextResponse.json(modeOf(id));
14
+ }
15
+
16
+ // PUT /api/tasks/:id/mode {mode} - this task's override; null follows its workspace.
17
+ export async function PUT(request: NextRequest, { params }: Ctx) {
18
+ const { id } = await params;
19
+ const refused = refuse(request, id, "plan");
20
+ if (refused) return refused;
21
+ const { mode } = await request.json().catch(() => ({}));
22
+ if (mode !== null && !isMode(mode))
23
+ return NextResponse.json(
24
+ { error: "mode is manual, assisted, autonomous or null" },
25
+ { status: 400 }
26
+ );
27
+ setTaskMode(id, mode);
28
+ return NextResponse.json(modeOf(id));
29
+ }
@@ -0,0 +1,54 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { refuse } from "@/lib/tasks/contract/authorize";
3
+ import { approveDraft, dismissDraft, DraftError } from "@/lib/reports/drafts";
4
+ import { AnalystError } from "@/lib/analyst/types";
5
+ import { readReport } from "@/lib/reports/read";
6
+
7
+ // POST /api/tasks/:id/report/draft {source, id, linkId?, action, text?} -
8
+ // send the drafted next prompt to the coding agent (text: the reader's
9
+ // edit), or dismiss it. 409 when it was already decided.
10
+ export async function POST(
11
+ request: NextRequest,
12
+ { params }: { params: Promise<{ id: string }> }
13
+ ) {
14
+ const { id: taskId } = await params;
15
+ const refused = refuse(request, taskId, "plan");
16
+ if (refused) return refused;
17
+ const b = await request.json().catch(() => ({}));
18
+ const source =
19
+ b.source === "analyst"
20
+ ? "analyst"
21
+ : b.source === "report"
22
+ ? "report"
23
+ : null;
24
+ if (
25
+ !source ||
26
+ typeof b.id !== "string" ||
27
+ !["send", "dismiss"].includes(b.action)
28
+ )
29
+ return NextResponse.json({ error: "Bad draft request" }, { status: 400 });
30
+ if (
31
+ b.text !== undefined &&
32
+ (typeof b.text !== "string" || b.text.length > 20_000)
33
+ )
34
+ return NextResponse.json({ error: "Bad text" }, { status: 400 });
35
+ const ref = {
36
+ source,
37
+ id: b.id,
38
+ linkId: typeof b.linkId === "string" ? b.linkId : undefined,
39
+ } as const;
40
+ try {
41
+ if (b.action === "send")
42
+ await approveDraft(taskId, { ...ref, text: b.text });
43
+ else dismissDraft(taskId, ref);
44
+ return NextResponse.json(readReport(taskId));
45
+ } catch (error) {
46
+ if (error instanceof DraftError || error instanceof AnalystError)
47
+ return NextResponse.json(
48
+ { error: error.message },
49
+ { status: error.status }
50
+ );
51
+ const message = error instanceof Error ? error.message : String(error);
52
+ return NextResponse.json({ error: message }, { status: 500 });
53
+ }
54
+ }
@@ -0,0 +1,18 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { refuse } from "@/lib/tasks/contract/authorize";
3
+ import { latestReport } from "@/lib/reports/store";
4
+
5
+ // GET /api/tasks/:id/report/evidence/:evId - one piece of evidence behind the
6
+ // latest report, for the drill-down. Its text is untrusted: show it only.
7
+ export async function GET(
8
+ request: NextRequest,
9
+ { params }: { params: Promise<{ id: string; evId: string }> }
10
+ ) {
11
+ const { id, evId } = await params;
12
+ const refused = refuse(request, id, "open");
13
+ if (refused) return refused;
14
+ const ev = latestReport(id)?.evidence.find((e) => e.id === evId);
15
+ if (!ev)
16
+ return NextResponse.json({ error: "No such evidence" }, { status: 404 });
17
+ return NextResponse.json({ evidence: ev });
18
+ }
@@ -0,0 +1,39 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { refuse } from "@/lib/tasks/contract/authorize";
3
+ import { generateReport } from "@/lib/reports/generate";
4
+ import { readReport } from "@/lib/reports/read";
5
+ import { historyOf } from "@/lib/reports/store";
6
+ import { db } from "@/lib/db";
7
+
8
+ type Ctx = { params: Promise<{ id: string }> };
9
+
10
+ const exists = (id: string) =>
11
+ !!db.prepare(`SELECT 1 FROM session_rows WHERE id = ?`).get(id);
12
+
13
+ // GET /api/tasks/:id/report - the latest plain-English health report, whether
14
+ // one is being built, and the task's operating mode. ?history=1 lists the
15
+ // earlier ones.
16
+ export async function GET(request: NextRequest, { params }: Ctx) {
17
+ const { id } = await params;
18
+ const refused = refuse(request, id, "open");
19
+ if (refused) return refused;
20
+ if (!exists(id))
21
+ return NextResponse.json({ error: "No such task" }, { status: 404 });
22
+ if (request.nextUrl.searchParams.get("history") === "1")
23
+ return NextResponse.json({ history: historyOf(id) });
24
+ return NextResponse.json(readReport(id));
25
+ }
26
+
27
+ // POST /api/tasks/:id/report - build a new report now. Answers at once; the
28
+ // client polls the GET until `generating` clears.
29
+ export async function POST(request: NextRequest, { params }: Ctx) {
30
+ const { id } = await params;
31
+ const refused = refuse(request, id, "plan");
32
+ if (refused) return refused;
33
+ if (!exists(id))
34
+ return NextResponse.json({ error: "No such task" }, { status: 404 });
35
+ generateReport(id, "on-demand").catch((error) =>
36
+ console.error(`Report ${id}:`, error)
37
+ );
38
+ return NextResponse.json({ generating: true }, { status: 202 });
39
+ }
@@ -0,0 +1,23 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { setWorkspaceMode, workspaceMode } from "@/lib/reports/mode";
3
+ import { isMode } from "@/lib/reports/types";
4
+
5
+ type Ctx = { params: Promise<{ id: string }> };
6
+
7
+ // GET /api/workspaces/:id/mode - the default operating mode of its tasks.
8
+ export async function GET(_request: NextRequest, { params }: Ctx) {
9
+ return NextResponse.json({ mode: workspaceMode((await params).id) });
10
+ }
11
+
12
+ // PUT /api/workspaces/:id/mode {mode}
13
+ export async function PUT(request: NextRequest, { params }: Ctx) {
14
+ const { id } = await params;
15
+ const { mode } = await request.json().catch(() => ({}));
16
+ if (!isMode(mode))
17
+ return NextResponse.json(
18
+ { error: "mode is manual, assisted or autonomous" },
19
+ { status: 400 }
20
+ );
21
+ setWorkspaceMode(id, mode);
22
+ return NextResponse.json({ mode });
23
+ }
@@ -0,0 +1,17 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { isFinished } from "@/lib/tasks/state";
3
+ import { latestByTask, summaryOf } from "@/lib/reports/store";
4
+
5
+ // GET /api/workspaces/:id/reports - the newest report of each task: headline,
6
+ // estimate and state. ?active=1 leaves out finished tasks.
7
+ export async function GET(
8
+ request: NextRequest,
9
+ { params }: { params: Promise<{ id: string }> }
10
+ ) {
11
+ const { id } = await params;
12
+ const active = request.nextUrl.searchParams.get("active") === "1";
13
+ const reports = latestByTask(id)
14
+ .filter(({ report }) => !active || !isFinished(report.state))
15
+ .map(({ report, name }) => summaryOf(report, name));
16
+ return NextResponse.json({ reports });
17
+ }
package/app/page.tsx CHANGED
@@ -142,6 +142,13 @@ const ExportSheet = dynamic(
142
142
  () => import("@/components/Export/ExportSheet").then((m) => m.ExportSheet),
143
143
  { ssr: false }
144
144
  );
145
+ const StartAnalystSheet = dynamic(
146
+ () =>
147
+ import("@/components/Analyst/StartAnalystSheet").then(
148
+ (m) => m.StartAnalystSheet
149
+ ),
150
+ { ssr: false }
151
+ );
145
152
  const CommandPalette = dynamic(
146
153
  () =>
147
154
  import("@/components/CommandPalette/CommandPalette").then(
@@ -171,6 +178,7 @@ const AppDialogs = memo(function AppDialogs({
171
178
  <MoveDialog />
172
179
  <ShareDialog />
173
180
  <ExportSheet />
181
+ <StartAnalystSheet />
174
182
  <AwayReport />
175
183
  <IncomingLinks />
176
184
  <CommandPalette />
@@ -0,0 +1,131 @@
1
+ "use client";
2
+
3
+ import { toast } from "sonner";
4
+ import { Eye, Loader2 } from "lucide-react";
5
+ import {
6
+ DropdownMenu,
7
+ DropdownMenuContent,
8
+ DropdownMenuItem,
9
+ DropdownMenuLabel,
10
+ DropdownMenuSeparator,
11
+ DropdownMenuTrigger,
12
+ } from "@/components/ui/dropdown-menu";
13
+ import { usePanes } from "@/contexts/PaneContext";
14
+ import {
15
+ useAskAgain,
16
+ useEndAnalyst,
17
+ useReviewNow,
18
+ useSessionAnalyst,
19
+ useSetAnalystPaused,
20
+ } from "@/data/analyst";
21
+ import type { Session } from "@/lib/db";
22
+ import { cn } from "@/lib/utils";
23
+ import {
24
+ PAIR_STATUS_LABEL,
25
+ pairStatus,
26
+ partnerOf,
27
+ paneTmux,
28
+ type PairStatus,
29
+ } from "./pair";
30
+
31
+ const DOT: Record<PairStatus, string> = {
32
+ paused: "bg-muted-foreground/50",
33
+ reviewing: "bg-primary animate-pulse",
34
+ recommendation: "bg-primary",
35
+ watching: "bg-green-500",
36
+ };
37
+
38
+ const fail = (e: Error) => toast.error(e.message);
39
+
40
+ // A paired chat's link to the other side, with the pairing's controls.
41
+ // Nothing for a session that isn't paired.
42
+ export function PairChip({
43
+ session,
44
+ paneId,
45
+ }: {
46
+ session: Session | null | undefined;
47
+ paneId: string;
48
+ }) {
49
+ const { data: link } = useSessionAnalyst(
50
+ session?.view === "chat" ? session.id : undefined
51
+ );
52
+ const { openBeside } = usePanes();
53
+ const id = link?.id ?? "";
54
+ const review = useReviewNow(id);
55
+ const pause = useSetAnalystPaused(id);
56
+ const end = useEndAnalyst(id);
57
+ const again = useAskAgain(id);
58
+ if (!session || !link || link.endedAt) return null;
59
+
60
+ const status = pairStatus(link);
61
+ const isCoder = link.coder.id === session.id;
62
+ const partner = partnerOf(link, session.id);
63
+ const latest = link.recommendations[0];
64
+
65
+ return (
66
+ <DropdownMenu>
67
+ <DropdownMenuTrigger asChild>
68
+ <button
69
+ type="button"
70
+ onClick={(e) => e.stopPropagation()}
71
+ title={PAIR_STATUS_LABEL[status]}
72
+ aria-label={`${isCoder ? "Analyst" : "Reviewing"} ${partner.name}: ${PAIR_STATUS_LABEL[status]}`}
73
+ className="ring-border/70 bg-muted/60 hover:bg-accent flex h-7 max-w-56 shrink-0 items-center gap-1.5 rounded-full px-2.5 text-xs ring-1 ring-inset"
74
+ >
75
+ <Eye className="text-muted-foreground h-3.5 w-3.5 shrink-0" />
76
+ <span className="truncate">
77
+ {isCoder ? "Analyst" : "Coding agent"}
78
+ </span>
79
+ <span
80
+ className={cn("h-1.5 w-1.5 shrink-0 rounded-full", DOT[status])}
81
+ />
82
+ </button>
83
+ </DropdownMenuTrigger>
84
+ <DropdownMenuContent align="start" className="w-60">
85
+ <DropdownMenuLabel className="space-y-0.5">
86
+ <div className="text-muted-foreground text-[11px] font-normal">
87
+ {isCoder ? "Reviewed by" : "Reviewing"} ·{" "}
88
+ {PAIR_STATUS_LABEL[status]}
89
+ </div>
90
+ <div className="truncate text-sm">{partner.name}</div>
91
+ </DropdownMenuLabel>
92
+ <DropdownMenuItem
93
+ onSelect={() =>
94
+ openBeside(paneId, {
95
+ sessionId: partner.id,
96
+ tmux: paneTmux(partner),
97
+ })
98
+ }
99
+ >
100
+ Open {isCoder ? "analyst" : "coding agent"} beside
101
+ </DropdownMenuItem>
102
+ <DropdownMenuItem
103
+ disabled={review.isPending || !!link.pendingDigestId}
104
+ onSelect={() => review.mutate(undefined, { onError: fail })}
105
+ >
106
+ {review.isPending && <Loader2 className="animate-spin" />}
107
+ {link.pendingDigestId ? "Reviewing…" : "Review now"}
108
+ </DropdownMenuItem>
109
+ {latest?.state === "invalid" && (
110
+ <DropdownMenuItem
111
+ onSelect={() => again.mutate(latest.id, { onError: fail })}
112
+ >
113
+ Ask for the review again
114
+ </DropdownMenuItem>
115
+ )}
116
+ <DropdownMenuItem
117
+ onSelect={() => pause.mutate(!link.paused, { onError: fail })}
118
+ >
119
+ {link.paused ? "Resume reviews" : "Pause reviews"}
120
+ </DropdownMenuItem>
121
+ <DropdownMenuSeparator />
122
+ <DropdownMenuItem
123
+ variant="destructive"
124
+ onSelect={() => end.mutate(true, { onError: fail })}
125
+ >
126
+ Unlink and finish the analyst
127
+ </DropdownMenuItem>
128
+ </DropdownMenuContent>
129
+ </DropdownMenu>
130
+ );
131
+ }
@@ -0,0 +1,76 @@
1
+ "use client";
2
+
3
+ import { usePanes } from "@/contexts/PaneContext";
4
+ import { useSessionAnalyst } from "@/data/analyst";
5
+ import type { Session } from "@/lib/db";
6
+ import { cn } from "@/lib/utils";
7
+ import { PAIR_STATUS_LABEL, pairStatus, paneTmux } from "./pair";
8
+
9
+ // A phone shows one pane: a paired chat switches between its two sides
10
+ // here, with a dot on the analyst while a recommendation waits.
11
+ export function PairSwitch({
12
+ session,
13
+ paneId,
14
+ }: {
15
+ session: Session | null | undefined;
16
+ paneId: string;
17
+ }) {
18
+ const { data: link } = useSessionAnalyst(
19
+ session?.view === "chat" ? session.id : undefined
20
+ );
21
+ const { attachSession } = usePanes();
22
+ if (!session || !link || link.endedAt) return null;
23
+
24
+ const status = pairStatus(link);
25
+ const sides = [
26
+ { side: link.coder, label: "Coding agent", note: null },
27
+ {
28
+ side: link.analyst,
29
+ label: "Analyst",
30
+ note: PAIR_STATUS_LABEL[status],
31
+ },
32
+ ];
33
+
34
+ return (
35
+ <div
36
+ role="tablist"
37
+ aria-label="Paired sessions"
38
+ className="bg-muted grid grid-cols-2 gap-1 px-2 pb-1.5"
39
+ onClick={(e) => e.stopPropagation()}
40
+ >
41
+ {sides.map(({ side, label, note }) => {
42
+ const active = side.id === session.id;
43
+ return (
44
+ <button
45
+ key={side.id}
46
+ type="button"
47
+ role="tab"
48
+ aria-selected={active}
49
+ onClick={() =>
50
+ !active && attachSession(paneId, side.id, paneTmux(side))
51
+ }
52
+ className={cn(
53
+ "flex h-11 min-w-0 items-center justify-center gap-1.5 rounded-lg text-sm",
54
+ active
55
+ ? "bg-background text-foreground font-medium shadow-sm"
56
+ : "text-muted-foreground"
57
+ )}
58
+ >
59
+ <span className="truncate">{label}</span>
60
+ {note && status === "recommendation" && !active && (
61
+ <span
62
+ aria-label="Recommendation waiting"
63
+ className="bg-primary h-2 w-2 shrink-0 rounded-full"
64
+ />
65
+ )}
66
+ {note && (
67
+ <span className="text-muted-foreground shrink-0 text-xs font-normal">
68
+ {note}
69
+ </span>
70
+ )}
71
+ </button>
72
+ );
73
+ })}
74
+ </div>
75
+ );
76
+ }