shraga 0.1.88 → 0.1.89
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/dist/client/assets/{index-BSvY0squ.js → index-B-8NBGtJ.js} +38 -38
- package/dist/client/index.html +1 -1
- package/package.json +1 -1
- package/src/client/components/ConversationHeader.tsx +54 -11
- package/src/client/components/ConversationPane.tsx +8 -1
- package/src/client/lib/ws.ts +1 -1
- package/src/server/claude.ts +14 -3
- package/src/server/engine/claude-code.ts +27 -4
- package/src/server/engine/index.ts +20 -8
- package/src/server/scheduler/builtins.ts +15 -1
- package/src/server/scheduler/runner.ts +5 -4
- package/src/server/sessions.ts +11 -3
package/dist/client/index.html
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
14
14
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
15
15
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet" />
|
|
16
|
-
<script type="module" crossorigin src="/assets/index-
|
|
16
|
+
<script type="module" crossorigin src="/assets/index-B-8NBGtJ.js"></script>
|
|
17
17
|
<link rel="stylesheet" crossorigin href="/assets/index-J2NH6FvE.css">
|
|
18
18
|
</head>
|
|
19
19
|
<body>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "shraga",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.89",
|
|
4
4
|
"description": "The teammate you delegate coding to — a self-hostable, multi-user AI coding agent web UI (Claude Code, with a pluggable engine seam).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -11,11 +11,45 @@ interface SessionDirectives {
|
|
|
11
11
|
engine?: string;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
/** What the chips must report: the engine/model that ACTUALLY ran, and whether that disagrees with
|
|
15
|
+
* what this session asks for. Pure, so the rule is testable without a DOM.
|
|
16
|
+
*
|
|
17
|
+
* The bug this replaces: the old code inferred the engine from the model id's SHAPE (bare ⇒ native)
|
|
18
|
+
* and threw away the runtime-recorded model whenever the shape disagreed with the requested engine —
|
|
19
|
+
* i.e. precisely in the case where a run had silently switched provider. The header was structurally
|
|
20
|
+
* incapable of reporting a fallback, so a run that billed Anthropic still showed the Cursor chips.
|
|
21
|
+
* Runtime ground truth now arrives as a self-consistent (engine, model) PAIR, so nothing is inferred. */
|
|
22
|
+
export function deriveRuntimeBadges(input: {
|
|
23
|
+
requestedEngine?: string;
|
|
24
|
+
requestedModel?: string;
|
|
25
|
+
/** Engine that ran the last turn (session meta `lastEngine`). */
|
|
26
|
+
actualEngine?: string;
|
|
27
|
+
/** Model that engine resolved (session meta `lastModel`). */
|
|
28
|
+
actualModel?: string;
|
|
29
|
+
}) {
|
|
30
|
+
const requestedEngine = input.requestedEngine || 'claude-code';
|
|
31
|
+
const engine = input.actualEngine || requestedEngine;
|
|
32
|
+
// A mismatch is a fact worth showing, not something to launder away: the last turn ran somewhere
|
|
33
|
+
// other than where this session currently asks to run.
|
|
34
|
+
const engineMismatch = input.actualEngine && input.actualEngine !== requestedEngine ? requestedEngine : undefined;
|
|
35
|
+
const engineIsNative = engine === 'claude-code' || engine === 'cursor';
|
|
36
|
+
// Only trust the recorded model when we also know which engine recorded it — the pair, or neither.
|
|
37
|
+
const rawModel =
|
|
38
|
+
(input.actualEngine ? input.actualModel : undefined) ||
|
|
39
|
+
input.requestedModel ||
|
|
40
|
+
(engine === 'cursor' ? 'cursor/composer-2.5' : 'sonnet-4-6');
|
|
41
|
+
// Provider = the model's prefix; a bare id belongs to the engine that ran it (claude-code ⇒ anthropic,
|
|
42
|
+
// an add-on engine ⇒ that engine's own provider) — never assume anthropic just because a prefix is absent.
|
|
43
|
+
const billingProvider = rawModel.includes('/') ? rawModel.split('/')[0] : engine === 'claude-code' ? 'anthropic' : engine;
|
|
44
|
+
return { engine, engineIsNative, engineMismatch, rawModel, billingProvider };
|
|
45
|
+
}
|
|
46
|
+
|
|
14
47
|
function InfoBadges({
|
|
15
48
|
sessionId,
|
|
16
49
|
config,
|
|
17
50
|
sessionDirectives,
|
|
18
51
|
actualModel,
|
|
52
|
+
actualEngine,
|
|
19
53
|
scheduleId,
|
|
20
54
|
onScheduleClick,
|
|
21
55
|
}: {
|
|
@@ -24,19 +58,19 @@ function InfoBadges({
|
|
|
24
58
|
sessionDirectives?: SessionDirectives;
|
|
25
59
|
/** Model the engine actually resolved at runtime (session meta `lastModel`) — beats configured/requested. */
|
|
26
60
|
actualModel?: string;
|
|
61
|
+
/** Engine that actually ran the last turn (session meta `lastEngine`). Ground truth, arriving in the
|
|
62
|
+
* same `model_resolved` event as `actualModel` — so the pair never has to be inferred from a shape. */
|
|
63
|
+
actualEngine?: string;
|
|
27
64
|
scheduleId?: string;
|
|
28
65
|
onScheduleClick?: () => void;
|
|
29
66
|
}) {
|
|
30
67
|
const [copied, setCopied] = useState(false);
|
|
31
|
-
const engine
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const trustedActual = recordedByNative !== undefined && recordedByNative === engineIsNative ? actualModel : undefined;
|
|
38
|
-
const rawModel =
|
|
39
|
-
trustedActual || sessionDirectives?.model || config.model || (engine === 'cursor' ? 'cursor/composer-2.5' : 'sonnet-4-6');
|
|
68
|
+
const { engine, engineIsNative, engineMismatch, rawModel, billingProvider } = deriveRuntimeBadges({
|
|
69
|
+
requestedEngine: sessionDirectives?.engine || config.engine,
|
|
70
|
+
requestedModel: sessionDirectives?.model || config.model,
|
|
71
|
+
actualEngine,
|
|
72
|
+
actualModel,
|
|
73
|
+
});
|
|
40
74
|
// A multi-provider add-on engine runs any provider's model through its own loop, so it must be
|
|
41
75
|
// distinguishable from a native runtime running the same model. Prefix such a model with the engine
|
|
42
76
|
// name; native engines (claude-code, cursor) show the model plainly. Engine name comes from data.
|
|
@@ -46,8 +80,6 @@ function InfoBadges({
|
|
|
46
80
|
// engine / provider-prefixed model runs on that provider's key (ai.libx.js adapters throw without
|
|
47
81
|
// one). What that key COSTS is plan-dependent and NOT knowable here (Anthropic API is metered;
|
|
48
82
|
// a Cursor key may draw on a Cursor subscription) — so we label the mechanism, not the billing.
|
|
49
|
-
// Provider = the model's prefix (bare ⇒ anthropic).
|
|
50
|
-
const billingProvider = rawModel.includes('/') ? rawModel.split('/')[0] : 'anthropic';
|
|
51
83
|
const onSubscription = engine === 'claude-code' && config.claudeAuthSource === 'subscription';
|
|
52
84
|
// Tone: green = claude.ai login (no key); amber = provider key whose usage may be subscription-
|
|
53
85
|
// covered (Cursor); rose = provider key that is genuinely metered (Anthropic/OpenAI/etc.).
|
|
@@ -87,6 +119,14 @@ function InfoBadges({
|
|
|
87
119
|
>
|
|
88
120
|
{onSubscription ? 'sub' : `API·${billingProvider}`}
|
|
89
121
|
</span>
|
|
122
|
+
{engineMismatch && (
|
|
123
|
+
<span
|
|
124
|
+
title={`This session requests the "${engineMismatch}" engine, but the last turn actually ran on "${engine}" — so the chips above report ${engine}, and that provider was billed.`}
|
|
125
|
+
className="inline-flex items-center rounded-md bg-rose-50 px-1.5 py-0.5 text-[10px] font-medium text-rose-700 ring-1 ring-inset ring-rose-600/20 dark:bg-rose-950/50 dark:text-rose-300 dark:ring-rose-400/30"
|
|
126
|
+
>
|
|
127
|
+
≠ {engineMismatch}
|
|
128
|
+
</span>
|
|
129
|
+
)}
|
|
90
130
|
<span className="inline-flex items-center rounded-md bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium text-amber-700 ring-1 ring-inset ring-amber-600/20 dark:bg-amber-950/50 dark:text-amber-300 dark:ring-amber-400/30">
|
|
91
131
|
{permLabel}
|
|
92
132
|
</span>
|
|
@@ -127,6 +167,7 @@ export interface ConversationHeaderProps {
|
|
|
127
167
|
agentConfig: AgentConfig;
|
|
128
168
|
sessionDirectives?: SessionDirectives;
|
|
129
169
|
sessionLastModel?: string;
|
|
170
|
+
sessionLastEngine?: string;
|
|
130
171
|
sessionScheduleId?: string;
|
|
131
172
|
artifactCount: number;
|
|
132
173
|
getToken: () => Promise<string | null>;
|
|
@@ -143,6 +184,7 @@ export function ConversationHeader({
|
|
|
143
184
|
agentConfig,
|
|
144
185
|
sessionDirectives,
|
|
145
186
|
sessionLastModel,
|
|
187
|
+
sessionLastEngine,
|
|
146
188
|
sessionScheduleId,
|
|
147
189
|
artifactCount,
|
|
148
190
|
getToken,
|
|
@@ -159,6 +201,7 @@ export function ConversationHeader({
|
|
|
159
201
|
config={agentConfig}
|
|
160
202
|
sessionDirectives={sessionDirectives}
|
|
161
203
|
actualModel={sessionLastModel}
|
|
204
|
+
actualEngine={sessionLastEngine}
|
|
162
205
|
scheduleId={sessionScheduleId}
|
|
163
206
|
onScheduleClick={onScheduleClick}
|
|
164
207
|
/>
|
|
@@ -43,6 +43,9 @@ export function ConversationPane({ nodeId, sessionId }: { nodeId: string; sessio
|
|
|
43
43
|
const [sessionDirectives, setSessionDirectives] = useState<SessionDirectives | undefined>();
|
|
44
44
|
const [sessionScheduleId, setSessionScheduleId] = useState<string | undefined>();
|
|
45
45
|
const [sessionLastModel, setSessionLastModel] = useState<string | undefined>();
|
|
46
|
+
// Engine that ACTUALLY ran the last turn — always set/cleared together with sessionLastModel, since
|
|
47
|
+
// the pair is the runtime ground truth the header reports (neither half may be inferred).
|
|
48
|
+
const [sessionLastEngine, setSessionLastEngine] = useState<string | undefined>();
|
|
46
49
|
const [multiParticipant, setMultiParticipant] = useState(false);
|
|
47
50
|
|
|
48
51
|
const artifacts = useArtifacts(currentSessionId, ws.getToken, ws.token);
|
|
@@ -66,9 +69,10 @@ export function ConversationPane({ nodeId, sessionId }: { nodeId: string; sessio
|
|
|
66
69
|
} else if (event.type === 'directives') {
|
|
67
70
|
// Optimistic-then-confirm pill: drop stale ground-truth so the pill shows the just-requested
|
|
68
71
|
// model. Untagged event — gate on this pane being mid-turn so idle panes don't flicker.
|
|
69
|
-
if (busyRef.current) setSessionLastModel(undefined);
|
|
72
|
+
if (busyRef.current) { setSessionLastModel(undefined); setSessionLastEngine(undefined); }
|
|
70
73
|
} else if (event.type === 'model_resolved' && event.sessionId === currentSessionIdRef.current) {
|
|
71
74
|
setSessionLastModel(event.model);
|
|
75
|
+
setSessionLastEngine(event.engine);
|
|
72
76
|
}
|
|
73
77
|
},
|
|
74
78
|
[ws, nodeId, artifacts],
|
|
@@ -113,6 +117,7 @@ export function ConversationPane({ nodeId, sessionId }: { nodeId: string; sessio
|
|
|
113
117
|
setSessionDirectives(undefined);
|
|
114
118
|
setSessionScheduleId(undefined);
|
|
115
119
|
setSessionLastModel(undefined);
|
|
120
|
+
setSessionLastEngine(undefined);
|
|
116
121
|
return;
|
|
117
122
|
}
|
|
118
123
|
apiFetch(`/api/sessions/${currentSessionId}/meta`, ws.getToken)
|
|
@@ -121,6 +126,7 @@ export function ConversationPane({ nodeId, sessionId }: { nodeId: string; sessio
|
|
|
121
126
|
setSessionDirectives(meta.directives);
|
|
122
127
|
setSessionScheduleId(meta.scheduleId);
|
|
123
128
|
setSessionLastModel(meta.lastModel);
|
|
129
|
+
setSessionLastEngine(meta.lastEngine);
|
|
124
130
|
if (meta.runStatus === 'running') conv.setBusy(true);
|
|
125
131
|
})
|
|
126
132
|
.catch(() => {});
|
|
@@ -221,6 +227,7 @@ export function ConversationPane({ nodeId, sessionId }: { nodeId: string; sessio
|
|
|
221
227
|
agentConfig={ws.agentConfig}
|
|
222
228
|
sessionDirectives={sessionDirectives}
|
|
223
229
|
sessionLastModel={sessionLastModel}
|
|
230
|
+
sessionLastEngine={sessionLastEngine}
|
|
224
231
|
sessionScheduleId={sessionScheduleId}
|
|
225
232
|
artifactCount={artifacts.artifacts.length}
|
|
226
233
|
getToken={ws.getToken}
|
package/src/client/lib/ws.ts
CHANGED
|
@@ -26,7 +26,7 @@ export type ServerEvent =
|
|
|
26
26
|
| { type: 'session_id'; sessionId: string }
|
|
27
27
|
| { type: 'forked'; sourceSessionId: string; sessionId: string }
|
|
28
28
|
| { type: 'done'; sessionId: string; stopReason?: 'end_turn' | 'max_turns_reached' | (string & {}) }
|
|
29
|
-
| { type: 'model_resolved'; sessionId: string; model: string }
|
|
29
|
+
| { type: 'model_resolved'; sessionId: string; model: string; engine: string }
|
|
30
30
|
| { type: 'error'; message: string; sessionId?: string }
|
|
31
31
|
| { type: 'workspace_change'; action: 'created' | 'modified' | 'deleted'; path: string }
|
|
32
32
|
| { type: 'disconnected' }
|
package/src/server/claude.ts
CHANGED
|
@@ -81,7 +81,7 @@ export type WsEvent =
|
|
|
81
81
|
| { type: 'question_request'; id: string; questions: AskQuestion[] }
|
|
82
82
|
| { type: 'thinking_delta'; text: string }
|
|
83
83
|
| { type: 'done'; sessionId: string; stopReason?: 'end_turn' | 'max_turns_reached' | (string & {}); builtinHandled?: boolean }
|
|
84
|
-
| { type: 'model_resolved'; sessionId: string; model: string }
|
|
84
|
+
| { type: 'model_resolved'; sessionId: string; model: string; engine: string }
|
|
85
85
|
| { type: 'error'; message: string }
|
|
86
86
|
| { type: 'stats'; sample: { t: number; cpu: number; mem: number; load: number; disk: number; diskUsedBytes?: number; diskTotalBytes?: number } };
|
|
87
87
|
// Add-on engines/features emit their OWN events (e.g. a duplex voice brain's `duplex_*`) through the
|
|
@@ -356,8 +356,19 @@ export async function* streamChat(opts: {
|
|
|
356
356
|
console.log(`[stream] turn-context injected (${turnContext.length} chars) for session=${sessionId}`);
|
|
357
357
|
}
|
|
358
358
|
|
|
359
|
-
// Resolve engine and delegate
|
|
360
|
-
|
|
359
|
+
// Resolve engine and delegate. An unregistered engine is a HARD stop: rerouting to claude-code
|
|
360
|
+
// would switch provider and billing under the caller while the UI still showed the requested one.
|
|
361
|
+
// Surfaced as a turn `error` event (not a throw) so every transport — WS, Slack, scheduler, MCP,
|
|
362
|
+
// webhook — reports it the same way and a scheduled run is marked failed instead of dying.
|
|
363
|
+
let engine: ReturnType<typeof resolveAndGetEngine>;
|
|
364
|
+
try {
|
|
365
|
+
engine = resolveAndGetEngine(directives as any, config);
|
|
366
|
+
} catch (err) {
|
|
367
|
+
const message = (err as Error).message;
|
|
368
|
+
console.error(`[stream] ${message} (user=${opts.uid} session=${sessionId})`);
|
|
369
|
+
yield { type: 'error', message };
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
361
372
|
console.log(`[stream] engine=${engine.name} user=${opts.uid} session=${sessionId}`);
|
|
362
373
|
|
|
363
374
|
yield* engine.stream({
|
|
@@ -10,7 +10,7 @@ import { registerProactiveMessage } from '../slack/sessions.ts';
|
|
|
10
10
|
import { registerPoll } from '../polls.ts';
|
|
11
11
|
import { getSession, setSessionModel, getSessionModel, type ConvMessage } from '../sessions.ts';
|
|
12
12
|
import { DEFAULT_MODEL } from '../directives.ts';
|
|
13
|
-
import { resolveModelSwitch } from '../model-aliases.ts';
|
|
13
|
+
import { resolveModelSwitch, MODEL_ALIASES } from '../model-aliases.ts';
|
|
14
14
|
import type { WsEvent, AskQuestion, QuestionAnswers, QuestionHandler } from '../claude.ts';
|
|
15
15
|
import type { AgentEngine, EngineStreamOpts, EngineModel } from './types.ts';
|
|
16
16
|
import { getPromptSuffix } from '../prompt-suffix.ts';
|
|
@@ -41,6 +41,16 @@ const DESTRUCTIVE_DATA_PATTERNS = [
|
|
|
41
41
|
/>\s*data\/(conversations?|sessions?|schedules?)\//i,
|
|
42
42
|
];
|
|
43
43
|
|
|
44
|
+
/** Is `id` a model this engine can actually run? Anything else — notably a foreign id like
|
|
45
|
+
* `cursor/composer-2.5` or the bare `composer-2.5` a schedule pinned for another engine — used to be
|
|
46
|
+
* forwarded straight to the Anthropic SDK, billing Anthropic for a model the caller never asked it
|
|
47
|
+
* for. Shape-based rather than an exact list so dated ids (`claude-sonnet-4-5-20250929`) still pass.
|
|
48
|
+
* An explicit `anthropic/` prefix is this engine's own provider and is stripped first. */
|
|
49
|
+
function isOwnModel(id: string): boolean {
|
|
50
|
+
const bare = id.startsWith('anthropic/') ? id.slice('anthropic/'.length) : id;
|
|
51
|
+
return bare.startsWith('claude-') || bare.toLowerCase() in MODEL_ALIASES;
|
|
52
|
+
}
|
|
53
|
+
|
|
44
54
|
type DenyResult = { behavior: 'deny'; message: string };
|
|
45
55
|
|
|
46
56
|
function checkSensitiveAccess(toolName: string, input: Record<string, unknown>): DenyResult | null {
|
|
@@ -303,7 +313,20 @@ export class ClaudeCodeEngine implements AgentEngine {
|
|
|
303
313
|
|
|
304
314
|
// Always pass an explicit model — left unset, the CLI applies its own default
|
|
305
315
|
// (observed: Opus 4.7), not what the UI's "Default" label promises.
|
|
306
|
-
|
|
316
|
+
const requestedModel = directives.model || config.model || DEFAULT_MODEL;
|
|
317
|
+
// Refuse a model that isn't ours instead of posting it to Anthropic. Substituting our default
|
|
318
|
+
// silently would be the same billing lie in a different costume, so this ends the turn.
|
|
319
|
+
if (!isOwnModel(requestedModel)) {
|
|
320
|
+
const message =
|
|
321
|
+
`Model "${requestedModel}" does not belong to the ${this.name} engine, so this run was stopped ` +
|
|
322
|
+
`rather than billed to Anthropic under another provider's model name. Pick a Claude model, or ` +
|
|
323
|
+
`request the engine that owns it (e.g. \`[engine:cursor,model:${requestedModel}]\`) and make sure ` +
|
|
324
|
+
`that engine is registered on this server.`;
|
|
325
|
+
console.error(`[claude] ${message}`);
|
|
326
|
+
yield { type: 'error', message };
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
options['model'] = requestedModel;
|
|
307
330
|
const thinkingMode = directives.thinking ?? config.thinking;
|
|
308
331
|
if (thinkingMode) options['thinking'] = thinkingMode === 'enabled' ? { type: 'enabled' } : { type: thinkingMode };
|
|
309
332
|
const effort = directives.effort ?? config.effort;
|
|
@@ -412,11 +435,11 @@ export class ClaudeCodeEngine implements AgentEngine {
|
|
|
412
435
|
prior: opts.sessionId ? getSessionModel(opts.sessionId) : undefined,
|
|
413
436
|
});
|
|
414
437
|
if (sw.notice) yield { type: 'text_delta', text: sw.notice };
|
|
415
|
-
if (opts.sessionId) setSessionModel(opts.sessionId, m.model);
|
|
438
|
+
if (opts.sessionId) setSessionModel(opts.sessionId, m.model, this.name);
|
|
416
439
|
// Live ground-truth so the header pill confirms the actually-resolved model mid-turn
|
|
417
440
|
// (catches inline overrides like [opus] and silent rate-limit fallbacks) instead of
|
|
418
441
|
// only updating on session reload.
|
|
419
|
-
yield { type: 'model_resolved', sessionId: opts.sessionId ?? '', model: m.model };
|
|
442
|
+
yield { type: 'model_resolved', sessionId: opts.sessionId ?? '', model: m.model, engine: this.name };
|
|
420
443
|
}
|
|
421
444
|
const servers = m.mcp_servers;
|
|
422
445
|
if (Array.isArray(servers) && servers.length > 0) {
|
|
@@ -15,7 +15,8 @@ export async function initEngines(): Promise<void> {
|
|
|
15
15
|
// Core always registers the Claude Code engine (the CE default — @anthropic-ai/claude-agent-sdk).
|
|
16
16
|
// Optional engines are registered by an add-on through the same `registerEngine` seam when the
|
|
17
17
|
// SHRAGA_OVERLAY loads (it's imported before the server serves any turn). Bare CE runs Claude Code
|
|
18
|
-
// only; a directive requesting an unregistered engine
|
|
18
|
+
// only; a directive requesting an unregistered engine FAILS the turn (resolveAndGetEngine) rather
|
|
19
|
+
// than rerouting to another vendor's billing.
|
|
19
20
|
registerEngine(new ClaudeCodeEngine());
|
|
20
21
|
|
|
21
22
|
// Let `[<model>]` name ANY registered engine's model (e.g. `[composer-2.5]`) and imply its engine.
|
|
@@ -49,15 +50,26 @@ export function resolveEngine(directives?: { engine?: string }, agentConfig?: {
|
|
|
49
50
|
return 'claude-code';
|
|
50
51
|
}
|
|
51
52
|
|
|
53
|
+
/** Requested engine isn't registered on this boot. Carries an actionable message; callers surface it
|
|
54
|
+
* as a turn error (see streamClaude) — never as a reroute to a different vendor's engine/billing. */
|
|
55
|
+
export class EngineUnavailableError extends Error {
|
|
56
|
+
constructor(public readonly engine: string, available: string[]) {
|
|
57
|
+
super(
|
|
58
|
+
`Engine "${engine}" is not available on this server. Registered engines: ${available.join(', ') || 'none'}. ` +
|
|
59
|
+
`Optional engines register only when enabled at boot (AGENT_ENGINES must list the engine; the native ` +
|
|
60
|
+
`cursor engine also needs CURSOR_API_KEY) — check the server env and startup log, then retry. ` +
|
|
61
|
+
`The run was stopped rather than silently re-routed to another provider's billing.`,
|
|
62
|
+
);
|
|
63
|
+
this.name = 'EngineUnavailableError';
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
52
67
|
export function resolveAndGetEngine(directives?: { engine?: string }, agentConfig?: { engine?: string }) {
|
|
53
68
|
const name = resolveEngine(directives, agentConfig);
|
|
54
69
|
// An optional engine may be unregistered on a given boot (add-on not loaded, missing API key or
|
|
55
|
-
// failed init).
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
if (!hasEngine(name))
|
|
59
|
-
console.warn(`[engine] "${name}" not registered (available: ${getAvailableEngines().join(', ') || 'none'}) — falling back to claude-code`);
|
|
60
|
-
return getEngine('claude-code');
|
|
61
|
-
}
|
|
70
|
+
// failed init). Rerouting to claude-code here silently switched PROVIDER AND BILLING under the
|
|
71
|
+
// caller — a cursor/agentx run billed Anthropic while the UI still showed the cursor chips. Fail
|
|
72
|
+
// loudly instead; the degradation is surfaced to whoever asked (user, schedule) as a turn error.
|
|
73
|
+
if (!hasEngine(name)) throw new EngineUnavailableError(name, getAvailableEngines());
|
|
62
74
|
return getEngine(name);
|
|
63
75
|
}
|
|
@@ -138,7 +138,11 @@ export function ensureBuiltinSchedules(schedules: Schedule[]): Schedule[] {
|
|
|
138
138
|
match: { status: 'error' },
|
|
139
139
|
throttle: { byFields: ['name', 'error'], windowSec: 21600 },
|
|
140
140
|
},
|
|
141
|
-
|
|
141
|
+
// Pinned to the always-registered engine ON PURPOSE. Left unpinned, this run inherits whatever
|
|
142
|
+
// agent-config.json's global `engine` happens to be — and an optional engine can simply not
|
|
143
|
+
// register on a given boot (add-on absent, missing API key), which is exactly the failure this
|
|
144
|
+
// job exists to report. Its own alert must not be the second casualty.
|
|
145
|
+
task: { kind: 'prompt', prompt: FAILURE_NOTIFIER_PROMPT, engine: 'claude-code' },
|
|
142
146
|
scope: 'system',
|
|
143
147
|
createdBy: { uid: SYSTEM_UID, email: 'system@shraga.local' },
|
|
144
148
|
createdAt: now,
|
|
@@ -163,6 +167,16 @@ export function ensureBuiltinSchedules(schedules: Schedule[]): Schedule[] {
|
|
|
163
167
|
existing.scope = builtin.scope;
|
|
164
168
|
existing.trigger = existing.trigger ?? builtin.trigger;
|
|
165
169
|
existing.createdBy = builtin.createdBy;
|
|
170
|
+
// A builtin's stored task is preserved (deployments edit the prompt) — with one exception: an
|
|
171
|
+
// engine/model pin is only meaningful as a PAIR. A stored task with a `model` but no `engine`
|
|
172
|
+
// was resolved against whatever the ambient global config was at the time, which is how the
|
|
173
|
+
// failure notifier ended up pinned to `composer-2.5` while running on claude-code. So when the
|
|
174
|
+
// builtin pins an engine and the stored task pins NONE, adopt the builtin's pair wholesale.
|
|
175
|
+
// An explicit stored `engine` is a real operator choice and is left untouched.
|
|
176
|
+
if (builtin.task.kind !== 'job' && existing.task.kind !== 'job' && builtin.task.engine && !existing.task.engine) {
|
|
177
|
+
existing.task.engine = builtin.task.engine;
|
|
178
|
+
existing.task.model = builtin.task.model;
|
|
179
|
+
}
|
|
166
180
|
} else {
|
|
167
181
|
schedules.push(builtin);
|
|
168
182
|
}
|
|
@@ -219,10 +219,11 @@ export async function runSchedule(
|
|
|
219
219
|
// task.engine/task.model ride the same prompt-directive channel users type by hand —
|
|
220
220
|
// parseDirectives strips them and resolves aliases. Prepending (vs new plumbing) also persists the
|
|
221
221
|
// choice into the saved prompt, so the session UI shows what the schedule actually requested.
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
}
|
|
222
|
+
// Applied on RESUME too: a resume's prompt is a fresh "continue where you left off" string, not the
|
|
223
|
+
// saved original, so skipping this here silently dropped the schedule's engine/model pin and let the
|
|
224
|
+
// resumed turn run on the global default — a different engine, and a different vendor's bill.
|
|
225
|
+
const pins = [task.engine && `engine:${task.engine}`, task.model && `model:${task.model}`].filter(Boolean);
|
|
226
|
+
if (pins.length) prompt = `[${pins.join(',')}] ${prompt}`;
|
|
226
227
|
|
|
227
228
|
// Save the synthesized user prompt to the conversation (skip on resume — task prompt already persisted).
|
|
228
229
|
if (!resume) {
|
package/src/server/sessions.ts
CHANGED
|
@@ -38,6 +38,9 @@ export interface SessionMeta {
|
|
|
38
38
|
directives?: Directives;
|
|
39
39
|
/** Actual model the engine resolved at runtime (from the SDK init message) — ground truth, unlike directives.model which is the request. */
|
|
40
40
|
lastModel?: string;
|
|
41
|
+
/** Engine that actually ran the last turn — ground truth beside `lastModel`, so the UI can report
|
|
42
|
+
* what executed instead of inferring the engine from the model id's shape. */
|
|
43
|
+
lastEngine?: string;
|
|
41
44
|
forkedFrom?: string;
|
|
42
45
|
}
|
|
43
46
|
|
|
@@ -189,19 +192,24 @@ export function setSessionDirectives(sessionId: string, directives: NonNullable<
|
|
|
189
192
|
// Resolved model per running session (set by the engine from the SDK init message).
|
|
190
193
|
// appendMessage stamps assistant messages from this map so every channel records it.
|
|
191
194
|
const liveModels = new Map<string, string>();
|
|
195
|
+
const liveEngines = new Map<string, string>();
|
|
192
196
|
|
|
193
197
|
/** Last resolved model for a session (live map first, then persisted lastModel). */
|
|
194
198
|
export function getSessionModel(sessionId: string): string | undefined {
|
|
195
199
|
return liveModels.get(sessionId) ?? loadIndex().find((s) => s.sessionId === sessionId)?.lastModel;
|
|
196
200
|
}
|
|
197
201
|
|
|
198
|
-
|
|
199
|
-
|
|
202
|
+
/** Record the runtime ground truth for a turn. `engine` is the engine that actually ran it — the
|
|
203
|
+
* pair is what the header reports, so neither half may be inferred from the other. */
|
|
204
|
+
export function setSessionModel(sessionId: string, model: string, engine?: string): void {
|
|
205
|
+
if (liveModels.get(sessionId) === model && (!engine || liveEngines.get(sessionId) === engine)) return;
|
|
200
206
|
liveModels.set(sessionId, model);
|
|
207
|
+
if (engine) liveEngines.set(sessionId, engine);
|
|
201
208
|
const sessions = loadIndex();
|
|
202
209
|
const s = sessions.find((s) => s.sessionId === sessionId);
|
|
203
|
-
if (s && s.lastModel !== model) {
|
|
210
|
+
if (s && (s.lastModel !== model || (engine && s.lastEngine !== engine))) {
|
|
204
211
|
s.lastModel = model;
|
|
212
|
+
if (engine) s.lastEngine = engine;
|
|
205
213
|
saveIndex(sessions);
|
|
206
214
|
}
|
|
207
215
|
}
|