@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,91 @@
1
+ /**
2
+ * Whether a model's server answers. Only endpoints on the local networks
3
+ * are probed (a cloud provider's key isn't proof and a probe costs), one
4
+ * short GET, cached for a while. Any HTTP answer means the server is up;
5
+ * what's down is a refused connection or no answer in time.
6
+ */
7
+
8
+ export type AvailabilityState = "up" | "down" | "unknown";
9
+
10
+ export interface Availability {
11
+ state: AvailabilityState;
12
+ checkedAt: string | null;
13
+ error?: string;
14
+ }
15
+
16
+ const TTL_MS = 30_000;
17
+ const TIMEOUT_MS = 1_500;
18
+
19
+ interface Entry extends Availability {
20
+ at: number;
21
+ }
22
+ const cache = new Map<string, Entry>();
23
+ const probing = new Map<string, Promise<Entry>>();
24
+
25
+ const UNKNOWN: Availability = { state: "unknown", checkedAt: null };
26
+
27
+ // What a server of that API lists its models at.
28
+ export const probeUrl = (baseUrl: string, api: "anthropic" | "openai") => {
29
+ const base = baseUrl.replace(/\/+$/, "");
30
+ return api === "anthropic" && !/\/v1$/.test(base)
31
+ ? `${base}/v1/models`
32
+ : `${base}/models`;
33
+ };
34
+
35
+ async function probe(url: string): Promise<Entry> {
36
+ const at = Date.now();
37
+ try {
38
+ await fetch(url, {
39
+ method: "GET",
40
+ signal: AbortSignal.timeout(TIMEOUT_MS),
41
+ redirect: "manual",
42
+ });
43
+ return { state: "up", checkedAt: new Date(at).toISOString(), at };
44
+ } catch (error) {
45
+ const cause = (error as { cause?: { code?: string } }).cause?.code;
46
+ const timedOut = (error as Error).name === "TimeoutError";
47
+ return {
48
+ state: "down",
49
+ checkedAt: new Date(at).toISOString(),
50
+ at,
51
+ error: timedOut
52
+ ? `no answer in ${TIMEOUT_MS / 1000}s`
53
+ : (cause ?? (error as Error).message),
54
+ };
55
+ }
56
+ }
57
+
58
+ const strip = ({ at: _at, ...rest }: Entry): Availability => rest;
59
+
60
+ // The last check of a URL, probing in the background when it's stale: a
61
+ // request handler never waits on a probe.
62
+ export function cachedAvailability(url: string): Availability {
63
+ const hit = cache.get(url);
64
+ if (!hit || Date.now() - hit.at >= TTL_MS) void checkAvailability(url);
65
+ return hit ? strip(hit) : UNKNOWN;
66
+ }
67
+
68
+ // A fresh enough answer, waiting for the probe if there isn't one.
69
+ export async function checkAvailability(
70
+ url: string,
71
+ refresh = false
72
+ ): Promise<Availability> {
73
+ const hit = cache.get(url);
74
+ if (!refresh && hit && Date.now() - hit.at < TTL_MS) return strip(hit);
75
+ let running = probing.get(url);
76
+ if (!running) {
77
+ running = probe(url)
78
+ .then((e) => {
79
+ cache.set(url, e);
80
+ return e;
81
+ })
82
+ .finally(() => probing.delete(url));
83
+ probing.set(url, running);
84
+ }
85
+ return strip(await running);
86
+ }
87
+
88
+ export function clearAvailability(): void {
89
+ cache.clear();
90
+ probing.clear();
91
+ }
@@ -0,0 +1,156 @@
1
+ /**
2
+ * The one list of models every picker shows, built on the server: each
3
+ * enabled provider's models, and the models Pi reaches (local servers in its
4
+ * models.json). Nothing about a model, provider or its locality is decided
5
+ * in the browser.
6
+ */
7
+
8
+ import fs from "fs";
9
+ import os from "os";
10
+ import path from "path";
11
+ import { effectiveModels, type EffectiveModels } from "./config";
12
+ import { ANTHROPIC } from "./roles";
13
+ import { PI, type ModelProvider } from "./providers";
14
+ import { hostLocality, type Locality } from "./locality";
15
+ import {
16
+ cachedAvailability,
17
+ checkAvailability,
18
+ probeUrl,
19
+ type Availability,
20
+ } from "./availability";
21
+
22
+ export interface ModelChoice {
23
+ // "anthropic/claude-opus-5-5", "pi/local/qwen3-27b"
24
+ ref: string;
25
+ label: string;
26
+ // The provider's name: "Anthropic", "DeepSeek", "Pi · local".
27
+ group: string;
28
+ // What runs it: Claude Code, or the Pi agent.
29
+ agent: "claude" | "pi";
30
+ locality: Locality;
31
+ availability: Availability;
32
+ // May serve a merge gate: Claude Code can run it.
33
+ gateCapable: boolean;
34
+ // Where the server is (never a key).
35
+ endpoint?: string;
36
+ }
37
+
38
+ interface PiProvider {
39
+ id: string;
40
+ baseUrl: string;
41
+ models: { id: string; name?: string }[];
42
+ }
43
+
44
+ const piModelsFile = () =>
45
+ process.env.AGENTOS_PI_MODELS_FILE ||
46
+ path.join(os.homedir(), ".pi", "agent", "models.json");
47
+
48
+ // The providers in Pi's own config. A missing or unreadable file means Pi
49
+ // has no servers of ours to list.
50
+ export function piProviders(file = piModelsFile()): PiProvider[] {
51
+ let raw: unknown;
52
+ try {
53
+ raw = JSON.parse(fs.readFileSync(file, "utf8"));
54
+ } catch {
55
+ return [];
56
+ }
57
+ const providers = (raw as { providers?: Record<string, unknown> })?.providers;
58
+ if (!providers || typeof providers !== "object") return [];
59
+ const out: PiProvider[] = [];
60
+ for (const [id, p] of Object.entries(providers)) {
61
+ const q = p as { baseUrl?: unknown; models?: unknown };
62
+ if (typeof q?.baseUrl !== "string" || !Array.isArray(q.models)) continue;
63
+ const models = q.models
64
+ .map((m) =>
65
+ typeof m === "string"
66
+ ? { id: m }
67
+ : {
68
+ id: (m as { id?: unknown })?.id,
69
+ name: (m as { name?: unknown })?.name,
70
+ }
71
+ )
72
+ .filter(
73
+ (m): m is { id: string; name?: string } => typeof m.id === "string"
74
+ )
75
+ .map((m) => ({
76
+ id: m.id,
77
+ ...(typeof m.name === "string" ? { name: m.name } : {}),
78
+ }));
79
+ out.push({ id, baseUrl: q.baseUrl, models });
80
+ }
81
+ return out;
82
+ }
83
+
84
+ const providerEndpoint = (p: ModelProvider) =>
85
+ p.baseUrl ? probeUrl(p.baseUrl, p.api) : undefined;
86
+
87
+ export function modelCatalog(
88
+ effective: EffectiveModels = effectiveModels(),
89
+ pi: PiProvider[] = piProviders()
90
+ ): ModelChoice[] {
91
+ const out: ModelChoice[] = [];
92
+ for (const p of effective.providers.filter((x) => x.enabled)) {
93
+ const endpoint = providerEndpoint(p);
94
+ const locality = hostLocality(p.baseUrl);
95
+ const availability: Availability =
96
+ p.id === ANTHROPIC
97
+ ? { state: "up", checkedAt: null }
98
+ : locality === "local" && endpoint
99
+ ? cachedAvailability(endpoint)
100
+ : { state: "unknown", checkedAt: null };
101
+ for (const m of p.models)
102
+ out.push({
103
+ ref: `${p.id}/${m}`,
104
+ label: m,
105
+ group: p.label,
106
+ agent: "claude",
107
+ locality,
108
+ availability,
109
+ gateCapable: p.api === "anthropic",
110
+ endpoint: p.baseUrl,
111
+ });
112
+ }
113
+ for (const p of pi) {
114
+ const locality = hostLocality(p.baseUrl);
115
+ const endpoint = probeUrl(p.baseUrl, "openai");
116
+ const availability: Availability =
117
+ locality === "local"
118
+ ? cachedAvailability(endpoint)
119
+ : { state: "unknown", checkedAt: null };
120
+ for (const m of p.models)
121
+ out.push({
122
+ ref: `${PI}/${p.id}/${m.id}`,
123
+ label: m.name ?? m.id,
124
+ group: `Pi · ${p.id}`,
125
+ agent: "pi",
126
+ locality,
127
+ availability,
128
+ gateCapable: false,
129
+ endpoint: p.baseUrl,
130
+ });
131
+ }
132
+ return out;
133
+ }
134
+
135
+ export const choiceFor = (
136
+ ref: string,
137
+ effective?: EffectiveModels
138
+ ): ModelChoice | undefined =>
139
+ modelCatalog(effective).find((c) => c.ref === ref);
140
+
141
+ // Whether the model's server answers now, waiting for a probe. Cloud and
142
+ // unlisted models aren't probed: they count as available.
143
+ export async function modelAvailability(
144
+ ref: string,
145
+ effective?: EffectiveModels,
146
+ refresh = false
147
+ ): Promise<Availability> {
148
+ const choice = choiceFor(ref, effective);
149
+ if (!choice || choice.locality !== "local" || !choice.endpoint)
150
+ return { state: "unknown", checkedAt: null };
151
+ const url =
152
+ choice.agent === "pi"
153
+ ? probeUrl(choice.endpoint, "openai")
154
+ : probeUrl(choice.endpoint, "anthropic");
155
+ return checkAvailability(url, refresh);
156
+ }
@@ -138,25 +138,35 @@ const subagentMap = z
138
138
  });
139
139
  });
140
140
 
141
+ // A role may name a model to start on when its own can't be reached.
142
+ const fallbackMap = z.partialRecord(z.enum(MODEL_ROLES), roleRef);
143
+
141
144
  export const modelSettingsSchema = z.strictObject({
142
145
  roles: roleMap.optional(),
143
146
  subagents: subagentMap.optional(),
144
147
  providers: z.array(providerSchema).optional(),
145
148
  prices: z.record(z.string().regex(REF_PATTERN), price).optional(),
146
149
  planStep: z.boolean().optional(),
150
+ fallbacks: fallbackMap.optional(),
147
151
  });
148
152
 
149
153
  export const workspaceModelsSchema = z.strictObject({
150
154
  roles: roleMap.optional(),
151
155
  });
152
156
 
157
+ export const projectModelsSchema = z.strictObject({
158
+ roles: roleMap.optional(),
159
+ });
160
+
153
161
  export type ModelSettings = z.infer<typeof modelSettingsSchema>;
154
162
  export type WorkspaceModels = z.infer<typeof workspaceModelsSchema>;
163
+ export type ProjectModels = z.infer<typeof projectModelsSchema>;
155
164
  export type RoleMap = Partial<Record<ModelRole, string>>;
156
165
  export const roleMapSchema = roleMap;
157
166
 
158
167
  const GLOBAL_KEY = "models";
159
168
  const workspaceKey = (id: string) => `models.workspace.${id}`;
169
+ const projectKey = (id: string) => `models.project.${id}`;
160
170
 
161
171
  function read<T>(key: string, schema: z.ZodType<T>): T | null {
162
172
  const row = db
@@ -221,6 +231,23 @@ export function setWorkspaceModels(
221
231
  return saved;
222
232
  }
223
233
 
234
+ // Set in Settings, never in the repo, so work can't choose its own models.
235
+ export const projectModels = (projectId: string): ProjectModels =>
236
+ read(projectKey(projectId), projectModelsSchema) ?? {};
237
+
238
+ export function setProjectModels(
239
+ projectId: string,
240
+ value: unknown
241
+ ): ProjectModels {
242
+ const saved = write(projectKey(projectId), projectModelsSchema, value);
243
+ if (!Object.keys(saved.roles ?? {}).length) clearProjectModels(projectId);
244
+ return saved;
245
+ }
246
+
247
+ export const clearProjectModels = (projectId: string): void => {
248
+ db.prepare(`DELETE FROM settings WHERE key = ?`).run(projectKey(projectId));
249
+ };
250
+
224
251
  // The settings in force: the presets with any saved edits laid over them
225
252
  // (by id), saved providers that aren't presets, and every role filled.
226
253
  export interface EffectiveModels {
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Whether a model endpoint is on this machine's own networks (local) or
3
+ * out on the internet (cloud), read from its host, never from a name.
4
+ * Imported by the browser too.
5
+ */
6
+
7
+ export type Locality = "local" | "cloud";
8
+
9
+ const PRIVATE_V4 = [
10
+ /^127\./,
11
+ /^10\./,
12
+ /^192\.168\./,
13
+ /^172\.(1[6-9]|2\d|3[01])\./,
14
+ /^169\.254\./,
15
+ // The tailnet: 100.64.0.0/10.
16
+ /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./,
17
+ ];
18
+
19
+ export function hostLocality(url: string | null | undefined): Locality {
20
+ if (!url) return "cloud";
21
+ let host: string;
22
+ try {
23
+ host = new URL(url).hostname.toLowerCase();
24
+ } catch {
25
+ return "cloud";
26
+ }
27
+ host = host.replace(/^\[|\]$/g, "");
28
+ if (host === "localhost" || host === "::1" || host.endsWith(".localhost"))
29
+ return "local";
30
+ if (host.endsWith(".local") || host.endsWith(".ts.net")) return "local";
31
+ if (/^f[cd][0-9a-f]{2}:/.test(host)) return "local";
32
+ return PRIVATE_V4.some((re) => re.test(host)) ? "local" : "cloud";
33
+ }
@@ -63,6 +63,17 @@ export const ANTHROPIC_PROVIDER: ModelProvider = {
63
63
  enabled: true,
64
64
  };
65
65
 
66
+ // Pi's own models ("pi/<its provider>/<model>"): local servers Pi reaches,
67
+ // run by the Pi agent rather than through Claude Code's endpoint variables.
68
+ export const PI = "pi";
69
+ export const PI_PROVIDER: ModelProvider = {
70
+ id: PI,
71
+ label: "Pi",
72
+ api: "openai",
73
+ models: [],
74
+ enabled: true,
75
+ };
76
+
66
77
  // Checked against each provider's docs on 2026-10-11: DeepSeek
67
78
  // (api-docs.deepseek.com/guides/anthropic_api) and Z.ai
68
79
  // (docs.z.ai/devpack/tool/claude). Off until a key reference is set.
@@ -12,11 +12,17 @@ import {
12
12
  effectiveModels,
13
13
  roleMapSchema,
14
14
  savedModelSettings,
15
+ projectModels,
15
16
  workspaceModels,
16
17
  type EffectiveModels,
17
18
  type RoleMap,
18
19
  } from "./config";
19
- import { ANTHROPIC_PROVIDER, type ModelProvider } from "./providers";
20
+ import {
21
+ ANTHROPIC_PROVIDER,
22
+ PI,
23
+ PI_PROVIDER,
24
+ type ModelProvider,
25
+ } from "./providers";
20
26
  import {
21
27
  ANTHROPIC,
22
28
  DEFAULT_ROLE_MODELS,
@@ -33,7 +39,14 @@ import {
33
39
  type SecretCommand,
34
40
  } from "./secrets";
35
41
 
36
- export type RoleSource = "task" | "workspace" | "settings" | "env" | "default";
42
+ export type RoleSource =
43
+ | "task"
44
+ | "project"
45
+ | "workspace"
46
+ | "settings"
47
+ | "env"
48
+ | "inherited"
49
+ | "default";
37
50
 
38
51
  // The server's own environment can still stand in for a role's default:
39
52
  // what AgentOS read before model routing existed.
@@ -87,9 +100,15 @@ const overrideFor = (o: ModelOverride | null | undefined, role: ModelRole) =>
87
100
  // The provider by id, or throws why it can't be used.
88
101
  export function providerFor(
89
102
  id: string,
90
- models: EffectiveModels = effectiveModels()
103
+ models: EffectiveModels = effectiveModels(),
104
+ allowPi = false
91
105
  ): ModelProvider {
92
106
  if (id === ANTHROPIC) return ANTHROPIC_PROVIDER;
107
+ if (id === PI) {
108
+ if (!allowPi)
109
+ throw new Error("Pi models run only in a session started in a role");
110
+ return PI_PROVIDER;
111
+ }
93
112
  const p = models.providers.find((x) => x.id === id);
94
113
  if (!p) throw new Error(`No model provider "${id}" (Settings > Models)`);
95
114
  if (!p.enabled) throw new Error(`The ${p.label} provider is turned off`);
@@ -104,10 +123,19 @@ export function providerFor(
104
123
 
105
124
  export function resolveRole(
106
125
  role: ModelRole,
107
- opts: { workspaceId?: string | null; override?: ModelOverride | null } = {},
126
+ opts: {
127
+ workspaceId?: string | null;
128
+ projectId?: string | null;
129
+ override?: ModelOverride | null;
130
+ // A role session runs Pi models; a task or gate can't.
131
+ allowPi?: boolean;
132
+ } = {},
108
133
  models: EffectiveModels = effectiveModels()
109
134
  ): RoutedModel {
110
135
  const task = overrideFor(opts.override, role);
136
+ const proj = opts.projectId
137
+ ? projectModels(opts.projectId).roles?.[role]
138
+ : undefined;
111
139
  const ws = opts.workspaceId
112
140
  ? workspaceModels(opts.workspaceId).roles?.[role]
113
141
  : undefined;
@@ -117,20 +145,30 @@ export function resolveRole(
117
145
  saved === DEFAULT_ROLE_MODELS[role] && !savedModelSettings().roles?.[role]
118
146
  ? envModel(role)
119
147
  : undefined;
148
+ // The reviewer's advice follows the gate's model until it's set.
149
+ const named = !!savedModelSettings().roles?.[role];
150
+ const inherited =
151
+ role === "advise" && !env && !named
152
+ ? resolveRole("review", { ...opts, override: null }, models)
153
+ : null;
120
154
  const [ref, source]: [string, RoleSource] = task
121
155
  ? [task, "task"]
122
- : ws
123
- ? [ws, "workspace"]
124
- : saved !== DEFAULT_ROLE_MODELS[role]
125
- ? [saved, "settings"]
126
- : env
127
- ? [env, "env"]
128
- : [saved, "default"];
156
+ : proj
157
+ ? [proj, "project"]
158
+ : ws
159
+ ? [ws, "workspace"]
160
+ : saved !== DEFAULT_ROLE_MODELS[role] || (role === "advise" && named)
161
+ ? [saved, "settings"]
162
+ : env
163
+ ? [env, "env"]
164
+ : inherited
165
+ ? [inherited.ref, "inherited"]
166
+ : [saved, "default"];
129
167
  const parsed = parseModelRef(ref);
130
168
  return {
131
169
  role,
132
170
  ref: formatModelRef(parsed),
133
- provider: providerFor(parsed.provider, models),
171
+ provider: providerFor(parsed.provider, models, opts.allowPi),
134
172
  model: parsed.model,
135
173
  source,
136
174
  };
@@ -9,6 +9,8 @@
9
9
  export const MODEL_ROLES = [
10
10
  "plan",
11
11
  "implement",
12
+ "analyze",
13
+ "advise",
12
14
  "orchestrator",
13
15
  "review",
14
16
  "scope",
@@ -28,6 +30,14 @@ export const ROLE_LABELS: Record<ModelRole, { label: string; hint: string }> = {
28
30
  label: "Task implementation",
29
31
  hint: "A task's agent, following the plan in its worktree",
30
32
  },
33
+ analyze: {
34
+ label: "Analyst",
35
+ hint: "Reads code, logs and a developer's work; explains and proposes",
36
+ },
37
+ advise: {
38
+ label: "Reviewer",
39
+ hint: "Advises on another role's diff. Not the merge gate",
40
+ },
31
41
  orchestrator: { label: "Orchestrator", hint: "A workspace's orchestrator" },
32
42
  review: {
33
43
  label: "Gate review",
@@ -58,6 +68,8 @@ export const DEFAULT_ROLE_MODELS: Record<ModelRole, string> = {
58
68
  plan: OPUS,
59
69
  review: OPUS,
60
70
  implement: SONNET,
71
+ analyze: OPUS,
72
+ advise: OPUS,
61
73
  orchestrator: OPUS,
62
74
  scope: SONNET,
63
75
  interactive: OPUS,
package/lib/needs-you.ts CHANGED
@@ -17,7 +17,7 @@ export function markSeen(sessionId: string): void {
17
17
  notifyStatusChanged();
18
18
  }
19
19
 
20
- function latestChatItem(sessionId: string): ChatItem | null {
20
+ export function latestChatItem(sessionId: string): ChatItem | null {
21
21
  const row = getDb()
22
22
  .prepare(
23
23
  `SELECT data FROM chat_items WHERE session_id = ? ORDER BY seq DESC LIMIT 1`
@@ -13,7 +13,7 @@ import { peerStatus } from "../hosts/peer-sessions";
13
13
  import { chatCompacting, chatState } from "../chat/runner";
14
14
  import { chatActivityLine, chatStall, type Stall } from "../chat/activity";
15
15
  import { lastUserTask } from "../chat/store";
16
- import { needsYou } from "../needs-you";
16
+ import { latestChatItem, needsYou } from "../needs-you";
17
17
  import { programSummary } from "../program-status/store";
18
18
  import { sessionRowInfo } from "../session-meta";
19
19
  import { taskView, type TaskPR, type TaskState } from "../tasks";
@@ -170,9 +170,14 @@ export async function statusOf(s: Session): Promise<{
170
170
  const state = chatState(s.id);
171
171
  const status: FactStatus =
172
172
  state === "running" ? "running" : needsYou(s, state) ? "waiting" : "idle";
173
+ const latest = status === "running" ? null : latestChatItem(s.id);
174
+ const early =
175
+ latest?.kind === "turn_end" && !latest.interrupted ? latest.ended : null;
173
176
  return {
174
177
  status,
175
- activity: chatActivityLine(s.id) ?? lastUserTask(s.id),
178
+ activity: early
179
+ ? `ended its turn early (${early.reason}): the task is not finished`
180
+ : (chatActivityLine(s.id) ?? lastUserTask(s.id)),
176
181
  needsInput: state === "waiting",
177
182
  };
178
183
  }
@@ -91,7 +91,11 @@ function itemText(i: ChatItem): string | null {
91
91
  case "error":
92
92
  return `[error] ${i.message}`;
93
93
  case "turn_end":
94
- return i.interrupted ? "[turn stopped]" : "[turn ended]";
94
+ return i.interrupted
95
+ ? "[turn stopped]"
96
+ : i.ended
97
+ ? `[turn ended early: ${i.ended.reason}] ${i.ended.notice}`
98
+ : "[turn ended]";
95
99
  case "command_output":
96
100
  return i.text;
97
101
  case "note":
@@ -31,6 +31,10 @@ export const looksRandom = (s: string) =>
31
31
  export const SECRET_ASSIGNMENT =
32
32
  /^(\s*(?:export\s+)?[A-Z][A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASS|PWD|CREDENTIALS?|AUTH)[A-Z0-9_]*\s*[=:]\s*).+$/gm;
33
33
 
34
+ // The same assignment inline, ahead of a command: API_TOKEN=... npm test.
35
+ const INLINE_ASSIGNMENT =
36
+ /\b([A-Z][A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASS|PWD|CREDENTIALS?|AUTH)[A-Z0-9_]*=)(?!\[redacted\])("[^"]*"|'[^']*'|\S+)/g;
37
+
34
38
  // A URL's userinfo (https://key@host, postgres://user:pw@host) and a query
35
39
  // parameter that carries a credential (?token=, &api_key=).
36
40
  export const URL_USERINFO = /\b([a-z][a-z0-9+.-]*:\/\/)[^@\s/]+@/gi;
@@ -47,6 +51,7 @@ const URL_PASSWORD = /\b([a-z][a-z0-9+.-]*:\/\/[^:\s/@]+:)[^@\s]+@/gi;
47
51
  export function redact(text: string): string {
48
52
  let out = text
49
53
  .replace(SECRET_ASSIGNMENT, "$1[redacted]")
54
+ .replace(INLINE_ASSIGNMENT, "$1[redacted]")
50
55
  .replace(URL_PASSWORD, "$1[redacted]@");
51
56
  for (const p of TOKEN_PATTERNS) out = out.replace(p, "[redacted]");
52
57
  return out.replace(BASE64_RUN, (m) => (looksRandom(m) ? "[redacted]" : m));
package/lib/projects.ts CHANGED
@@ -294,6 +294,7 @@ export function deleteProject(id: string): boolean {
294
294
 
295
295
  // Its merge settings override (lib/tasks/merge-policy.ts)
296
296
  db.prepare(`DELETE FROM settings WHERE key = ?`).run(`merge.project.${id}`);
297
+ db.prepare(`DELETE FROM settings WHERE key = ?`).run(`models.project.${id}`);
297
298
 
298
299
  // Delete project
299
300
  queries.deleteProject(db).run(id);
@@ -0,0 +1,94 @@
1
+ /**
2
+ * What a task saves under .agentos/mockups and .agentos/screenshots. Git
3
+ * ignores that folder (its .gitignore is `*`) and a Bash write isn't a
4
+ * file-writing tool call, so neither other source lists them.
5
+ */
6
+
7
+ import fs from "fs";
8
+ import path from "path";
9
+ import { isSecretPath } from "../secret-paths";
10
+ import { SCREENSHOT_DIR } from "../tasks/ui-brief";
11
+ import { kindForPath, type Candidate } from "./derive";
12
+
13
+ export const MOCKUP_DIR = ".agentos/mockups";
14
+ export const AGENTOS_DIRS = [MOCKUP_DIR, SCREENSHOT_DIR];
15
+ const MAX_DEPTH = 3;
16
+ const MAX_LISTED = 200;
17
+
18
+ const isAgentosFile = (rel: string) =>
19
+ AGENTOS_DIRS.some((d) => rel.startsWith(`${d}/`));
20
+
21
+ function candidate(
22
+ worktree: string,
23
+ rel: string,
24
+ at: number
25
+ ): Candidate | null {
26
+ const file = path.join(worktree, rel);
27
+ if (isSecretPath(file)) return null;
28
+ return {
29
+ key: `file:${file}`,
30
+ kind: kindForPath(file),
31
+ name: path.basename(file),
32
+ at,
33
+ by: "shell",
34
+ source: { type: "file", path: file },
35
+ };
36
+ }
37
+
38
+ function walk(root: string, rel: string, depth: number, out: string[]): void {
39
+ if (out.length >= MAX_LISTED) return;
40
+ let names: fs.Dirent[];
41
+ try {
42
+ names = fs.readdirSync(path.join(root, rel), { withFileTypes: true });
43
+ } catch {
44
+ return;
45
+ }
46
+ for (const d of names) {
47
+ const child = `${rel}/${d.name}`;
48
+ // Symlinks are skipped: they could point out of the worktree.
49
+ if (d.isFile()) out.push(child);
50
+ else if (d.isDirectory() && depth < MAX_DEPTH)
51
+ walk(root, child, depth + 1, out);
52
+ }
53
+ }
54
+
55
+ export function agentosFiles(worktree: string): { rel: string; at: number }[] {
56
+ const rels: string[] = [];
57
+ for (const dir of AGENTOS_DIRS) walk(worktree, dir, 1, rels);
58
+ return rels.slice(0, MAX_LISTED).flatMap((rel) => {
59
+ try {
60
+ return [{ rel, at: fs.statSync(path.join(worktree, rel)).mtimeMs }];
61
+ } catch {
62
+ return [];
63
+ }
64
+ });
65
+ }
66
+
67
+ export function fromAgentosDirs(
68
+ worktree: string,
69
+ known: Set<string>
70
+ ): Candidate[] {
71
+ return agentosFiles(worktree).flatMap(({ rel, at }) => {
72
+ const c = candidate(worktree, rel, at);
73
+ return c && !known.has(c.key) ? [c] : [];
74
+ });
75
+ }
76
+
77
+ // After the worktree is gone: the copies that were kept.
78
+ export function fromKept(
79
+ worktree: string,
80
+ kept: string[],
81
+ at: number,
82
+ known: Set<string>
83
+ ): Candidate[] {
84
+ return kept.filter(isAgentosFile).flatMap((rel) => {
85
+ const c = candidate(worktree, rel, at);
86
+ return c && !known.has(c.key) ? [c] : [];
87
+ });
88
+ }
89
+
90
+ // Changes when a file is added, removed or rewritten there.
91
+ export function agentosSignature(worktree: string): string {
92
+ const files = agentosFiles(worktree);
93
+ return `${files.length}:${files.reduce((m, f) => Math.max(m, f.at), 0)}`;
94
+ }