@saadnvd1/agent-os 0.3.715 → 0.3.723

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 (142) hide show
  1. package/README.md +1 -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/models/availability/route.ts +9 -0
  12. package/app/api/models/choices/route.ts +9 -0
  13. package/app/api/sessions/[id]/analyst/route.ts +11 -0
  14. package/app/api/sessions/[id]/crew/route.ts +38 -0
  15. package/app/api/sessions/[id]/export/estimate/route.ts +23 -0
  16. package/app/api/sessions/[id]/export/route.test.ts +125 -0
  17. package/app/api/sessions/[id]/export/route.ts +34 -0
  18. package/app/api/sessions/[id]/model/route.ts +49 -0
  19. package/app/api/sessions/route.ts +16 -0
  20. package/app/page.tsx +5 -0
  21. package/components/Chat/Messages.tsx +104 -10
  22. package/components/Draft/DraftChips.tsx +6 -1
  23. package/components/Draft/DraftPanel.tsx +51 -2
  24. package/components/Draft/ModelUnavailableDialog.tsx +78 -0
  25. package/components/Draft/RoleChips.tsx +110 -0
  26. package/components/Export/ExportActions.tsx +136 -0
  27. package/components/Export/ExportSheet.tsx +217 -0
  28. package/components/Export/RangePicker.tsx +144 -0
  29. package/components/Export/Seg.tsx +38 -0
  30. package/components/Export/TurnSelectBar.tsx +45 -0
  31. package/components/Models/AgentRoleCard.tsx +114 -0
  32. package/components/Models/AgentRolesSection.tsx +132 -0
  33. package/components/Models/ModelBadges.tsx +48 -0
  34. package/components/Models/ModelsSection.tsx +4 -2
  35. package/components/Models/RoleModelSelect.tsx +87 -0
  36. package/components/Pane/DesktopTabBar.tsx +2 -0
  37. package/components/Pane/MobileTabBar.tsx +12 -0
  38. package/components/Pane/PaneMenu.tsx +10 -0
  39. package/components/SessionList/SessionRow.tsx +18 -7
  40. package/components/SessionList/SessionRowMenu.tsx +6 -0
  41. package/components/Sessions/RolePill.tsx +196 -0
  42. package/components/Settings/SettingsDialog.tsx +9 -0
  43. package/components/Share/ShareDialog.tsx +13 -0
  44. package/data/analyst/index.ts +3 -0
  45. package/data/analyst/keys.ts +6 -0
  46. package/data/analyst/mutations.ts +116 -0
  47. package/data/analyst/queries.ts +35 -0
  48. package/data/exports/index.ts +2 -0
  49. package/data/exports/keys.ts +3 -0
  50. package/data/exports/queries.ts +51 -0
  51. package/data/models/index.ts +98 -0
  52. package/data/push/topics.ts +5 -0
  53. package/data/sessions/queries.ts +26 -0
  54. package/lib/agents/brief.ts +1 -0
  55. package/lib/analyst/__fixtures__/items.ts +57 -0
  56. package/lib/analyst/__fixtures__/mocks.ts +49 -0
  57. package/lib/analyst/brief.ts +17 -0
  58. package/lib/analyst/buildDigest.ts +204 -0
  59. package/lib/analyst/createLink.ts +98 -0
  60. package/lib/analyst/endLink.ts +31 -0
  61. package/lib/analyst/forward.ts +109 -0
  62. package/lib/analyst/index.ts +16 -0
  63. package/lib/analyst/linkView.ts +38 -0
  64. package/lib/analyst/onTurnEnd.ts +119 -0
  65. package/lib/analyst/parseReview.ts +41 -0
  66. package/lib/analyst/pushDigest.ts +143 -0
  67. package/lib/analyst/resume.ts +35 -0
  68. package/lib/analyst/route.ts +50 -0
  69. package/lib/analyst/signals.ts +121 -0
  70. package/lib/analyst/store.ts +303 -0
  71. package/lib/analyst/types.ts +140 -0
  72. package/lib/bus/condense.ts +5 -1
  73. package/lib/bus/index.ts +5 -0
  74. package/lib/bus/read-scope.ts +12 -0
  75. package/lib/bus/role-alias.ts +70 -0
  76. package/lib/bus/targets.ts +3 -0
  77. package/lib/chat/drivers/opencode-mapper.ts +55 -1
  78. package/lib/chat/drivers/pi-approvals.ts +7 -4
  79. package/lib/chat/drivers/pi-mapper.ts +38 -0
  80. package/lib/chat/drivers/pi-text-tools.ts +104 -0
  81. package/lib/chat/drivers/pi.ts +8 -3
  82. package/lib/chat/drivers/turn-items.ts +36 -11
  83. package/lib/chat/drivers/turn-outcome.ts +108 -0
  84. package/lib/chat/events.ts +8 -0
  85. package/lib/chat/registry.ts +13 -0
  86. package/lib/chat/store.ts +19 -0
  87. package/lib/chat/turns.ts +1 -0
  88. package/lib/chat/worker/extras.ts +4 -2
  89. package/lib/claims/collectLocal.ts +3 -1
  90. package/lib/db/changes.ts +6 -0
  91. package/lib/db/migrations/20261021T1400_session_agent_roles.ts +20 -0
  92. package/lib/db/migrations/20261021T1430_analyst_links.ts +52 -0
  93. package/lib/db/session-parts/columns.ts +2 -0
  94. package/lib/db/types.ts +3 -0
  95. package/lib/done/archive.ts +2 -0
  96. package/lib/drafts.ts +4 -0
  97. package/lib/export/build.ts +470 -0
  98. package/lib/export/compact.ts +156 -0
  99. package/lib/export/estimate.ts +83 -0
  100. package/lib/export/fixtures.ts +45 -0
  101. package/lib/export/html.tsx +195 -0
  102. package/lib/export/json.ts +39 -0
  103. package/lib/export/load.ts +64 -0
  104. package/lib/export/markdown.ts +127 -0
  105. package/lib/export/request.ts +36 -0
  106. package/lib/export/service.ts +142 -0
  107. package/lib/export/summary.ts +86 -0
  108. package/lib/export/turns.ts +63 -0
  109. package/lib/export/view.ts +173 -0
  110. package/lib/models/agent-binding.ts +102 -0
  111. package/lib/models/agent-roles-view.ts +157 -0
  112. package/lib/models/agent-roles.ts +87 -0
  113. package/lib/models/availability-refs.ts +17 -0
  114. package/lib/models/availability.ts +91 -0
  115. package/lib/models/catalog.ts +156 -0
  116. package/lib/models/config.ts +27 -0
  117. package/lib/models/locality.ts +33 -0
  118. package/lib/models/providers.ts +11 -0
  119. package/lib/models/resolve.ts +50 -12
  120. package/lib/models/roles.ts +12 -0
  121. package/lib/needs-you.ts +1 -1
  122. package/lib/orchestrator/facts.ts +7 -2
  123. package/lib/orchestrator/read.ts +5 -1
  124. package/lib/orchestrator/untrusted.ts +5 -0
  125. package/lib/projects.ts +1 -0
  126. package/lib/session-artifacts/agentos-files.ts +94 -0
  127. package/lib/session-artifacts/collect.ts +17 -0
  128. package/lib/sessions/crew.ts +72 -0
  129. package/lib/sessions/launch.ts +49 -9
  130. package/lib/settings-url.ts +1 -0
  131. package/lib/share/markdown.ts +2 -2
  132. package/lib/share/redact.ts +16 -10
  133. package/lib/share/render.tsx +1 -1
  134. package/lib/share/snapshot.ts +2 -2
  135. package/lib/sidebar/row-menu.ts +6 -1
  136. package/lib/tasks/index.ts +6 -0
  137. package/lib/worktree-deps/caches.ts +3 -2
  138. package/lib/worktree-deps/clone.ts +3 -1
  139. package/lib/worktree-deps/paths.ts +26 -0
  140. package/package.json +1 -1
  141. package/server.ts +9 -1
  142. package/stores/exportUi.ts +87 -0
@@ -0,0 +1,86 @@
1
+ import { addHits, noHits } from "../share/redact";
2
+ import type { Counts, ExportDoc, ExportTurn } from "./build";
3
+ import { inRange, resolveRange } from "./turns";
4
+ import {
5
+ maskedList,
6
+ type ExportMode,
7
+ type ExportRange,
8
+ type ExportRedact,
9
+ type Hits,
10
+ } from "./view";
11
+
12
+ export interface Selected {
13
+ doc: ExportDoc;
14
+ turns: ExportTurn[];
15
+ sel: { from: number; to: number; of: number };
16
+ hits: Hits;
17
+ omissions: Counts;
18
+ truncations: Counts;
19
+ }
20
+
21
+ export interface ExportInfo {
22
+ mode: ExportMode;
23
+ redact: ExportRedact;
24
+ generatedAt: string;
25
+ }
26
+
27
+ const sum = (parts: Counts[]) => {
28
+ const out: Counts = {};
29
+ for (const p of parts)
30
+ for (const [k, v] of Object.entries(p)) out[k] = (out[k] ?? 0) + v;
31
+ return out;
32
+ };
33
+
34
+ /** The turns a range means, with the counts for just those. */
35
+ export function selectTurns(doc: ExportDoc, range?: ExportRange): Selected {
36
+ const sel = resolveRange(doc.turns, range);
37
+ const turns = doc.turns.filter((t) => inRange(t.n, sel));
38
+ return {
39
+ doc,
40
+ turns,
41
+ sel,
42
+ hits: turns.reduce((h, t) => addHits(h, t.hits), doc.metaHits),
43
+ omissions: sum(turns.map((t) => t.omitted)),
44
+ truncations: sum(turns.map((t) => t.truncated)),
45
+ };
46
+ }
47
+
48
+ const plural = (n: number, what: string) => `${n} ${what}${n === 1 ? "" : "s"}`;
49
+
50
+ export const countList = (c: Counts) =>
51
+ Object.entries(c)
52
+ .filter(([, n]) => n > 0)
53
+ .map(([what, n]) => plural(n, what))
54
+ .join(", ");
55
+
56
+ const NOTHING = noHits();
57
+ export { NOTHING };
58
+
59
+ /** The lines every format prints under "Export notes". */
60
+ export function exportNotes(s: Selected, info: ExportInfo) {
61
+ const { from, to, of } = s.sel;
62
+ const n = s.turns.filter((t) => t.n > 0).length;
63
+ const first = s.turns[0]?.startedAt.slice(0, 16).replace("T", " ");
64
+ const last = s.turns[s.turns.length - 1]?.endedAt
65
+ .slice(0, 16)
66
+ .replace("T", " ");
67
+ const whole = from <= 1 && to >= of;
68
+ const range = whole
69
+ ? `Whole session, ${plural(of, "turn")}${first ? `, ${first} to ${last}` : ""}`
70
+ : `Turns ${from}–${to} of ${of} (${plural(n, "turn")}), ${first} to ${last}`;
71
+ const masked = maskedList(s.hits);
72
+ return {
73
+ range,
74
+ detail:
75
+ info.mode === "compact"
76
+ ? "Compact: thinking dropped; tool output cut to its first 10 and last 20 lines; file changes reduced to counts"
77
+ : "Full: nothing dropped except what the notes below list",
78
+ redaction:
79
+ (info.redact === "all"
80
+ ? "Credentials, home paths, addresses, machine names and emails are masked"
81
+ : "Credentials are masked; paths, machine names and emails are included") +
82
+ (masked ? `. Masked: ${masked}` : ""),
83
+ omitted: countList(s.omissions) || "nothing",
84
+ truncated: countList(s.truncations) || "nothing",
85
+ };
86
+ }
@@ -0,0 +1,63 @@
1
+ import type { ChatItem } from "../chat/events";
2
+ import { BadShare } from "../share/service";
3
+ import type { ExportRange } from "./view";
4
+
5
+ export interface Turn {
6
+ /** 1-based; 0 is whatever came before the first message. */
7
+ n: number;
8
+ items: ChatItem[];
9
+ }
10
+
11
+ /**
12
+ * Items (already `visible()`, in stored order) as turns. A turn is a user
13
+ * message and everything until the next one; a message sent while the agent
14
+ * was working stays inside the turn it landed in.
15
+ */
16
+ export function groupTurns(items: ChatItem[]): Turn[] {
17
+ const turns: Turn[] = [];
18
+ let n = 0;
19
+ for (const item of items) {
20
+ const starts = item.kind === "user" && !item.midTurn;
21
+ if (starts) {
22
+ n++;
23
+ turns.push({ n, items: [] });
24
+ } else if (turns.length === 0) {
25
+ turns.push({ n: 0, items: [] });
26
+ }
27
+ turns[turns.length - 1].items.push(item);
28
+ }
29
+ return turns;
30
+ }
31
+
32
+ type Numbered = { n: number; items: { kind: string }[] };
33
+
34
+ export const turnCount = (turns: Numbered[]) =>
35
+ turns.filter((t) => t.n > 0).length;
36
+
37
+ /** The inclusive turn numbers a range means, within 1..of. Throws BadShare. */
38
+ export function resolveRange(
39
+ turns: Numbered[],
40
+ range: ExportRange | undefined
41
+ ): { from: number; to: number; of: number } {
42
+ const of = turnCount(turns);
43
+ if (!range || of === 0) return { from: 1, to: Math.max(of, 1), of };
44
+ if ("last" in range) {
45
+ const k = Math.max(1, Math.min(of, Math.floor(range.last)));
46
+ return { from: of - k + 1, to: of, of };
47
+ }
48
+ if ("sinceCompaction" in range) {
49
+ let at = 0;
50
+ for (const t of turns)
51
+ if (t.items.some((i) => i.kind === "compacted")) at = Math.max(t.n, 1);
52
+ return { from: at || 1, to: of, of };
53
+ }
54
+ const { from, to } = range;
55
+ if (from < 1 || from > of)
56
+ throw new BadShare(`range starts at turn ${from}, there are ${of}`, 400);
57
+ if (to < from) throw new BadShare("range ends before it starts", 400);
58
+ return { from, to: Math.min(to, of), of };
59
+ }
60
+
61
+ /** Turn 0 (the start) rides along with a range that begins at turn 1. */
62
+ export const inRange = (n: number, sel: { from: number; to: number }) =>
63
+ n === 0 ? sel.from <= 1 : n >= sel.from && n <= sel.to;
@@ -0,0 +1,173 @@
1
+ // Wire types and helpers for session export. Imports nothing outside itself
2
+ // (bar the share helpers it reuses) so the sheet and the native app can use it.
3
+
4
+ import { fileName, formatBytes } from "../share/view";
5
+
6
+ export { formatBytes };
7
+
8
+ export type ExportFormat = "md" | "json" | "html";
9
+ export type ExportMode = "full" | "compact";
10
+ /** "all": mask paths, hosts and emails too. "secrets": credentials only. */
11
+ export type ExportRedact = "all" | "secrets";
12
+
13
+ export type ExportRange =
14
+ { from: number; to: number } | { last: number } | { sinceCompaction: true };
15
+
16
+ export interface ExportRequest {
17
+ format: ExportFormat;
18
+ mode: ExportMode;
19
+ range?: ExportRange;
20
+ redact: ExportRedact;
21
+ }
22
+
23
+ export const DEFAULT_REQUEST: ExportRequest = {
24
+ format: "md",
25
+ mode: "compact",
26
+ redact: "all",
27
+ };
28
+
29
+ export const EXPORT_FORMATS: {
30
+ id: ExportFormat;
31
+ label: string;
32
+ hint: string;
33
+ ext: string;
34
+ mime: string;
35
+ }[] = [
36
+ {
37
+ id: "md",
38
+ label: "Markdown",
39
+ hint: "Best for pasting into a chat",
40
+ ext: "md",
41
+ mime: "text/markdown; charset=utf-8",
42
+ },
43
+ {
44
+ id: "json",
45
+ label: "JSON",
46
+ hint: "For scripts and tools",
47
+ ext: "json",
48
+ mime: "application/json",
49
+ },
50
+ {
51
+ id: "html",
52
+ label: "HTML",
53
+ hint: "To read or upload as a page",
54
+ ext: "html",
55
+ mime: "text/html; charset=utf-8",
56
+ },
57
+ ];
58
+
59
+ export const formatInfo = (f: ExportFormat) =>
60
+ EXPORT_FORMATS.find((x) => x.id === f)!;
61
+
62
+ export const MAX_EXPORT_BYTES = 25 * 1024 * 1024;
63
+ export const WARN_TOKENS = 100_000;
64
+ export const DANGER_TOKENS = 1_000_000;
65
+ export const TOO_BIG =
66
+ "That export is over 25 MB. Narrow the range or use Compact.";
67
+
68
+ export type Hits = Record<"secret" | "path" | "ip" | "host" | "email", number>;
69
+
70
+ export interface ExportTurnInfo {
71
+ /** 0 is the part before the first message. */
72
+ n: number;
73
+ at: string;
74
+ /** The message that began the turn. */
75
+ id?: string;
76
+ excerpt: string;
77
+ items: number;
78
+ tokens: number;
79
+ }
80
+
81
+ export interface ExportWarning {
82
+ level: "info" | "warn" | "danger";
83
+ code:
84
+ | "sensitive"
85
+ | "paths-included"
86
+ | "full-output"
87
+ | "secrets-found"
88
+ | "large"
89
+ | "huge"
90
+ | "too-big";
91
+ text: string;
92
+ }
93
+
94
+ export interface Size {
95
+ bytes: number;
96
+ tokens: number;
97
+ }
98
+
99
+ export interface ExportEstimate {
100
+ title: string;
101
+ turns: ExportTurnInfo[];
102
+ selected: {
103
+ from: number;
104
+ to: number;
105
+ of: number;
106
+ turns: number;
107
+ startedAt: string | null;
108
+ endedAt: string | null;
109
+ };
110
+ /** The chosen format, in each mode. */
111
+ estimate: { full: Size; compact: Size };
112
+ hits: Hits;
113
+ omissions: Record<string, number>;
114
+ truncations: Record<string, number>;
115
+ warnings: ExportWarning[];
116
+ tooBig: boolean;
117
+ }
118
+
119
+ /** "all", "turn-12", "turns-12-18" for a file name. */
120
+ export function rangeSlug(sel: { from: number; to: number; of: number }) {
121
+ if (sel.from <= 1 && sel.to >= sel.of) return "all";
122
+ return sel.from === sel.to
123
+ ? `turn-${sel.from}`
124
+ : `turns-${sel.from}-${sel.to}`;
125
+ }
126
+
127
+ export function exportFileName(
128
+ title: string,
129
+ slug: string,
130
+ format: ExportFormat
131
+ ): string {
132
+ const ext = formatInfo(format).ext;
133
+ const base = fileName(title, "md").replace(/\.md$/, "");
134
+ return `${base}-${slug}.${ext}`;
135
+ }
136
+
137
+ /** "about 24k tokens" */
138
+ export function tokenLabel(tokens: number): string {
139
+ if (tokens < 1000) return `about ${tokens} tokens`;
140
+ if (tokens < 1_000_000) return `about ${Math.round(tokens / 1000)}k tokens`;
141
+ return `about ${(tokens / 1_000_000).toFixed(1)}M tokens`;
142
+ }
143
+
144
+ const KINDS: Record<keyof Hits, [string, string]> = {
145
+ secret: ["credential", "credentials"],
146
+ path: ["home path", "home paths"],
147
+ ip: ["address", "addresses"],
148
+ host: ["machine name", "machine names"],
149
+ email: ["email", "emails"],
150
+ };
151
+
152
+ /** "3 credentials, 88 home paths", or "" when nothing was masked. */
153
+ export function maskedList(hits: Hits): string {
154
+ return (Object.keys(KINDS) as (keyof Hits)[])
155
+ .filter((k) => hits[k] > 0)
156
+ .map((k) => `${hits[k]} ${KINDS[k][hits[k] === 1 ? 0 : 1]}`)
157
+ .join(", ");
158
+ }
159
+
160
+ export function redactionStatus(hits: Hits): string {
161
+ const list = maskedList(hits);
162
+ return list ? `Masked ${list}` : "Nothing needed masking";
163
+ }
164
+
165
+ export const COMPACT_RULES = [
166
+ "Thinking blocks are dropped (counted).",
167
+ "Tool and command output keeps the first 10 and last 20 lines, 2,000 characters at most.",
168
+ "Tool input is clipped to 300 characters; a shell command to 1,000.",
169
+ "File changes become the path and +added −removed.",
170
+ "Only the last to-do list of each turn is kept.",
171
+ "Pages the agent showed keep their title only.",
172
+ "A pasted block over 8,000 characters keeps its start and end.",
173
+ ];
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Starting a session in an agent role: which model its binding gives it,
3
+ * whether that model's server answers, and what to run it on. A local model
4
+ * that's down never turns into a cloud one by itself: only a fallback the
5
+ * user set for the role, and only when the start asks for it.
6
+ */
7
+
8
+ import { AGENT_ROLE_DEFS, type AgentRole } from "./agent-roles";
9
+ import { modelAvailability, choiceFor } from "./catalog";
10
+ import { savedModelSettings } from "./config";
11
+ import { PI } from "./providers";
12
+ import { resolveRole, type RoleSource, type RoutedModel } from "./resolve";
13
+
14
+ export interface RoleBinding {
15
+ role: AgentRole;
16
+ routed: RoutedModel;
17
+ // The user's fallback for the role, if set.
18
+ fallback?: string;
19
+ }
20
+
21
+ export function bindingFor(
22
+ role: AgentRole,
23
+ opts: {
24
+ workspaceId?: string | null;
25
+ projectId?: string | null;
26
+ // The model picked for this one session.
27
+ override?: string | null;
28
+ } = {}
29
+ ): RoleBinding {
30
+ const modelRole = AGENT_ROLE_DEFS[role].modelRole;
31
+ const routed = resolveRole(modelRole, { ...opts, allowPi: true });
32
+ return {
33
+ role,
34
+ routed,
35
+ fallback: savedModelSettings().fallbacks?.[modelRole],
36
+ };
37
+ }
38
+
39
+ export class ModelUnavailableError extends Error {
40
+ readonly code = "model_unavailable";
41
+ constructor(
42
+ readonly role: AgentRole,
43
+ readonly ref: string,
44
+ readonly detail: string,
45
+ readonly fallback?: string
46
+ ) {
47
+ super(
48
+ `${AGENT_ROLE_DEFS[role].label}'s model ${ref} isn't answering${detail ? ` (${detail})` : ""}`
49
+ );
50
+ }
51
+ }
52
+
53
+ export interface RoleStart {
54
+ role: AgentRole;
55
+ routed: RoutedModel;
56
+ // "pi" runs the Pi agent on the model; "claude" runs Claude Code.
57
+ agentType: "claude" | "pi";
58
+ // What the agent CLI is given as its model.
59
+ model: string;
60
+ source: RoleSource | "fallback";
61
+ }
62
+
63
+ const startFrom = (
64
+ role: AgentRole,
65
+ routed: RoutedModel,
66
+ source: RoleStart["source"]
67
+ ): RoleStart =>
68
+ routed.provider.id === PI
69
+ ? {
70
+ role,
71
+ routed,
72
+ agentType: "pi",
73
+ model: routed.ref.slice(PI.length + 1),
74
+ source,
75
+ }
76
+ : { role, routed, agentType: "claude", model: routed.model, source };
77
+
78
+ // The model a session started in `role` runs on, after checking its server.
79
+ export async function prepareRoleStart(
80
+ role: AgentRole,
81
+ opts: Parameters<typeof bindingFor>[1] & { useFallback?: boolean }
82
+ ): Promise<RoleStart> {
83
+ const binding = bindingFor(role, opts);
84
+ const state = await modelAvailability(binding.routed.ref);
85
+ if (state.state !== "down")
86
+ return startFrom(role, binding.routed, binding.routed.source);
87
+ if (opts.useFallback && binding.fallback) {
88
+ const alt = bindingFor(role, { ...opts, override: binding.fallback });
89
+ const altState = await modelAvailability(alt.routed.ref);
90
+ if (altState.state !== "down")
91
+ return startFrom(role, alt.routed, "fallback");
92
+ }
93
+ throw new ModelUnavailableError(
94
+ role,
95
+ binding.routed.ref,
96
+ state.error ?? "",
97
+ binding.fallback &&
98
+ choiceFor(binding.fallback)?.availability.state !== "down"
99
+ ? binding.fallback
100
+ : undefined
101
+ );
102
+ }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * What the Agent roles screen and the native app show: each role, the model
3
+ * saved at a scope, the model in force there and where it came from, and
4
+ * what the server knows of that model (local or cloud, answering or not).
5
+ * Saving checks what a role may run on.
6
+ */
7
+
8
+ import {
9
+ AGENT_ROLES,
10
+ AGENT_ROLE_DEFS,
11
+ INTERNAL_MODEL_ROLES,
12
+ type AgentRole,
13
+ } from "./agent-roles";
14
+ import { choiceFor, type ModelChoice } from "./catalog";
15
+ import {
16
+ effectiveModels,
17
+ projectModels,
18
+ savedModelSettings,
19
+ setModelSettings,
20
+ setProjectModels,
21
+ setWorkspaceModels,
22
+ workspaceModels,
23
+ type RoleMap,
24
+ } from "./config";
25
+ import { PI } from "./providers";
26
+ import { resolveRole, type RoleSource } from "./resolve";
27
+ import {
28
+ DEFAULT_ROLE_MODELS,
29
+ GATE_ROLES,
30
+ MODEL_ROLES,
31
+ ROLE_LABELS,
32
+ parseModelRef,
33
+ type ModelRole,
34
+ } from "./roles";
35
+
36
+ export type RoleScope = "settings" | "workspace" | "project";
37
+ export interface ScopeRef {
38
+ scope: RoleScope;
39
+ id?: string | null;
40
+ }
41
+
42
+ export interface RoleView {
43
+ modelRole: ModelRole;
44
+ label: string;
45
+ hint: string;
46
+ // Saved at this scope, or null when it inherits.
47
+ value: string | null;
48
+ binding: { ref: string; source: RoleSource } | null;
49
+ fallback: string | null;
50
+ choice: ModelChoice | null;
51
+ // Why the binding can't be used (a provider turned off).
52
+ error?: string;
53
+ default: string;
54
+ }
55
+
56
+ export interface AgentRoleView extends RoleView {
57
+ id: AgentRole;
58
+ purpose: string;
59
+ brief: string;
60
+ edits: boolean;
61
+ }
62
+
63
+ function savedAt({ scope, id }: ScopeRef): RoleMap {
64
+ if (scope === "workspace" && id) return workspaceModels(id).roles ?? {};
65
+ if (scope === "project" && id) return projectModels(id).roles ?? {};
66
+ return savedModelSettings().roles ?? {};
67
+ }
68
+
69
+ function viewOf(modelRole: ModelRole, at: ScopeRef, ctx: ContextIds): RoleView {
70
+ const effective = effectiveModels();
71
+ let binding: RoleView["binding"] = null;
72
+ let error: string | undefined;
73
+ try {
74
+ const r = resolveRole(modelRole, { ...ctx, allowPi: true }, effective);
75
+ binding = { ref: r.ref, source: r.source };
76
+ } catch (e) {
77
+ error = e instanceof Error ? e.message : String(e);
78
+ }
79
+ return {
80
+ modelRole,
81
+ label: ROLE_LABELS[modelRole].label,
82
+ hint: ROLE_LABELS[modelRole].hint,
83
+ value: savedAt(at)[modelRole] ?? null,
84
+ binding,
85
+ fallback: savedModelSettings().fallbacks?.[modelRole] ?? null,
86
+ choice: binding ? (choiceFor(binding.ref, effective) ?? null) : null,
87
+ ...(error ? { error } : {}),
88
+ default: DEFAULT_ROLE_MODELS[modelRole],
89
+ };
90
+ }
91
+
92
+ interface ContextIds {
93
+ workspaceId?: string | null;
94
+ projectId?: string | null;
95
+ }
96
+
97
+ const contextOf = ({ scope, id }: ScopeRef): ContextIds =>
98
+ scope === "workspace"
99
+ ? { workspaceId: id }
100
+ : scope === "project"
101
+ ? { projectId: id }
102
+ : {};
103
+
104
+ export function agentRolesView(at: ScopeRef, ctx?: ContextIds) {
105
+ const ids = ctx ?? contextOf(at);
106
+ return {
107
+ roles: AGENT_ROLES.map((id): AgentRoleView => ({
108
+ ...viewOf(AGENT_ROLE_DEFS[id].modelRole, at, ids),
109
+ id,
110
+ label: AGENT_ROLE_DEFS[id].label,
111
+ hint: AGENT_ROLE_DEFS[id].purpose,
112
+ purpose: AGENT_ROLE_DEFS[id].purpose,
113
+ brief: AGENT_ROLE_DEFS[id].brief,
114
+ edits: AGENT_ROLE_DEFS[id].edits,
115
+ })),
116
+ internal: INTERNAL_MODEL_ROLES(MODEL_ROLES).map((r) => viewOf(r, at, ids)),
117
+ };
118
+ }
119
+
120
+ // The models a role can be bound to: a Pi model runs a role session only,
121
+ // never a task, a gate or the orchestrator.
122
+ const PI_ROLES: readonly ModelRole[] = ["implement", "analyze", "advise"];
123
+
124
+ function checkRoles(roles: RoleMap): void {
125
+ for (const [role, ref] of Object.entries(roles) as [ModelRole, string][]) {
126
+ if (parseModelRef(ref).provider !== PI) continue;
127
+ if (!PI_ROLES.includes(role))
128
+ throw new Error(
129
+ `${ROLE_LABELS[role].label} can't run on a Pi model (${GATE_ROLES.includes(role) ? "a merge gate needs Claude Code" : "only the Developer, Analyst and Reviewer can"})`
130
+ );
131
+ }
132
+ }
133
+
134
+ export function saveAgentRoles(
135
+ at: ScopeRef,
136
+ roles: RoleMap,
137
+ fallbacks?: RoleMap
138
+ ): void {
139
+ checkRoles(roles);
140
+ if (fallbacks) {
141
+ for (const ref of Object.values(fallbacks))
142
+ if (ref && parseModelRef(ref).provider === PI)
143
+ throw new Error("A fallback must be a cloud or Claude Code model");
144
+ }
145
+ if (at.scope === "settings") {
146
+ const saved = savedModelSettings();
147
+ setModelSettings({
148
+ ...saved,
149
+ roles,
150
+ ...(fallbacks ? { fallbacks } : {}),
151
+ });
152
+ return;
153
+ }
154
+ if (!at.id) throw new Error(`${at.scope} needs an id`);
155
+ if (at.scope === "workspace") setWorkspaceModels(at.id, { roles });
156
+ else setProjectModels(at.id, { roles });
157
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * The roles a person assigns models to: Developer, Analyst, Reviewer and
3
+ * Supervisor. Each binds one of the model roles in ./roles, so there is one
4
+ * routing system: Developer is `implement`, Supervisor is `orchestrator`,
5
+ * and Analyst and Reviewer have a model role of their own. Imported by the
6
+ * browser too, so no Node modules here.
7
+ */
8
+
9
+ import type { ModelRole } from "./roles";
10
+
11
+ export const AGENT_ROLES = [
12
+ "developer",
13
+ "analyst",
14
+ "reviewer",
15
+ "supervisor",
16
+ ] as const;
17
+
18
+ export type AgentRole = (typeof AGENT_ROLES)[number];
19
+
20
+ export interface AgentRoleDef {
21
+ id: AgentRole;
22
+ label: string;
23
+ // The one line shown beside its model.
24
+ purpose: string;
25
+ modelRole: ModelRole;
26
+ // Appended to the instructions of a session started in the role.
27
+ brief: string;
28
+ // Whether it may change files.
29
+ edits: boolean;
30
+ }
31
+
32
+ export const AGENT_ROLE_DEFS: Record<AgentRole, AgentRoleDef> = {
33
+ developer: {
34
+ id: "developer",
35
+ label: "Developer",
36
+ purpose: "Writes the code: tasks, and sessions started as Developer",
37
+ modelRole: "implement",
38
+ edits: true,
39
+ brief:
40
+ "You are the Developer in this crew. You write and change the code. When your work is ready, tell the Reviewer with `aos send @reviewer`; answer the Analyst's questions with `aos send @analyst`.",
41
+ },
42
+ analyst: {
43
+ id: "analyst",
44
+ label: "Analyst",
45
+ purpose:
46
+ "Reads code, logs and the Developer's work; explains and proposes; doesn't commit",
47
+ modelRole: "analyze",
48
+ edits: false,
49
+ brief:
50
+ "You are the Analyst in this crew. You read code, logs and data, and the Developer's transcript (`aos read @developer`), then explain what you find and propose changes. You don't edit files, commit or open pull requests; send proposals to the Developer with `aos send @developer`.",
51
+ },
52
+ reviewer: {
53
+ id: "reviewer",
54
+ label: "Reviewer",
55
+ purpose:
56
+ "Reviews another role's diff and suggests improvements. Advice only: not the merge gate",
57
+ modelRole: "advise",
58
+ edits: false,
59
+ brief:
60
+ "You are the Reviewer in this crew. Read the Developer's diff (`git diff` in its worktree, `aos read @developer`) and send concrete suggestions with `aos send @developer`. You don't edit files or open pull requests. Your review is advice; the merge gate is separate.",
61
+ },
62
+ supervisor: {
63
+ id: "supervisor",
64
+ label: "Supervisor",
65
+ purpose:
66
+ "Watches a crew, sequences work, unblocks and reports. The workspace orchestrator",
67
+ modelRole: "orchestrator",
68
+ edits: false,
69
+ brief:
70
+ "You are the Supervisor in this crew. Watch the other roles (`aos peers`, `aos read @developer`), unblock them, sequence the work and report to the user. You don't write the code yourself.",
71
+ },
72
+ };
73
+
74
+ export const isAgentRole = (v: unknown): v is AgentRole =>
75
+ typeof v === "string" && (AGENT_ROLES as readonly string[]).includes(v);
76
+
77
+ export const agentRoleOf = (modelRole: ModelRole): AgentRole | undefined =>
78
+ AGENT_ROLES.find((r) => AGENT_ROLE_DEFS[r].modelRole === modelRole);
79
+
80
+ // The model roles under the four above, shown as "internal" roles.
81
+ export const INTERNAL_MODEL_ROLES = (all: readonly ModelRole[]) =>
82
+ all.filter((r) => !agentRoleOf(r));
83
+
84
+ // A session's brief for its role, or "" with none.
85
+ export function roleBrief(role: string | null | undefined): string {
86
+ return isAgentRole(role) ? AGENT_ROLE_DEFS[role].brief : "";
87
+ }
@@ -0,0 +1,17 @@
1
+ import { modelCatalog } from "./catalog";
2
+ import { modelAvailability as probeRef } from "./catalog";
3
+ import type { Availability } from "./availability";
4
+
5
+ // Every local model's availability by ref, probing the stale or all of them.
6
+ export async function modelAvailability(
7
+ refresh = false
8
+ ): Promise<Record<string, Availability>> {
9
+ const local = modelCatalog().filter((c) => c.locality === "local");
10
+ const out: Record<string, Availability> = {};
11
+ await Promise.all(
12
+ local.map(async (c) => {
13
+ out[c.ref] = await probeRef(c.ref, undefined, refresh);
14
+ })
15
+ );
16
+ return out;
17
+ }