@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.
- package/README.md +1 -0
- package/app/api/agent-roles/route.test.ts +67 -0
- package/app/api/agent-roles/route.ts +61 -0
- package/app/api/analyst/[id]/recommendations/[recId]/again/route.ts +15 -0
- package/app/api/analyst/[id]/recommendations/[recId]/dismiss/route.ts +13 -0
- package/app/api/analyst/[id]/recommendations/[recId]/send/route.ts +13 -0
- package/app/api/analyst/[id]/review/route.ts +22 -0
- package/app/api/analyst/[id]/route.ts +44 -0
- package/app/api/analyst/route.ts +10 -0
- package/app/api/bus/spawn/route.ts +22 -1
- package/app/api/models/availability/route.ts +9 -0
- package/app/api/models/choices/route.ts +9 -0
- package/app/api/sessions/[id]/analyst/route.ts +11 -0
- package/app/api/sessions/[id]/crew/route.ts +38 -0
- package/app/api/sessions/[id]/export/estimate/route.ts +23 -0
- package/app/api/sessions/[id]/export/route.test.ts +125 -0
- package/app/api/sessions/[id]/export/route.ts +34 -0
- package/app/api/sessions/[id]/model/route.ts +49 -0
- package/app/api/sessions/route.ts +16 -0
- package/app/page.tsx +5 -0
- package/components/Chat/Messages.tsx +104 -10
- package/components/Draft/DraftChips.tsx +6 -1
- package/components/Draft/DraftPanel.tsx +51 -2
- package/components/Draft/ModelUnavailableDialog.tsx +78 -0
- package/components/Draft/RoleChips.tsx +110 -0
- package/components/Export/ExportActions.tsx +136 -0
- package/components/Export/ExportSheet.tsx +217 -0
- package/components/Export/RangePicker.tsx +144 -0
- package/components/Export/Seg.tsx +38 -0
- package/components/Export/TurnSelectBar.tsx +45 -0
- package/components/Models/AgentRoleCard.tsx +114 -0
- package/components/Models/AgentRolesSection.tsx +132 -0
- package/components/Models/ModelBadges.tsx +48 -0
- package/components/Models/ModelsSection.tsx +4 -2
- package/components/Models/RoleModelSelect.tsx +87 -0
- package/components/Pane/DesktopTabBar.tsx +2 -0
- package/components/Pane/MobileTabBar.tsx +12 -0
- package/components/Pane/PaneMenu.tsx +10 -0
- package/components/SessionList/SessionRow.tsx +18 -7
- package/components/SessionList/SessionRowMenu.tsx +6 -0
- package/components/Sessions/RolePill.tsx +196 -0
- package/components/Settings/SettingsDialog.tsx +9 -0
- package/components/Share/ShareDialog.tsx +13 -0
- package/data/analyst/index.ts +3 -0
- package/data/analyst/keys.ts +6 -0
- package/data/analyst/mutations.ts +116 -0
- package/data/analyst/queries.ts +35 -0
- package/data/exports/index.ts +2 -0
- package/data/exports/keys.ts +3 -0
- package/data/exports/queries.ts +51 -0
- package/data/models/index.ts +98 -0
- package/data/push/topics.ts +5 -0
- package/data/sessions/queries.ts +26 -0
- package/lib/agents/brief.ts +1 -0
- package/lib/analyst/__fixtures__/items.ts +57 -0
- package/lib/analyst/__fixtures__/mocks.ts +49 -0
- package/lib/analyst/brief.ts +17 -0
- package/lib/analyst/buildDigest.ts +204 -0
- package/lib/analyst/createLink.ts +98 -0
- package/lib/analyst/endLink.ts +31 -0
- package/lib/analyst/forward.ts +109 -0
- package/lib/analyst/index.ts +16 -0
- package/lib/analyst/linkView.ts +38 -0
- package/lib/analyst/onTurnEnd.ts +119 -0
- package/lib/analyst/parseReview.ts +41 -0
- package/lib/analyst/pushDigest.ts +143 -0
- package/lib/analyst/resume.ts +35 -0
- package/lib/analyst/route.ts +50 -0
- package/lib/analyst/signals.ts +121 -0
- package/lib/analyst/store.ts +303 -0
- package/lib/analyst/types.ts +140 -0
- package/lib/bus/condense.ts +5 -1
- package/lib/bus/index.ts +5 -0
- package/lib/bus/read-scope.ts +12 -0
- package/lib/bus/role-alias.ts +70 -0
- package/lib/bus/targets.ts +3 -0
- package/lib/chat/drivers/opencode-mapper.ts +55 -1
- package/lib/chat/drivers/pi-approvals.ts +7 -4
- package/lib/chat/drivers/pi-mapper.ts +38 -0
- package/lib/chat/drivers/pi-text-tools.ts +104 -0
- package/lib/chat/drivers/pi.ts +8 -3
- package/lib/chat/drivers/turn-items.ts +36 -11
- package/lib/chat/drivers/turn-outcome.ts +108 -0
- package/lib/chat/events.ts +8 -0
- package/lib/chat/registry.ts +13 -0
- package/lib/chat/store.ts +19 -0
- package/lib/chat/turns.ts +1 -0
- package/lib/chat/worker/extras.ts +4 -2
- package/lib/claims/collectLocal.ts +3 -1
- package/lib/db/changes.ts +6 -0
- package/lib/db/migrations/20261021T1400_session_agent_roles.ts +20 -0
- package/lib/db/migrations/20261021T1430_analyst_links.ts +52 -0
- package/lib/db/session-parts/columns.ts +2 -0
- package/lib/db/types.ts +3 -0
- package/lib/done/archive.ts +2 -0
- package/lib/drafts.ts +4 -0
- package/lib/export/build.ts +470 -0
- package/lib/export/compact.ts +156 -0
- package/lib/export/estimate.ts +83 -0
- package/lib/export/fixtures.ts +45 -0
- package/lib/export/html.tsx +195 -0
- package/lib/export/json.ts +39 -0
- package/lib/export/load.ts +64 -0
- package/lib/export/markdown.ts +127 -0
- package/lib/export/request.ts +36 -0
- package/lib/export/service.ts +142 -0
- package/lib/export/summary.ts +86 -0
- package/lib/export/turns.ts +63 -0
- package/lib/export/view.ts +173 -0
- package/lib/models/agent-binding.ts +102 -0
- package/lib/models/agent-roles-view.ts +157 -0
- package/lib/models/agent-roles.ts +87 -0
- package/lib/models/availability-refs.ts +17 -0
- package/lib/models/availability.ts +91 -0
- package/lib/models/catalog.ts +156 -0
- package/lib/models/config.ts +27 -0
- package/lib/models/locality.ts +33 -0
- package/lib/models/providers.ts +11 -0
- package/lib/models/resolve.ts +50 -12
- package/lib/models/roles.ts +12 -0
- package/lib/needs-you.ts +1 -1
- package/lib/orchestrator/facts.ts +7 -2
- package/lib/orchestrator/read.ts +5 -1
- package/lib/orchestrator/untrusted.ts +5 -0
- package/lib/projects.ts +1 -0
- package/lib/session-artifacts/agentos-files.ts +94 -0
- package/lib/session-artifacts/collect.ts +17 -0
- package/lib/sessions/crew.ts +72 -0
- package/lib/sessions/launch.ts +49 -9
- package/lib/settings-url.ts +1 -0
- package/lib/share/markdown.ts +2 -2
- package/lib/share/redact.ts +16 -10
- package/lib/share/render.tsx +1 -1
- package/lib/share/snapshot.ts +2 -2
- package/lib/sidebar/row-menu.ts +6 -1
- package/lib/tasks/index.ts +6 -0
- package/lib/worktree-deps/caches.ts +3 -2
- package/lib/worktree-deps/clone.ts +3 -1
- package/lib/worktree-deps/paths.ts +26 -0
- package/package.json +1 -1
- package/server.ts +9 -1
- 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
|
+
}
|
package/lib/models/config.ts
CHANGED
|
@@ -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
|
+
}
|
package/lib/models/providers.ts
CHANGED
|
@@ -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.
|
package/lib/models/resolve.ts
CHANGED
|
@@ -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 {
|
|
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 =
|
|
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: {
|
|
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
|
-
:
|
|
123
|
-
? [
|
|
124
|
-
:
|
|
125
|
-
? [
|
|
126
|
-
:
|
|
127
|
-
? [
|
|
128
|
-
:
|
|
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
|
};
|
package/lib/models/roles.ts
CHANGED
|
@@ -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:
|
|
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
|
}
|
package/lib/orchestrator/read.ts
CHANGED
|
@@ -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
|
|
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
|
+
}
|