@saadnvd1/agent-os 0.3.744 → 0.3.749
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 -1
- package/app/api/models/choices/route.ts +7 -3
- package/app/api/sessions/[id]/model/route.ts +2 -23
- package/components/Analyst/StartAnalystSheet.tsx +7 -2
- package/components/Analyst/useAnalystModels.ts +19 -0
- package/components/Approvals/NoApprovalsBadge.tsx +22 -9
- package/components/Away/AwayToggle.tsx +5 -5
- package/components/Chat/ChatPanel.tsx +10 -2
- package/components/Chat/Messages.test.ts +33 -0
- package/components/Chat/Messages.tsx +15 -0
- package/components/Chat/ModelPicker.tsx +3 -2
- package/components/Chat/idle.ts +9 -0
- package/components/Draft/AnalystModelChip.tsx +58 -0
- package/components/Draft/DraftChips.tsx +8 -0
- package/components/Draft/DraftPanel.tsx +7 -1
- package/components/Draft/RoleChips.tsx +12 -3
- package/components/Models/AgentRoleCard.tsx +9 -2
- package/components/Models/RoleModelSelect.tsx +23 -8
- package/components/SessionList/MachineSwitcher.tsx +1 -1
- package/components/SessionList/ProjectFilter.tsx +1 -1
- package/components/SessionList/SidebarHeader.tsx +3 -3
- package/components/SessionList/SidebarSearch.tsx +1 -1
- package/components/SessionList/index.tsx +1 -1
- package/components/Settings/NoApprovalsSection.test.ts +5 -2
- package/lib/chat/demo.ts +23 -2
- package/lib/chat/drivers/demo-script.ts +30 -4
- package/lib/chat/drivers/opencode-config.ts +5 -0
- package/lib/chat/drivers/opencode-mapper.ts +104 -17
- package/lib/chat/drivers/opencode.ts +19 -1
- package/lib/chat/drivers/turn-outcome.ts +22 -1
- package/lib/chat/events.ts +12 -1
- package/lib/chat/group.ts +11 -2
- package/lib/chat/progress.ts +4 -0
- package/lib/chat/role-models.ts +46 -0
- package/lib/chat/runner-watch.ts +1 -1
- package/lib/chat/settings.ts +7 -4
- package/lib/chat/socket.ts +3 -2
- package/lib/chat/turn-end.ts +16 -0
- package/lib/drafts.ts +2 -0
- package/lib/models/agent-binding.ts +23 -2
- package/lib/models/catalog.ts +43 -1
- package/lib/models/providers.ts +12 -0
- package/lib/models/resolve.ts +3 -7
- package/lib/models/session-model.ts +70 -4
- package/lib/models/tool-use.ts +78 -0
- package/lib/models/usable.ts +45 -0
- package/lib/updates/check.ts +32 -0
- package/lib/updates/view.ts +2 -1
- package/package.json +1 -1
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { TurnEndReason } from "./events";
|
|
2
|
+
|
|
3
|
+
// The ends a Retry button can resume: the model's side failed or went quiet.
|
|
4
|
+
const RETRYABLE: TurnEndReason[] = [
|
|
5
|
+
"error",
|
|
6
|
+
"empty",
|
|
7
|
+
"stalled",
|
|
8
|
+
"compaction_empty",
|
|
9
|
+
"cut_off",
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
export const retryableEnd = (reason: TurnEndReason) =>
|
|
13
|
+
RETRYABLE.includes(reason);
|
|
14
|
+
|
|
15
|
+
// What Retry sends: the steps that ran stay, the model carries on.
|
|
16
|
+
export const RETRY_TEXT = "continue";
|
package/lib/drafts.ts
CHANGED
|
@@ -35,6 +35,8 @@ export interface Draft {
|
|
|
35
35
|
roleModel?: string | null;
|
|
36
36
|
// Pair it with an analyst that reviews each turn (a local chat only).
|
|
37
37
|
withAnalyst?: boolean;
|
|
38
|
+
// The analyst's model, "provider/model"; unset, the analyst role's.
|
|
39
|
+
analystModel?: string | null;
|
|
38
40
|
// A task's priority in the queue; unset: normal.
|
|
39
41
|
taskPriority?: Priority;
|
|
40
42
|
createdAt: number;
|
|
@@ -9,6 +9,7 @@ import { AGENT_ROLE_DEFS, type AgentRole } from "./agent-roles";
|
|
|
9
9
|
import { modelAvailability, choiceFor } from "./catalog";
|
|
10
10
|
import { savedModelSettings } from "./config";
|
|
11
11
|
import { PI } from "./providers";
|
|
12
|
+
import { checkToolUse, ollamaOrigin } from "./tool-use";
|
|
12
13
|
import { resolveRole, type RoleSource, type RoutedModel } from "./resolve";
|
|
13
14
|
|
|
14
15
|
export interface RoleBinding {
|
|
@@ -50,6 +51,22 @@ export class ModelUnavailableError extends Error {
|
|
|
50
51
|
}
|
|
51
52
|
}
|
|
52
53
|
|
|
54
|
+
// An analyst reads code and transcripts through tools: a model its server
|
|
55
|
+
// says can't call them is refused, with the reason.
|
|
56
|
+
export async function assertCanCallTools(
|
|
57
|
+
role: AgentRole,
|
|
58
|
+
ref: string
|
|
59
|
+
): Promise<void> {
|
|
60
|
+
if (role !== "analyst") return;
|
|
61
|
+
const choice = choiceFor(ref);
|
|
62
|
+
if (!choice || choice.locality !== "local" || !choice.endpoint) return;
|
|
63
|
+
const model = choice.ref.split("/").slice(choice.agent === "pi" ? 2 : 1);
|
|
64
|
+
if (!(await checkToolUse(ollamaOrigin(choice.endpoint), model.join("/"))))
|
|
65
|
+
throw new Error(
|
|
66
|
+
`${choice.label} can't call tools; an analyst reads code through tools`
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
53
70
|
export interface RoleStart {
|
|
54
71
|
role: AgentRole;
|
|
55
72
|
routed: RoutedModel;
|
|
@@ -82,13 +99,17 @@ export async function prepareRoleStart(
|
|
|
82
99
|
): Promise<RoleStart> {
|
|
83
100
|
const binding = bindingFor(role, opts);
|
|
84
101
|
const state = await modelAvailability(binding.routed.ref);
|
|
85
|
-
if (state.state !== "down")
|
|
102
|
+
if (state.state !== "down") {
|
|
103
|
+
await assertCanCallTools(role, binding.routed.ref);
|
|
86
104
|
return startFrom(role, binding.routed, binding.routed.source);
|
|
105
|
+
}
|
|
87
106
|
if (opts.useFallback && binding.fallback) {
|
|
88
107
|
const alt = bindingFor(role, { ...opts, override: binding.fallback });
|
|
89
108
|
const altState = await modelAvailability(alt.routed.ref);
|
|
90
|
-
if (altState.state !== "down")
|
|
109
|
+
if (altState.state !== "down") {
|
|
110
|
+
await assertCanCallTools(role, alt.routed.ref);
|
|
91
111
|
return startFrom(role, alt.routed, "fallback");
|
|
112
|
+
}
|
|
92
113
|
}
|
|
93
114
|
throw new ModelUnavailableError(
|
|
94
115
|
role,
|
package/lib/models/catalog.ts
CHANGED
|
@@ -10,7 +10,8 @@ import os from "os";
|
|
|
10
10
|
import path from "path";
|
|
11
11
|
import { effectiveModels, type EffectiveModels } from "./config";
|
|
12
12
|
import { ANTHROPIC } from "./roles";
|
|
13
|
-
import { PI, type ModelProvider } from "./providers";
|
|
13
|
+
import { PI, providerProblem, type ModelProvider } from "./providers";
|
|
14
|
+
import { cachedToolUse, ollamaOrigin } from "./tool-use";
|
|
14
15
|
import { hostLocality, type Locality } from "./locality";
|
|
15
16
|
import {
|
|
16
17
|
cachedAvailability,
|
|
@@ -31,6 +32,10 @@ export interface ModelChoice {
|
|
|
31
32
|
availability: Availability;
|
|
32
33
|
// May serve a merge gate: Claude Code can run it.
|
|
33
34
|
gateCapable: boolean;
|
|
35
|
+
// May call tools, which an analyst needs (lib/models/tool-use).
|
|
36
|
+
toolUse: boolean;
|
|
37
|
+
// Why it can't be started at all (its provider is off or half set up).
|
|
38
|
+
unusable?: string;
|
|
34
39
|
// Where the server is (never a key).
|
|
35
40
|
endpoint?: string;
|
|
36
41
|
}
|
|
@@ -84,6 +89,16 @@ export function piProviders(file = piModelsFile()): PiProvider[] {
|
|
|
84
89
|
const providerEndpoint = (p: ModelProvider) =>
|
|
85
90
|
p.baseUrl ? probeUrl(p.baseUrl, p.api) : undefined;
|
|
86
91
|
|
|
92
|
+
// Anthropic's and cloud models can; a model on a local server is asked of it.
|
|
93
|
+
const toolUseOf = (
|
|
94
|
+
baseUrl: string | undefined,
|
|
95
|
+
locality: Locality,
|
|
96
|
+
model: string
|
|
97
|
+
) =>
|
|
98
|
+
baseUrl && locality === "local"
|
|
99
|
+
? cachedToolUse(ollamaOrigin(baseUrl), model)
|
|
100
|
+
: true;
|
|
101
|
+
|
|
87
102
|
export function modelCatalog(
|
|
88
103
|
effective: EffectiveModels = effectiveModels(),
|
|
89
104
|
pi: PiProvider[] = piProviders()
|
|
@@ -98,6 +113,7 @@ export function modelCatalog(
|
|
|
98
113
|
: locality === "local" && endpoint
|
|
99
114
|
? cachedAvailability(endpoint)
|
|
100
115
|
: { state: "unknown", checkedAt: null };
|
|
116
|
+
const unusable = providerProblem(p) ?? undefined;
|
|
101
117
|
for (const m of p.models)
|
|
102
118
|
out.push({
|
|
103
119
|
ref: `${p.id}/${m}`,
|
|
@@ -107,6 +123,8 @@ export function modelCatalog(
|
|
|
107
123
|
locality,
|
|
108
124
|
availability,
|
|
109
125
|
gateCapable: p.api === "anthropic",
|
|
126
|
+
toolUse: toolUseOf(p.baseUrl, locality, m),
|
|
127
|
+
...(unusable && { unusable }),
|
|
110
128
|
endpoint: p.baseUrl,
|
|
111
129
|
});
|
|
112
130
|
}
|
|
@@ -126,12 +144,36 @@ export function modelCatalog(
|
|
|
126
144
|
locality,
|
|
127
145
|
availability,
|
|
128
146
|
gateCapable: false,
|
|
147
|
+
toolUse: toolUseOf(p.baseUrl, locality, m.id),
|
|
129
148
|
endpoint: p.baseUrl,
|
|
130
149
|
});
|
|
131
150
|
}
|
|
132
151
|
return out;
|
|
133
152
|
}
|
|
134
153
|
|
|
154
|
+
// The models of providers that are turned off, so a picker can show them
|
|
155
|
+
// greyed with the reason rather than leave them out.
|
|
156
|
+
export function unconfiguredChoices(
|
|
157
|
+
effective: EffectiveModels = effectiveModels()
|
|
158
|
+
): ModelChoice[] {
|
|
159
|
+
return effective.providers
|
|
160
|
+
.filter((p) => !p.enabled)
|
|
161
|
+
.flatMap((p) =>
|
|
162
|
+
p.models.map((m) => ({
|
|
163
|
+
ref: `${p.id}/${m}`,
|
|
164
|
+
label: m,
|
|
165
|
+
group: p.label,
|
|
166
|
+
agent: "claude" as const,
|
|
167
|
+
locality: hostLocality(p.baseUrl),
|
|
168
|
+
availability: { state: "unknown" as const, checkedAt: null },
|
|
169
|
+
gateCapable: false,
|
|
170
|
+
toolUse: true,
|
|
171
|
+
unusable: providerProblem(p) ?? undefined,
|
|
172
|
+
endpoint: p.baseUrl,
|
|
173
|
+
}))
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
135
177
|
export const choiceFor = (
|
|
136
178
|
ref: string,
|
|
137
179
|
effective?: EffectiveModels
|
package/lib/models/providers.ts
CHANGED
|
@@ -50,6 +50,18 @@ export interface ModelPrice {
|
|
|
50
50
|
output: number;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
// Why a provider can't run a Claude Code session, or null when it can.
|
|
54
|
+
export function providerProblem(p: ModelProvider): string | null {
|
|
55
|
+
if (!p.enabled) return `The ${p.label} provider is turned off`;
|
|
56
|
+
// Anthropic runs on the Claude login: no URL or key of ours.
|
|
57
|
+
if (p.id === ANTHROPIC) return null;
|
|
58
|
+
if (!p.baseUrl || !p.auth)
|
|
59
|
+
return `The ${p.label} provider needs a base URL and a key`;
|
|
60
|
+
if (p.api !== "anthropic")
|
|
61
|
+
return `${p.label} speaks the OpenAI API; Claude Code needs an Anthropic-compatible endpoint`;
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
53
65
|
export const ANTHROPIC_PROVIDER: ModelProvider = {
|
|
54
66
|
id: ANTHROPIC,
|
|
55
67
|
label: "Anthropic (Claude login)",
|
package/lib/models/resolve.ts
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
ANTHROPIC_PROVIDER,
|
|
22
22
|
PI,
|
|
23
23
|
PI_PROVIDER,
|
|
24
|
+
providerProblem,
|
|
24
25
|
type ModelProvider,
|
|
25
26
|
} from "./providers";
|
|
26
27
|
import {
|
|
@@ -111,13 +112,8 @@ export function providerFor(
|
|
|
111
112
|
}
|
|
112
113
|
const p = models.providers.find((x) => x.id === id);
|
|
113
114
|
if (!p) throw new Error(`No model provider "${id}" (Settings > Models)`);
|
|
114
|
-
|
|
115
|
-
if (
|
|
116
|
-
throw new Error(`The ${p.label} provider needs a base URL and a key`);
|
|
117
|
-
if (p.api !== "anthropic")
|
|
118
|
-
throw new Error(
|
|
119
|
-
`${p.label} speaks the OpenAI API; Claude Code needs an Anthropic-compatible endpoint`
|
|
120
|
-
);
|
|
115
|
+
const problem = providerProblem(p);
|
|
116
|
+
if (problem) throw new Error(problem);
|
|
121
117
|
return p;
|
|
122
118
|
}
|
|
123
119
|
|
|
@@ -7,10 +7,18 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { db, type Session } from "../db";
|
|
10
|
-
import { registry } from "../chat/registry";
|
|
11
|
-
import { setChatModel } from "../chat/settings";
|
|
12
|
-
import {
|
|
13
|
-
|
|
10
|
+
import { getSession, registry } from "../chat/registry";
|
|
11
|
+
import { emitCapabilities, setChatModel } from "../chat/settings";
|
|
12
|
+
import {
|
|
13
|
+
providerFor,
|
|
14
|
+
sessionRole,
|
|
15
|
+
setSessionModel,
|
|
16
|
+
type RoutedModel,
|
|
17
|
+
} from "./resolve";
|
|
18
|
+
import { PI } from "./providers";
|
|
19
|
+
import { isAgentRole } from "./agent-roles";
|
|
20
|
+
import { assertCanCallTools } from "./agent-binding";
|
|
21
|
+
import { ANTHROPIC, formatModelRef, parseModelRef } from "./roles";
|
|
14
22
|
|
|
15
23
|
export async function switchSessionModel(
|
|
16
24
|
sessionId: string,
|
|
@@ -38,3 +46,61 @@ export async function applySessionModel(
|
|
|
38
46
|
if (registry.live.has(sessionId))
|
|
39
47
|
(await import("../chat/runner")).stopChat(sessionId);
|
|
40
48
|
}
|
|
49
|
+
|
|
50
|
+
// Moves a session to "provider/model" (or "pi/<provider>/<model>" for a Pi
|
|
51
|
+
// session), keeping its conversation. Only this session's row changes: no
|
|
52
|
+
// other session's model and no role setting.
|
|
53
|
+
export async function switchSessionModelRef(
|
|
54
|
+
session: Session,
|
|
55
|
+
ref: string
|
|
56
|
+
): Promise<void> {
|
|
57
|
+
const parsed = parseModelRef(ref);
|
|
58
|
+
if (isAgentRole(session.agent_role))
|
|
59
|
+
await assertCanCallTools(session.agent_role, formatModelRef(parsed));
|
|
60
|
+
if (session.agent_type === "pi") {
|
|
61
|
+
if (parsed.provider !== PI)
|
|
62
|
+
throw new Error(
|
|
63
|
+
"A Pi session runs on a Pi model (pi/<provider>/<model>)"
|
|
64
|
+
);
|
|
65
|
+
await setChatModel(session.id, parsed.model);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (parsed.provider === PI)
|
|
69
|
+
throw new Error("Only a Pi session can move to a Pi model");
|
|
70
|
+
await switchSessionModel(session.id, {
|
|
71
|
+
role: sessionRole(session),
|
|
72
|
+
ref: formatModelRef(parsed),
|
|
73
|
+
provider: providerFor(parsed.provider),
|
|
74
|
+
model: parsed.model,
|
|
75
|
+
source: "task",
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// A pick in a chat's model list. A full ref ("provider/model") moves the
|
|
80
|
+
// session across providers; a bare model on a session routed to another
|
|
81
|
+
// provider goes back to Anthropic, so "opus" is never asked of another
|
|
82
|
+
// provider's endpoint. Anything else is the agent's own model name.
|
|
83
|
+
export async function setComposerModel(
|
|
84
|
+
sessionId: string,
|
|
85
|
+
value: string
|
|
86
|
+
): Promise<void> {
|
|
87
|
+
const session = db
|
|
88
|
+
.prepare(`SELECT * FROM session_rows WHERE id = ?`)
|
|
89
|
+
.get(sessionId) as Session | undefined;
|
|
90
|
+
const model = value.trim();
|
|
91
|
+
if (!session || !model) return;
|
|
92
|
+
const claude = session.agent_type === "claude";
|
|
93
|
+
const ref = claude
|
|
94
|
+
? model.includes("/")
|
|
95
|
+
? model
|
|
96
|
+
: session.model_provider
|
|
97
|
+
? `${ANTHROPIC}/${model}`
|
|
98
|
+
: null
|
|
99
|
+
: session.agent_type === "pi" && model.startsWith(`${PI}/`)
|
|
100
|
+
? model
|
|
101
|
+
: null;
|
|
102
|
+
if (!ref) return setChatModel(sessionId, model);
|
|
103
|
+
await switchSessionModelRef(session, ref);
|
|
104
|
+
// A move across providers stops the worker, which tells nobody.
|
|
105
|
+
emitCapabilities(getSession(sessionId));
|
|
106
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a model can call tools, which an analyst needs to read code and
|
|
3
|
+
* transcripts. There is no list of it to read, so the server asks: an Ollama
|
|
4
|
+
* server reports each model's capabilities (POST /api/show), and one that
|
|
5
|
+
* answers without "tools" is out. Anything that can't be told (a server with
|
|
6
|
+
* no such endpoint, one that doesn't answer, a cloud provider) counts as
|
|
7
|
+
* able: Pi turns text tool calls into real ones, and hiding a model we can't
|
|
8
|
+
* judge is worse than letting it try. Cached, and never awaited by a request
|
|
9
|
+
* handler unless it asks to.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const TTL_MS = 10 * 60_000;
|
|
13
|
+
const TIMEOUT_MS = 1_500;
|
|
14
|
+
|
|
15
|
+
interface Entry {
|
|
16
|
+
toolUse: boolean;
|
|
17
|
+
at: number;
|
|
18
|
+
}
|
|
19
|
+
const cache = new Map<string, Entry>();
|
|
20
|
+
const probing = new Map<string, Promise<boolean>>();
|
|
21
|
+
|
|
22
|
+
// Where Ollama's own API lives for an OpenAI- or Anthropic-style base URL.
|
|
23
|
+
export const ollamaOrigin = (baseUrl: string): string =>
|
|
24
|
+
baseUrl.replace(/\/+$/, "").replace(/\/v1$/, "");
|
|
25
|
+
|
|
26
|
+
const key = (origin: string, model: string) => `${origin}\n${model}`;
|
|
27
|
+
|
|
28
|
+
async function probe(origin: string, model: string): Promise<boolean> {
|
|
29
|
+
try {
|
|
30
|
+
const res = await fetch(`${origin}/api/show`, {
|
|
31
|
+
method: "POST",
|
|
32
|
+
headers: { "content-type": "application/json" },
|
|
33
|
+
body: JSON.stringify({ model }),
|
|
34
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
35
|
+
redirect: "manual",
|
|
36
|
+
});
|
|
37
|
+
if (!res.ok) return true;
|
|
38
|
+
const body = (await res.json()) as { capabilities?: unknown };
|
|
39
|
+
return Array.isArray(body.capabilities)
|
|
40
|
+
? body.capabilities.includes("tools")
|
|
41
|
+
: true;
|
|
42
|
+
} catch {
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// The last answer, probing in the background when it is stale.
|
|
48
|
+
export function cachedToolUse(origin: string, model: string): boolean {
|
|
49
|
+
const k = key(origin, model);
|
|
50
|
+
const hit = cache.get(k);
|
|
51
|
+
if (!hit || Date.now() - hit.at >= TTL_MS) void checkToolUse(origin, model);
|
|
52
|
+
return hit?.toolUse ?? true;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function checkToolUse(
|
|
56
|
+
origin: string,
|
|
57
|
+
model: string
|
|
58
|
+
): Promise<boolean> {
|
|
59
|
+
const k = key(origin, model);
|
|
60
|
+
const hit = cache.get(k);
|
|
61
|
+
if (hit && Date.now() - hit.at < TTL_MS) return hit.toolUse;
|
|
62
|
+
let running = probing.get(k);
|
|
63
|
+
if (!running) {
|
|
64
|
+
running = probe(origin, model)
|
|
65
|
+
.then((toolUse) => {
|
|
66
|
+
cache.set(k, { toolUse, at: Date.now() });
|
|
67
|
+
return toolUse;
|
|
68
|
+
})
|
|
69
|
+
.finally(() => probing.delete(k));
|
|
70
|
+
probing.set(k, running);
|
|
71
|
+
}
|
|
72
|
+
return running;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function clearToolUse(): void {
|
|
76
|
+
cache.clear();
|
|
77
|
+
probing.clear();
|
|
78
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Why a model in the server's list can't be picked for a role, and the name
|
|
3
|
+
* a role's default shows. Imported by the browser too, so no Node modules.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { modelDisplayName } from "../model-catalog";
|
|
7
|
+
import { ANTHROPIC } from "./roles";
|
|
8
|
+
import type { ModelChoice } from "./catalog";
|
|
9
|
+
|
|
10
|
+
// Why a model can't be picked for an analyst, or null when it can. An
|
|
11
|
+
// analyst reads code and transcripts through tools, so a model that can't
|
|
12
|
+
// call them is out; one whose server isn't answering is too.
|
|
13
|
+
export function analystBlock(
|
|
14
|
+
c: Pick<ModelChoice, "unusable" | "toolUse" | "availability" | "agent">,
|
|
15
|
+
opts: { agent?: "claude" | "pi" } = {}
|
|
16
|
+
): string | null {
|
|
17
|
+
if (c.unusable) return c.unusable;
|
|
18
|
+
if (!c.toolUse) return "Can't call tools";
|
|
19
|
+
if (c.availability.state === "down")
|
|
20
|
+
return c.availability.error
|
|
21
|
+
? `Not answering (${c.availability.error})`
|
|
22
|
+
: "Not answering";
|
|
23
|
+
if (opts.agent && c.agent !== opts.agent)
|
|
24
|
+
return c.agent === "pi"
|
|
25
|
+
? "Runs on Pi: start a new analyst on it"
|
|
26
|
+
: "Runs on Claude Code: start a new analyst on it";
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// "Opus 5.5" for an Anthropic ref, the list's own label for any other.
|
|
31
|
+
export function defaultModelName(
|
|
32
|
+
ref: string | null | undefined,
|
|
33
|
+
choices: Pick<ModelChoice, "ref" | "label">[]
|
|
34
|
+
): string {
|
|
35
|
+
if (!ref) return "…";
|
|
36
|
+
const found = choices.find((c) => c.ref === ref);
|
|
37
|
+
if (ref.startsWith(`${ANTHROPIC}/`))
|
|
38
|
+
return modelDisplayName(ref.slice(ANTHROPIC.length + 1));
|
|
39
|
+
return found?.label ?? ref.split("/").slice(1).join("/");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const defaultAnalystLabel = (
|
|
43
|
+
ref: string | null | undefined,
|
|
44
|
+
choices: Pick<ModelChoice, "ref" | "label">[]
|
|
45
|
+
) => `Default analyst model (${defaultModelName(ref, choices)})`;
|
package/lib/updates/check.ts
CHANGED
|
@@ -163,6 +163,38 @@ export async function compare(deps: CheckDeps): Promise<CheckResult> {
|
|
|
163
163
|
};
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
+
/**
|
|
167
|
+
* A cached answer was worked out against whatever HEAD was then. If the
|
|
168
|
+
* checkout has since moved (a pull by hand, a switch to another branch) and
|
|
169
|
+
* now contains the latest commit, there's nothing to update to.
|
|
170
|
+
*/
|
|
171
|
+
export async function reconcile(
|
|
172
|
+
check: CheckResult | null,
|
|
173
|
+
deps: Pick<CheckDeps, "git">
|
|
174
|
+
): Promise<CheckResult | null> {
|
|
175
|
+
if (!check?.available || !check.latest) return check;
|
|
176
|
+
try {
|
|
177
|
+
const head = (await deps.git(["rev-parse", "HEAD"])).trim();
|
|
178
|
+
if (head === check.current?.sha) return check;
|
|
179
|
+
const has = await deps
|
|
180
|
+
.git(["merge-base", "--is-ancestor", check.latest.sha, "HEAD"])
|
|
181
|
+
.then(
|
|
182
|
+
() => true,
|
|
183
|
+
() => false
|
|
184
|
+
);
|
|
185
|
+
return has
|
|
186
|
+
? {
|
|
187
|
+
...check,
|
|
188
|
+
available: false,
|
|
189
|
+
behind: 0,
|
|
190
|
+
current: { version: check.latest.version, sha: head },
|
|
191
|
+
}
|
|
192
|
+
: check;
|
|
193
|
+
} catch {
|
|
194
|
+
return check;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
166
198
|
let memo: CheckResult | null = null;
|
|
167
199
|
let inflight: Promise<CheckResult> | null = null;
|
|
168
200
|
|
package/lib/updates/view.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// whether the caller may start an update.
|
|
3
3
|
|
|
4
4
|
import { isActivePhase, type UpdateView } from "./banner";
|
|
5
|
-
import { cachedCheck, checkForUpdate } from "./check";
|
|
5
|
+
import { cachedCheck, checkForUpdate, reconcile } from "./check";
|
|
6
6
|
import {
|
|
7
7
|
preflight,
|
|
8
8
|
readLog,
|
|
@@ -40,6 +40,7 @@ export async function updateView(
|
|
|
40
40
|
)
|
|
41
41
|
check = (await checkForUpdate({ force: true, always: true }, deps.check))
|
|
42
42
|
.result;
|
|
43
|
+
check = await reconcile(check, deps.check);
|
|
43
44
|
// The log can carry paths and remote URLs; only a device that may update sees it.
|
|
44
45
|
const run =
|
|
45
46
|
stored && (stored.phase === "failed" || stored.phase === "built")
|