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.
@@ -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-BSvY0squ.js"></script>
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.88",
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 = sessionDirectives?.engine || config.engine || 'claude-code';
32
- // The native Claude Code engine records a bare `claude-*` id; a multi-provider add-on engine records
33
- // a `provider/model` id. Trust the recorded model only when its shape matches the current engine
34
- // family, or a session that has since switched engines would show the previous engine's model.
35
- const recordedByNative = actualModel ? !actualModel.includes('/') : undefined;
36
- const engineIsNative = engine === 'claude-code' || engine === 'cursor';
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}
@@ -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' }
@@ -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
- const engine = resolveAndGetEngine(directives as any, config);
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
- options['model'] = directives.model ?? config.model ?? DEFAULT_MODEL;
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 falls back to claude-code (resolveAndGetEngine).
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). Don't let that throw and kill every run including scheduled jobs like the daily
56
- // digest, which resolve the engine from the global agent-config. Fall back to the always-present
57
- // claude-code engine with a warning instead.
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
- task: { kind: 'prompt', prompt: FAILURE_NOTIFIER_PROMPT },
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
- if (!resume) {
223
- const pins = [task.engine && `engine:${task.engine}`, task.model && `model:${task.model}`].filter(Boolean);
224
- if (pins.length) prompt = `[${pins.join(',')}] ${prompt}`;
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) {
@@ -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
- export function setSessionModel(sessionId: string, model: string): void {
199
- if (liveModels.get(sessionId) === model) return;
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
  }