@saadnvd1/agent-os 0.3.723 → 0.3.731

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 (150) hide show
  1. package/README.md +1 -0
  2. package/app/api/bus/ports/route.test.ts +43 -0
  3. package/app/api/bus/ports/route.ts +35 -0
  4. package/app/api/loops/route.ts +17 -0
  5. package/app/api/sessions/[id]/export/turn/route.test.ts +65 -0
  6. package/app/api/sessions/[id]/export/turn/route.ts +33 -0
  7. package/app/api/tasks/[id]/loop/cycles/[n]/log/route.ts +29 -0
  8. package/app/api/tasks/[id]/loop/draft-criteria/route.ts +17 -0
  9. package/app/api/tasks/[id]/loop/next/route.ts +25 -0
  10. package/app/api/tasks/[id]/loop/route.ts +72 -0
  11. package/app/api/tasks/[id]/loop/run/route.ts +12 -0
  12. package/app/api/tasks/[id]/loop/stop/route.ts +12 -0
  13. package/app/api/tasks/[id]/mode/route.ts +29 -0
  14. package/app/api/tasks/[id]/report/draft/route.ts +54 -0
  15. package/app/api/tasks/[id]/report/evidence/[evId]/route.ts +18 -0
  16. package/app/api/tasks/[id]/report/route.ts +39 -0
  17. package/app/api/workspaces/[id]/mode/route.ts +23 -0
  18. package/app/api/workspaces/[id]/reports/route.ts +17 -0
  19. package/app/page.tsx +8 -0
  20. package/components/Analyst/PairChip.tsx +131 -0
  21. package/components/Analyst/PairSwitch.tsx +76 -0
  22. package/components/Analyst/ReviewCard.tsx +272 -0
  23. package/components/Analyst/SignalChips.tsx +39 -0
  24. package/components/Analyst/StartAnalystSheet.tsx +109 -0
  25. package/components/Analyst/cardState.test.ts +109 -0
  26. package/components/Analyst/cardState.ts +95 -0
  27. package/components/Analyst/pair.ts +81 -0
  28. package/components/Chat/Code.tsx +3 -0
  29. package/components/Chat/Messages.tsx +8 -1
  30. package/components/Chat/TurnCopy.tsx +84 -0
  31. package/components/Chat/itemContext.ts +14 -0
  32. package/components/Draft/DraftChips.tsx +10 -0
  33. package/components/Draft/DraftPanel.tsx +7 -0
  34. package/components/Pane/DesktopTabBar.tsx +6 -0
  35. package/components/Pane/MobileTabBar.tsx +17 -0
  36. package/components/Pane/MobileViewPicker.tsx +4 -0
  37. package/components/Pane/PaneMenu.test.ts +21 -0
  38. package/components/Pane/PaneMenu.tsx +16 -0
  39. package/components/Pane/PaneViewToggle.tsx +12 -0
  40. package/components/Pane/index.tsx +19 -0
  41. package/components/Report/DraftCard.tsx +89 -0
  42. package/components/Report/EstimateBar.tsx +51 -0
  43. package/components/Report/EvidenceSheet.tsx +98 -0
  44. package/components/Report/ModeChooser.tsx +43 -0
  45. package/components/Report/ReportView.tsx +289 -0
  46. package/components/SessionList/GroupShelves.tsx +57 -22
  47. package/components/SessionList/MoveToGroupMenu.test.ts +77 -0
  48. package/components/SessionList/SessionRow.tsx +12 -1
  49. package/components/SessionList/SessionRowMenu.tsx +6 -0
  50. package/components/SessionList/SidebarContextMenu.test.ts +21 -1
  51. package/components/SessionList/SidebarContextMenu.tsx +33 -9
  52. package/components/SessionList/index.tsx +11 -2
  53. package/components/SessionList/useGroupDrag.ts +83 -0
  54. package/components/Sessions/RolePill.tsx +7 -0
  55. package/components/Tasks/Loop/LoopButton.tsx +74 -0
  56. package/components/Tasks/Loop/LoopSetup.tsx +146 -0
  57. package/components/Tasks/Loop/LoopView.tsx +350 -0
  58. package/components/Tasks/Loop/labels.ts +17 -0
  59. package/components/Tasks/TaskRow.tsx +2 -0
  60. package/components/Workspaces/WorkspaceModeForm.tsx +22 -0
  61. package/components/Workspaces/WorkspaceSettings.tsx +2 -0
  62. package/components/ui/segmented.tsx +55 -0
  63. package/components/ui/status-pill.tsx +3 -0
  64. package/contexts/PaneContext.tsx +21 -1
  65. package/data/exports/queries.ts +11 -0
  66. package/data/loops/index.ts +3 -0
  67. package/data/loops/keys.ts +4 -0
  68. package/data/loops/mutations.ts +68 -0
  69. package/data/loops/queries.ts +40 -0
  70. package/data/push/topics.ts +6 -0
  71. package/data/reports/index.ts +115 -0
  72. package/lib/agents/brief.ts +1 -1
  73. package/lib/analyst/forward.ts +1 -1
  74. package/lib/db/changes.ts +7 -0
  75. package/lib/db/migrations/20261021T1600_task_loops.ts +81 -0
  76. package/lib/db/migrations/20261021T1800_health_reports.ts +38 -0
  77. package/lib/done/index.ts +2 -0
  78. package/lib/drafts.ts +2 -0
  79. package/lib/export/markdown.ts +7 -1
  80. package/lib/export/turn.ts +48 -0
  81. package/lib/health/config.ts +2 -0
  82. package/lib/health/env/classes.ts +14 -1
  83. package/lib/health/port-wait-tick.ts +222 -0
  84. package/lib/health/port-wait.ts +160 -0
  85. package/lib/health/tick.ts +9 -2
  86. package/lib/health/types.ts +2 -0
  87. package/lib/loops/analyse.ts +46 -0
  88. package/lib/loops/analyst-prompt.ts +203 -0
  89. package/lib/loops/checkCriteria.ts +114 -0
  90. package/lib/loops/compactEvidence.ts +140 -0
  91. package/lib/loops/controls.ts +317 -0
  92. package/lib/loops/decideStop.ts +113 -0
  93. package/lib/loops/deps.ts +128 -0
  94. package/lib/loops/diffMap.ts +39 -0
  95. package/lib/loops/draftCriteria.ts +71 -0
  96. package/lib/loops/gatherEvidence.ts +265 -0
  97. package/lib/loops/hooks.ts +40 -0
  98. package/lib/loops/index.ts +7 -0
  99. package/lib/loops/route.ts +83 -0
  100. package/lib/loops/runCycle.ts +323 -0
  101. package/lib/loops/screenInstruction.ts +59 -0
  102. package/lib/loops/sendInstruction.ts +83 -0
  103. package/lib/loops/snapshot.ts +38 -0
  104. package/lib/loops/stopLoop.ts +86 -0
  105. package/lib/loops/store.ts +481 -0
  106. package/lib/loops/summary.ts +27 -0
  107. package/lib/loops/tickLoops.ts +141 -0
  108. package/lib/loops/types.ts +223 -0
  109. package/lib/loops/world.test-helpers.ts +223 -0
  110. package/lib/models/plan-step.ts +4 -1
  111. package/lib/orchestrator/digest.ts +2 -1
  112. package/lib/orchestrator/loop-guard.ts +5 -2
  113. package/lib/orchestrator/pr-wake.ts +4 -0
  114. package/lib/orchestrator/watcher.ts +8 -1
  115. package/lib/panes.ts +52 -0
  116. package/lib/plans/acceptance.ts +23 -0
  117. package/lib/{ports.ts → ports/allocatePorts.ts} +30 -7
  118. package/lib/ports/ensurePorts.ts +138 -0
  119. package/lib/ports/findHardcodedPorts.ts +116 -0
  120. package/lib/ports/index.ts +15 -0
  121. package/lib/ports/listListeners.ts +134 -0
  122. package/lib/ports/reassignPort.ts +45 -0
  123. package/lib/ports/types.ts +62 -0
  124. package/lib/ports/whoHolds.ts +155 -0
  125. package/lib/project-config/brief.ts +4 -2
  126. package/lib/project-config/session.ts +19 -3
  127. package/lib/reports/drafts.ts +87 -0
  128. package/lib/reports/end-hook.ts +17 -0
  129. package/lib/reports/estimate.ts +107 -0
  130. package/lib/reports/evidence/build.ts +193 -0
  131. package/lib/reports/evidence/gather.ts +229 -0
  132. package/lib/reports/fixtures.ts +17 -0
  133. package/lib/reports/generate.ts +159 -0
  134. package/lib/reports/hooks.ts +30 -0
  135. package/lib/reports/mode.ts +65 -0
  136. package/lib/reports/model.ts +115 -0
  137. package/lib/reports/read.ts +26 -0
  138. package/lib/reports/store.ts +180 -0
  139. package/lib/reports/template.ts +117 -0
  140. package/lib/reports/types.ts +135 -0
  141. package/lib/reports/validate.ts +142 -0
  142. package/lib/sidebar/groupDrop.ts +36 -0
  143. package/lib/sidebar/row-menu.ts +9 -0
  144. package/lib/tasks/contract/routes.ts +5 -0
  145. package/lib/tasks/contract/types.ts +3 -0
  146. package/lib/tasks/finish.ts +2 -0
  147. package/lib/tasks/move-carry.ts +1 -0
  148. package/lib/windows-ports.ts +36 -18
  149. package/package.json +2 -2
  150. package/stores/analystUi.ts +19 -0
@@ -0,0 +1,180 @@
1
+ import { db } from "../db";
2
+ import type {
3
+ DraftStatus,
4
+ HealthReport,
5
+ ReportSummary,
6
+ ReportTrigger,
7
+ } from "./types";
8
+
9
+ interface Row {
10
+ id: number;
11
+ session_id: string;
12
+ workspace_id: string | null;
13
+ head: string | null;
14
+ trigger: ReportTrigger;
15
+ evidence_hash: string;
16
+ data: string;
17
+ model: string | null;
18
+ cost_usd: number | null;
19
+ draft_text: string | null;
20
+ draft_status: DraftStatus | null;
21
+ created_at: string;
22
+ }
23
+
24
+ // What is stored in `data`: the report without what the row itself holds.
25
+ type Stored = Omit<
26
+ HealthReport,
27
+ "id" | "sessionId" | "workspaceId" | "createdAt" | "stale" | "draft"
28
+ > & { draft: HealthReport["draft"] };
29
+
30
+ export function insertReport(
31
+ r: Omit<HealthReport, "id" | "createdAt" | "stale">,
32
+ hash: string
33
+ ): number {
34
+ const { id: _i, sessionId, workspaceId, ...rest } = r as HealthReport;
35
+ void _i;
36
+ const draft = r.draft?.source === "report" ? r.draft : null;
37
+ db.prepare(
38
+ `UPDATE health_reports SET draft_status = 'superseded'
39
+ WHERE session_id = ? AND draft_status = 'pending'`
40
+ ).run(sessionId);
41
+ const info = db
42
+ .prepare(
43
+ `INSERT INTO health_reports (session_id, workspace_id, head, trigger, evidence_hash, data, model, cost_usd, draft_text, draft_status)
44
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
45
+ )
46
+ .run(
47
+ sessionId,
48
+ workspaceId,
49
+ r.head,
50
+ r.trigger,
51
+ hash,
52
+ JSON.stringify({ ...rest, draft: null } satisfies Stored),
53
+ r.model,
54
+ r.costUsd,
55
+ draft?.text ?? null,
56
+ draft ? "pending" : null
57
+ );
58
+ return Number(info.lastInsertRowid);
59
+ }
60
+
61
+ function toReport(row: Row, head: string | null | undefined): HealthReport {
62
+ const data = JSON.parse(row.data) as Stored;
63
+ return {
64
+ ...data,
65
+ id: row.id,
66
+ sessionId: row.session_id,
67
+ workspaceId: row.workspace_id,
68
+ createdAt: row.created_at,
69
+ draft: row.draft_text
70
+ ? {
71
+ source: "report",
72
+ id: String(row.id),
73
+ text: row.draft_text,
74
+ status: row.draft_status ?? "pending",
75
+ }
76
+ : null,
77
+ stale: head !== undefined && head !== row.head,
78
+ };
79
+ }
80
+
81
+ // `head` is the task's current head, to mark an older report stale.
82
+ export function latestReport(
83
+ sessionId: string,
84
+ head?: string | null
85
+ ): HealthReport | null {
86
+ const row = db
87
+ .prepare(
88
+ `SELECT * FROM health_reports WHERE session_id = ? ORDER BY id DESC LIMIT 1`
89
+ )
90
+ .get(sessionId) as Row | undefined;
91
+ return row ? toReport(row, head) : null;
92
+ }
93
+
94
+ export function getReport(id: number): HealthReport | null {
95
+ const row = db
96
+ .prepare(`SELECT * FROM health_reports WHERE id = ?`)
97
+ .get(id) as Row | undefined;
98
+ return row ? toReport(row, undefined) : null;
99
+ }
100
+
101
+ export function hashOfLatest(sessionId: string): string | null {
102
+ const row = db
103
+ .prepare(
104
+ `SELECT evidence_hash FROM health_reports WHERE session_id = ? ORDER BY id DESC LIMIT 1`
105
+ )
106
+ .get(sessionId) as { evidence_hash: string } | undefined;
107
+ return row?.evidence_hash ?? null;
108
+ }
109
+
110
+ // Claimed first, so a double tap sends once.
111
+ export function claimDraft(
112
+ reportId: number,
113
+ to: Exclude<DraftStatus, "pending">
114
+ ): boolean {
115
+ return (
116
+ db
117
+ .prepare(
118
+ `UPDATE health_reports SET draft_status = ?, draft_decided_at = datetime('now')
119
+ WHERE id = ? AND draft_status = 'pending'`
120
+ )
121
+ .run(to, reportId).changes === 1
122
+ );
123
+ }
124
+
125
+ export function reopenDraft(reportId: number): void {
126
+ db.prepare(
127
+ `UPDATE health_reports SET draft_status = 'pending', draft_decided_at = NULL
128
+ WHERE id = ? AND draft_status = 'sent'`
129
+ ).run(reportId);
130
+ }
131
+
132
+ export function setDraftText(
133
+ reportId: number,
134
+ text: string,
135
+ status: DraftStatus
136
+ ) {
137
+ db.prepare(
138
+ `UPDATE health_reports SET draft_text = ?, draft_status = ? WHERE id = ?`
139
+ ).run(text, status, reportId);
140
+ }
141
+
142
+ export function historyOf(sessionId: string, limit = 20): ReportSummary[] {
143
+ const rows = db
144
+ .prepare(
145
+ `SELECT * FROM health_reports WHERE session_id = ? ORDER BY id DESC LIMIT ?`
146
+ )
147
+ .all(sessionId, limit) as Row[];
148
+ return rows.map((r) => summaryOf(toReport(r, undefined), ""));
149
+ }
150
+
151
+ export function summaryOf(r: HealthReport, name: string): ReportSummary {
152
+ return {
153
+ sessionId: r.sessionId,
154
+ name,
155
+ headline: r.headline,
156
+ state: r.state,
157
+ estimate: {
158
+ done: r.estimate.done,
159
+ total: r.estimate.total,
160
+ basis: r.estimate.basis,
161
+ },
162
+ createdAt: r.createdAt,
163
+ };
164
+ }
165
+
166
+ // The newest report of each task in a workspace.
167
+ export function latestByTask(
168
+ workspaceId: string
169
+ ): { report: HealthReport; name: string }[] {
170
+ const rows = db
171
+ .prepare(
172
+ `SELECT h.*, s.name AS name FROM health_reports h
173
+ JOIN session_rows s ON s.id = h.session_id
174
+ WHERE h.workspace_id = ?
175
+ AND h.id = (SELECT MAX(id) FROM health_reports WHERE session_id = h.session_id)
176
+ ORDER BY h.id DESC LIMIT 100`
177
+ )
178
+ .all(workspaceId) as (Row & { name: string })[];
179
+ return rows.map((r) => ({ report: toReport(r, undefined), name: r.name }));
180
+ }
@@ -0,0 +1,117 @@
1
+ // The report with no model: sentences built from the same evidence. Used when
2
+ // the model fails or every line it wrote was rejected, and for the headline.
3
+
4
+ import type { TaskState } from "../tasks/state";
5
+ import type { EvidenceRef, ReportLine } from "./types";
6
+
7
+ const HEADLINES: Record<TaskState, string> = {
8
+ working: "The agent is working on it",
9
+ "needs-input": "The agent is waiting for an answer",
10
+ blocked: "The task is blocked and needs a decision",
11
+ review: "The pull request is open and waiting on review",
12
+ "checks-failing": "Checks are failing on the latest push",
13
+ "plan-ready": "The plan is written and waits for your approval",
14
+ exited: "The agent stopped before finishing",
15
+ merged: "The work is merged",
16
+ dropped: "The task was dropped",
17
+ done: "The task is finished",
18
+ moving: "The task is moving to another machine",
19
+ };
20
+
21
+ export const headlineFor = (state: TaskState) => HEADLINES[state];
22
+
23
+ const NEXT: Partial<Record<TaskState, string>> = {
24
+ "checks-failing": "Next: fix the failing checks, then push again.",
25
+ review: "Next: wait for the review and the checks to settle, then merge.",
26
+ blocked: "Next: answer what the task is waiting on.",
27
+ "needs-input": "Next: reply to the agent so it can go on.",
28
+ "plan-ready": "Next: read the plan and approve it.",
29
+ exited: "Next: restart the agent or take the work over.",
30
+ working: "Next: let the agent finish its turn.",
31
+ };
32
+
33
+ export function templateLines(
34
+ state: TaskState,
35
+ evidence: EvidenceRef[]
36
+ ): {
37
+ lines: ReportLine[];
38
+ recommendation: { text: string; evidence: string[] } | null;
39
+ } {
40
+ const lines: ReportLine[] = [];
41
+ const add = (tone: ReportLine["tone"], text: string, ev: EvidenceRef[]) =>
42
+ lines.push({
43
+ id: `l${lines.length + 1}`,
44
+ tone,
45
+ text,
46
+ evidence: ev.map((e) => e.id),
47
+ });
48
+ const of = (kind: EvidenceRef["kind"]) =>
49
+ evidence.filter((e) => e.kind === kind);
50
+
51
+ const ci = of("ci");
52
+ const ciPass = ci.filter((e) => e.status === "pass");
53
+ const ciFail = ci.filter((e) => e.status === "fail");
54
+ const ciWait = ci.filter((e) => e.status === "pending");
55
+ if (ciPass.length)
56
+ add(
57
+ "done",
58
+ `${ciPass.length} CI check${ciPass.length === 1 ? "" : "s"} passed.`,
59
+ ciPass
60
+ );
61
+ for (const e of ciFail) add("warn", `${e.label} is failing.`, [e]);
62
+ if (ciWait.length)
63
+ add(
64
+ "warn",
65
+ `${ciWait.length} CI check${ciWait.length === 1 ? " is" : "s are"} still running.`,
66
+ ciWait
67
+ );
68
+
69
+ for (const e of of("test"))
70
+ add(
71
+ e.status === "pass" ? "done" : e.status === "fail" ? "warn" : "next",
72
+ e.status === "pass"
73
+ ? `Tests passed: ${e.label}.`
74
+ : e.status === "fail"
75
+ ? `Tests failed: ${e.label}.`
76
+ : `Tests are running: ${e.label}.`,
77
+ [e]
78
+ );
79
+
80
+ for (const e of of("review"))
81
+ add(
82
+ e.status === "pass" ? "done" : e.status === "fail" ? "warn" : "next",
83
+ e.status === "pass"
84
+ ? "The independent review passed."
85
+ : e.status === "fail"
86
+ ? "The independent review found problems to fix."
87
+ : "The independent review has not finished.",
88
+ [e]
89
+ );
90
+
91
+ for (const e of of("piece"))
92
+ add(e.status === "pass" ? "done" : "next", `${e.label}.`, [e]);
93
+ for (const e of of("accept").filter((e) => e.status === "pass"))
94
+ add("done", `Verified: ${e.label}.`, [e]);
95
+ for (const e of of("ask")) add("blocked", `${e.label}.`, [e]);
96
+ for (const e of of("log")) add("done", `${e.label}.`, [e]);
97
+
98
+ const hasDiff = of("diff");
99
+ if (hasDiff.length)
100
+ add(
101
+ "done",
102
+ `${hasDiff.length} file${hasDiff.length === 1 ? "" : "s"} changed.`,
103
+ hasDiff.slice(0, 12)
104
+ );
105
+
106
+ const stateEv = of("state");
107
+ if (NEXT[state] && stateEv.length) add("next", NEXT[state]!, stateEv);
108
+
109
+ const bad = [...ciFail, ...of("test").filter((e) => e.status === "fail")];
110
+ const recommendation = bad.length
111
+ ? {
112
+ text: `Ask the agent to fix ${bad[0].label} first; everything else waits on it.`,
113
+ evidence: [bad[0].id],
114
+ }
115
+ : null;
116
+ return { lines, recommendation };
117
+ }
@@ -0,0 +1,135 @@
1
+ // Plain-English health reports: types shared by the server, the browser and
2
+ // the native app (no Node modules here).
3
+
4
+ import type { TaskState } from "../tasks/state";
5
+
6
+ export const OPERATING_MODES = ["manual", "assisted", "autonomous"] as const;
7
+ export type OperatingMode = (typeof OPERATING_MODES)[number];
8
+ export const isMode = (v: unknown): v is OperatingMode =>
9
+ typeof v === "string" && (OPERATING_MODES as readonly string[]).includes(v);
10
+
11
+ export const MODE_COPY: Record<
12
+ OperatingMode,
13
+ { label: string; blurb: string }
14
+ > = {
15
+ manual: {
16
+ label: "Manual",
17
+ blurb: "The analyst recommends, you decide.",
18
+ },
19
+ assisted: {
20
+ label: "Assisted",
21
+ blurb: "The analyst drafts the next prompt, you approve it with one click.",
22
+ },
23
+ autonomous: {
24
+ label: "Autonomous",
25
+ blurb: "Agents iterate on their own, within the loop's safeguards.",
26
+ },
27
+ };
28
+
29
+ export type ReportTrigger = "on-demand" | "cycle" | "end";
30
+ export type LineTone = "done" | "warn" | "blocked" | "next";
31
+ export type EvidenceKind =
32
+ | "ci"
33
+ | "test"
34
+ | "review"
35
+ | "diff"
36
+ | "piece"
37
+ | "accept"
38
+ | "ask"
39
+ | "state"
40
+ | "log";
41
+ export type EvidenceStatus = "pass" | "fail" | "pending" | "info";
42
+
43
+ export interface EvidenceRef {
44
+ id: string;
45
+ kind: EvidenceKind;
46
+ label: string;
47
+ status: EvidenceStatus;
48
+ at: string;
49
+ // The head it belongs to; stale when it differs from the current head.
50
+ sha?: string;
51
+ url?: string;
52
+ href?: string;
53
+ excerpt?: string;
54
+ }
55
+
56
+ export interface ReportLine {
57
+ id: string;
58
+ tone: LineTone;
59
+ text: string;
60
+ evidence: string[];
61
+ }
62
+
63
+ export interface EstimateItem {
64
+ label: string;
65
+ verified: boolean;
66
+ evidence: string[];
67
+ }
68
+
69
+ export interface Estimate {
70
+ done: number;
71
+ total: number;
72
+ basis: "acceptance" | "stages";
73
+ items: EstimateItem[];
74
+ }
75
+
76
+ export type DraftStatus = "pending" | "sent" | "dismissed" | "superseded";
77
+
78
+ export interface NextPromptDraft {
79
+ // The report row that holds it, or the analyst recommendation.
80
+ source: "report" | "analyst";
81
+ id: string;
82
+ text: string;
83
+ status: DraftStatus;
84
+ // Analyst drafts send through the analyst link.
85
+ linkId?: string;
86
+ }
87
+
88
+ export interface HealthReport {
89
+ id: number;
90
+ sessionId: string;
91
+ workspaceId: string | null;
92
+ head: string | null;
93
+ trigger: ReportTrigger;
94
+ createdAt: string;
95
+ headline: string;
96
+ state: TaskState;
97
+ estimate: Estimate;
98
+ lines: ReportLine[];
99
+ recommendation: { text: string; evidence: string[] } | null;
100
+ draft: NextPromptDraft | null;
101
+ evidence: EvidenceRef[];
102
+ // "template": the model failed, or every line it wrote was rejected.
103
+ source: "model" | "template";
104
+ // Model lines the validator rejected.
105
+ dropped: number;
106
+ model: string | null;
107
+ costUsd: number | null;
108
+ // Computed on read: the head moved since.
109
+ stale: boolean;
110
+ }
111
+
112
+ export interface ReportSummary {
113
+ sessionId: string;
114
+ name: string;
115
+ headline: string;
116
+ state: TaskState;
117
+ estimate: Pick<Estimate, "done" | "total" | "basis">;
118
+ createdAt: string;
119
+ }
120
+
121
+ export interface ReportResponse {
122
+ report: HealthReport | null;
123
+ generating: boolean;
124
+ mode: { mode: OperatingMode; source: "task" | "workspace" | "default" };
125
+ }
126
+
127
+ export const estimateLabel = (e: Pick<Estimate, "done" | "total" | "basis">) =>
128
+ `Estimate · ${e.done} of ${e.total} ${
129
+ e.basis === "acceptance"
130
+ ? "acceptance items verified"
131
+ : "delivery stages reached"
132
+ }`;
133
+
134
+ export const estimatePercent = (e: Pick<Estimate, "done" | "total">) =>
135
+ e.total ? Math.round((100 * e.done) / e.total) : 0;
@@ -0,0 +1,142 @@
1
+ // What a model may say in a report. Every line must cite evidence that
2
+ // exists, say only numbers that evidence contains, never use a percent sign,
3
+ // and take a tone its evidence supports. What fails is dropped and counted.
4
+
5
+ import type { EvidenceRef, LineTone, ReportLine } from "./types";
6
+ import type { Mapping } from "./estimate";
7
+
8
+ export interface ModelReport {
9
+ headline?: unknown;
10
+ lines?: unknown;
11
+ recommendation?: unknown;
12
+ acceptance?: unknown;
13
+ nextPrompt?: unknown;
14
+ }
15
+
16
+ export interface Validated {
17
+ headline: string | null;
18
+ lines: ReportLine[];
19
+ recommendation: { text: string; evidence: string[] } | null;
20
+ mappings: Mapping[];
21
+ nextPrompt: string | null;
22
+ dropped: number;
23
+ }
24
+
25
+ export const MAX_LINE = 280;
26
+ export const MAX_LINES = 10;
27
+ export const MAX_PROMPT = 4000;
28
+ const TONES: LineTone[] = ["done", "warn", "blocked", "next"];
29
+
30
+ const str = (v: unknown, max: number) =>
31
+ typeof v === "string" ? v.trim().slice(0, max) : "";
32
+ const ids = (v: unknown): string[] =>
33
+ Array.isArray(v)
34
+ ? [...new Set(v.filter((x): x is string => typeof x === "string"))]
35
+ : [];
36
+
37
+ // Digits in the text that none of the cited evidence contains.
38
+ function inventedNumber(text: string, cited: EvidenceRef[]): boolean {
39
+ const nums = text.match(/\d+/g);
40
+ if (!nums) return false;
41
+ const haystack = cited
42
+ .map((e) => `${e.id} ${e.label} ${e.excerpt ?? ""} ${e.sha ?? ""}`)
43
+ .join("\n");
44
+ return nums.some((n) => !new RegExp(`(?<!\\d)${n}(?!\\d)`).test(haystack));
45
+ }
46
+
47
+ export function sayable(text: string, cited: EvidenceRef[]): boolean {
48
+ return (
49
+ !!text &&
50
+ text.length <= MAX_LINE &&
51
+ !/%/.test(text) &&
52
+ !inventedNumber(text, cited)
53
+ );
54
+ }
55
+
56
+ function toneFits(tone: LineTone, cited: EvidenceRef[]): boolean {
57
+ if (tone === "done") return cited.some((e) => e.status === "pass");
58
+ if (tone === "warn" || tone === "blocked")
59
+ return cited.some(
60
+ (e) => e.status === "fail" || e.status === "pending" || e.kind === "ask"
61
+ );
62
+ return true;
63
+ }
64
+
65
+ export function validateModelReport(
66
+ raw: unknown,
67
+ evidence: EvidenceRef[]
68
+ ): Validated {
69
+ const out: Validated = {
70
+ headline: null,
71
+ lines: [],
72
+ recommendation: null,
73
+ mappings: [],
74
+ nextPrompt: null,
75
+ dropped: 0,
76
+ };
77
+ if (!raw || typeof raw !== "object") return out;
78
+ const m = raw as ModelReport;
79
+ const byId = new Map(evidence.map((e) => [e.id, e]));
80
+ const resolve = (cites: string[]) => {
81
+ const found = cites.map((c) => byId.get(c));
82
+ return found.some((e) => !e) ? null : (found as EvidenceRef[]);
83
+ };
84
+
85
+ const headline = str(m.headline, MAX_LINE);
86
+ if (
87
+ headline &&
88
+ sayable(
89
+ headline,
90
+ evidence.filter((e) => e.kind === "state")
91
+ )
92
+ )
93
+ out.headline = headline;
94
+ else if (headline) out.dropped++;
95
+
96
+ const rawLines = Array.isArray(m.lines) ? m.lines.slice(0, 24) : [];
97
+ for (const l of rawLines) {
98
+ const o = (l ?? {}) as Record<string, unknown>;
99
+ const tone = o.tone as LineTone;
100
+ const text = str(o.text, MAX_LINE + 1);
101
+ const cites = ids(o.evidence);
102
+ const cited = cites.length ? resolve(cites) : null;
103
+ if (
104
+ !TONES.includes(tone) ||
105
+ !cited ||
106
+ !sayable(text, cited) ||
107
+ !toneFits(tone, cited) ||
108
+ out.lines.length >= MAX_LINES
109
+ ) {
110
+ out.dropped++;
111
+ continue;
112
+ }
113
+ out.lines.push({
114
+ id: `l${out.lines.length + 1}`,
115
+ tone,
116
+ text,
117
+ evidence: cites,
118
+ });
119
+ }
120
+
121
+ if (m.recommendation && typeof m.recommendation === "object") {
122
+ const r = m.recommendation as Record<string, unknown>;
123
+ const text = str(r.text, 600);
124
+ const cites = ids(r.evidence);
125
+ const cited = cites.length ? resolve(cites) : null;
126
+ if (text && cited && !/%/.test(text) && !inventedNumber(text, cited))
127
+ out.recommendation = { text, evidence: cites };
128
+ else out.dropped++;
129
+ }
130
+
131
+ if (Array.isArray(m.acceptance))
132
+ for (const a of m.acceptance.slice(0, 24)) {
133
+ const o = (a ?? {}) as Record<string, unknown>;
134
+ // The model sees items numbered from 1, as in "accept:1".
135
+ if (typeof o.item === "number" && Number.isInteger(o.item) && o.item >= 1)
136
+ out.mappings.push({ item: o.item - 1, evidence: ids(o.evidence) });
137
+ }
138
+
139
+ const prompt = str(m.nextPrompt, MAX_PROMPT);
140
+ if (prompt) out.nextPrompt = prompt;
141
+ return out;
142
+ }
@@ -0,0 +1,36 @@
1
+ // Drag a session onto a group to file it there, or onto the list outside any
2
+ // group to take it out. Pure, so the rules are testable without a DOM.
3
+
4
+ export type DraggedSession = {
5
+ id: string;
6
+ groupId: string | null;
7
+ // The session's workspace, by the same rule the server files by.
8
+ workspaceId: string | null;
9
+ };
10
+
11
+ export type DropTarget = { groupId: string; workspaceId: string | null };
12
+
13
+ // What a drop should do: the group to file into (null = out of any group), or
14
+ // null when nothing should happen. A group of another workspace never takes
15
+ // the session (the server refuses it too); dropping on its own group, or an
16
+ // ungrouped session outside any group, changes nothing.
17
+ export function dropOutcome(
18
+ dragged: DraggedSession,
19
+ target: DropTarget | null
20
+ ): { groupId: string | null } | null {
21
+ if (!target) return dragged.groupId ? { groupId: null } : null;
22
+ if ((target.workspaceId ?? null) !== (dragged.workspaceId ?? null))
23
+ return null;
24
+ return target.groupId === dragged.groupId
25
+ ? null
26
+ : { groupId: target.groupId };
27
+ }
28
+
29
+ export const DRAG_TYPE = "application/x-agentos-session";
30
+
31
+ // dragover can't read the payload, so the one drag in flight is kept here.
32
+ let current: DraggedSession | null = null;
33
+ export const dragging = () => current;
34
+ export const setDragging = (d: DraggedSession | null) => {
35
+ current = d;
36
+ };
@@ -23,6 +23,7 @@ export type RowMenuItem =
23
23
  | "move-machine"
24
24
  | "move-project"
25
25
  | "move-group"
26
+ | "add-analyst"
26
27
  | "check-ins"
27
28
  | "save-recipe"
28
29
  | "run-again"
@@ -55,6 +56,7 @@ export interface RowMenuInput {
55
56
  | "has_brief"
56
57
  | "archived_at"
57
58
  | "pause_state"
59
+ | "agent_role"
58
60
  >;
59
61
  row?: Pick<SidebarRow, "task" | "wakesAt" | "settled">;
60
62
  // A linked machine runs it: what can't reach it yet is left out.
@@ -142,6 +144,13 @@ export function rowMenu(input: RowMenuInput): RowMenuPlan {
142
144
  agentRow && "move-group",
143
145
  ]),
144
146
  ...group("automate", [
147
+ // A local chat can have an analyst review its turns; not an analyst.
148
+ s.view === "chat" &&
149
+ agentRow &&
150
+ live &&
151
+ !s.archived_at &&
152
+ s.agent_role !== "analyst" &&
153
+ "add-analyst",
145
154
  input.hasWorkspace &&
146
155
  !orchestrator &&
147
156
  (!s.task_status || s.task_status === "running") &&
@@ -3,6 +3,11 @@
3
3
  export const taskPath = (id: string, leaf: string) =>
4
4
  `/api/tasks/${encodeURIComponent(id)}/${leaf}`;
5
5
 
6
+ // The loop's routes are the task's own: GET/POST/PATCH on `loop`, and the
7
+ // leaves stop, run and next beside it (lib/loops/route.ts).
8
+ export const loopPath = (id: string, leaf = "") =>
9
+ taskPath(id, leaf ? `loop/${leaf}` : "loop");
10
+
6
11
  export const LIST = "/api/tasks";
7
12
  export const IMPORT = "/api/tasks/import";
8
13
  export const HELD_REPLIES = "/api/tasks/held-replies";
@@ -27,6 +27,9 @@ export const TASK_CAPABILITIES = [
27
27
  // compat: the shims marked "task-contract-1" in contract/ go once every
28
28
  // linked machine reports this.
29
29
  "task-contract-1",
30
+ // A task's developer-analyst loop: /api/tasks/:id/loop and its leaves
31
+ // (lib/loops). A task started on a linked machine is looped from there.
32
+ "task-loop-1",
30
33
  ] as const;
31
34
 
32
35
  export interface HostTasks {
@@ -36,6 +36,7 @@ import {
36
36
  } from "./session";
37
37
  import { releaseSessionLeftovers } from "../sessions/leftovers";
38
38
  import { captureMergedTask } from "../session-logs";
39
+ import { taskEnded } from "../reports/end-hook";
39
40
  import { sessionEnded } from "../experiments/hooks";
40
41
  import { snapshotWorktree } from "../resurrect/snapshot";
41
42
  import { keepSessionFiles } from "../session-artifacts/collect";
@@ -202,6 +203,7 @@ export async function signOffTask(
202
203
  pushMerged(id);
203
204
  // Reads the plan file now: cleanup below removes the worktree.
204
205
  void captureMergedTask(id, { head: opts.head ?? pr!.head ?? null });
206
+ taskEnded(id);
205
207
  void sessionEnded(id, "merged");
206
208
  const head = opts.head ?? pr!.head;
207
209
  resolveMergedTaskAsks(
@@ -27,6 +27,7 @@ export const REKEYED = [
27
27
  ["check_runs", "session_id", "id"],
28
28
  ["away_actions", "subject", "task"],
29
29
  ["task_queue", "after_task", "id"],
30
+ ["task_loops", "session_id", "id"],
30
31
  ] as const;
31
32
 
32
33
  // Session-keyed columns that stay on the old id, and why.