shraga 0.1.93 → 0.1.95

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,8 +13,8 @@
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-C3l1Wz_q.js"></script>
17
- <link rel="stylesheet" crossorigin href="/assets/index-B--gTLyT.css">
16
+ <script type="module" crossorigin src="/assets/index-D2xviJL0.js"></script>
17
+ <link rel="stylesheet" crossorigin href="/assets/index-CJDfTuzn.css">
18
18
  </head>
19
19
  <body>
20
20
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shraga",
3
- "version": "0.1.93",
3
+ "version": "0.1.95",
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",
@@ -5,6 +5,7 @@ import { Input } from './ui/input';
5
5
  import { Textarea } from './ui/textarea';
6
6
  import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogBody, DialogFooter } from './ui/dialog';
7
7
  import { useSlots } from '@/lib/slots';
8
+ import { useEngines, type EngineModel } from '@/hooks/useEngines';
8
9
 
9
10
  interface AgentConfig {
10
11
  model?: string;
@@ -18,17 +19,6 @@ interface AgentConfig {
18
19
  effort?: 'low' | 'medium' | 'high' | 'max';
19
20
  }
20
21
 
21
- interface EngineModel {
22
- value: string;
23
- label: string;
24
- provider?: string;
25
- }
26
-
27
- interface EngineInfo {
28
- name: string;
29
- models: EngineModel[];
30
- }
31
-
32
22
  const FALLBACK_MODELS: EngineModel[] = [
33
23
  { value: '', label: 'Default (claude-sonnet-5)' },
34
24
  { value: 'claude-fable-5', label: 'Fable 5 — frontier, most capable' },
@@ -64,8 +54,7 @@ export function ConfigPanel({ getToken, onSaved, trigger, sessionId, sessionDire
64
54
  const [config, setConfig] = useState<AgentConfig>({});
65
55
  const [open, setOpen] = useState(false);
66
56
  const [saving, setSaving] = useState(false);
67
- const [engines, setEngines] = useState<EngineInfo[]>([]);
68
- const [multiEngine, setMultiEngine] = useState(false);
57
+ const { engines, multiEngine } = useEngines(getToken, open);
69
58
  // Global config as loaded — so a per-session runtime change doesn't clobber the global defaults.
70
59
  const globalRef = useRef<AgentConfig>({});
71
60
  // Save scope for the runtime knobs (engine/model/turns/thinking): this conversation, or the
@@ -93,13 +82,6 @@ export function ConfigPanel({ getToken, onSaved, trigger, sessionId, sessionDire
93
82
  });
94
83
  })
95
84
  .catch(() => {});
96
- fetch('/api/engines', { headers: { Authorization: `Bearer ${token}` } })
97
- .then((r) => r.json())
98
- .then((data) => {
99
- setEngines(data.engines ?? []);
100
- setMultiEngine(data.multiEngine ?? false);
101
- })
102
- .catch(() => {});
103
85
  });
104
86
  }, [open, getToken, sessionId, sessionDirectives]);
105
87
 
@@ -11,8 +11,16 @@ 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.
14
+ /** Provenance of what the chips report.
15
+ * - `ran` a turn executed and recorded a self-consistent (engine, model) PAIR.
16
+ * - `ran-model` a turn executed and recorded its MODEL, but not the engine that ran it — every
17
+ * session written before `lastEngine` existed (45 of 817 on the live box).
18
+ * - `pending` nothing has run in this conversation yet. */
19
+ export type RuntimeProvenance = 'ran' | 'ran-model' | 'pending';
20
+
21
+ /** What the chips must report: the engine/model that ACTUALLY ran, and — separately — what this
22
+ * session is currently SET to run. Those are different claims and the pill must never blur them.
23
+ * Pure, so the rule is testable without a DOM.
16
24
  *
17
25
  * The bug this replaces: the old code inferred the engine from the model id's SHAPE (bare ⇒ native)
18
26
  * and threw away the runtime-recorded model whenever the shape disagreed with the requested engine —
@@ -28,23 +36,32 @@ export function deriveRuntimeBadges(input: {
28
36
  actualModel?: string;
29
37
  }) {
30
38
  const requestedEngine = input.requestedEngine || 'claude-code';
31
- const engine = input.actualEngine || requestedEngine;
39
+ const provenance: RuntimeProvenance = input.actualEngine ? 'ran' : input.actualModel ? 'ran-model' : 'pending';
40
+
41
+ // A recorded model is ground truth about the MODEL whether or not an engine was recorded beside
42
+ // it, so it is never dropped — dropping it made the UI report the REQUESTED runtime for a turn
43
+ // that had already run, the one claim this must never make. What a lone model cannot do is name
44
+ // the engine: an id is bare or prefixed by PROVIDER, and a provider is not an engine (agentx runs
45
+ // anthropic models). So with no `lastEngine`, the engine stays unknown rather than guessed.
46
+ const engine = provenance === 'ran' ? input.actualEngine! : provenance === 'pending' ? requestedEngine : undefined;
47
+ const rawModel =
48
+ input.actualModel || input.requestedModel || (requestedEngine === 'cursor' ? 'cursor/composer-2.5' : 'sonnet-4-6');
49
+
32
50
  // 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;
51
+ // other than where this session currently asks to run — which the ground-truth pill alone cannot
52
+ // say, because it reports only where the turn DID run, not where the next one will be sent.
53
+ const engineMismatch = provenance === 'ran' && engine !== requestedEngine ? requestedEngine : undefined;
35
54
  const engineIsNative = engine === 'claude-code' || engine === 'cursor';
36
- // "The pair, or neither" is right for the ENGINE, not for the provider: a `provider/` prefix on the
37
- // recorded model is direct evidence of what actually ran and was billed, and needs no engine to read.
38
- // Only a BARE recorded id is unreadable alone (it belongs to whichever engine recorded it), so only
39
- // that one is dropped. Sessions written before lastEngine existed carry a prefixed model and nothing
40
- // else dropping it made the UI report the requested provider, the very claim this must never make.
41
- const recordedModel = input.actualEngine || input.actualModel?.includes('/') ? input.actualModel : undefined;
42
- const rawModel =
43
- recordedModel || input.requestedModel || (engine === 'cursor' ? 'cursor/composer-2.5' : 'sonnet-4-6');
44
- // Provider = the model's prefix; a bare id belongs to the engine that ran it (claude-code ⇒ anthropic,
45
- // an add-on engine that engine's own provider) — never assume anthropic just because a prefix is absent.
46
- const billingProvider = rawModel.includes('/') ? rawModel.split('/')[0] : engine === 'claude-code' ? 'anthropic' : engine;
47
- return { engine, engineIsNative, engineMismatch, rawModel, billingProvider };
55
+
56
+ // Provider = the model's prefix; a bare id belongs to the engine that ran it (claude-code
57
+ // anthropic, an add-on engine that engine's own provider). With neither a prefix nor a known
58
+ // engine there is nothing to name it from, so it is reported as unknown, never assumed.
59
+ const billingProvider = rawModel.includes('/')
60
+ ? rawModel.split('/')[0]
61
+ : engine === 'claude-code'
62
+ ? 'anthropic'
63
+ : engine;
64
+ return { provenance, engine, engineIsNative, engineMismatch, rawModel, billingProvider };
48
65
  }
49
66
 
50
67
  function InfoBadges({
@@ -68,7 +85,7 @@ function InfoBadges({
68
85
  onScheduleClick?: () => void;
69
86
  }) {
70
87
  const [copied, setCopied] = useState(false);
71
- const { engine, engineIsNative, engineMismatch, rawModel, billingProvider } = deriveRuntimeBadges({
88
+ const { provenance, engine, engineIsNative, engineMismatch, rawModel, billingProvider } = deriveRuntimeBadges({
72
89
  requestedEngine: sessionDirectives?.engine || config.engine,
73
90
  requestedModel: sessionDirectives?.model || config.model,
74
91
  actualEngine,
@@ -77,7 +94,18 @@ function InfoBadges({
77
94
  // A multi-provider add-on engine runs any provider's model through its own loop, so it must be
78
95
  // distinguishable from a native runtime running the same model. Prefix such a model with the engine
79
96
  // name; native engines (claude-code, cursor) show the model plainly. Engine name comes from data.
80
- const model = !engineIsNative ? `${engine} · ${rawModel.replace('claude-', '')}` : rawModel.replace('claude-', '');
97
+ // With no engine recorded there is nothing truthful to prefix with, so the model stands alone.
98
+ const shortModel = rawModel.replace('claude-', '');
99
+ const model = engine && !engineIsNative ? `${engine} · ${shortModel}` : shortModel;
100
+ // A selection is not a runtime. Until a turn has run, the chips describe what the NEXT one will
101
+ // use — marked so, and never presented as something that executed.
102
+ const pending = provenance === 'pending';
103
+ const pendingRing = pending ? ' border border-dashed border-current/40 opacity-80' : '';
104
+ const runtimeTitle = pending
105
+ ? 'Nothing has run in this conversation yet — this is what the next turn is set to use.'
106
+ : provenance === 'ran-model'
107
+ ? `Model recorded from the last turn. The engine that ran it was not recorded (this conversation predates engine tracking), so it is not named here.`
108
+ : `Engine and model that actually ran the last turn.`;
81
109
  // Auth-mechanism indicator. The only verifiable distinction is claude.ai OAuth login vs a provider
82
110
  // API key: the native claude-code engine can run on a login (no ANTHROPIC_API_KEY); every add-on
83
111
  // engine / provider-prefixed model runs on that provider's key (ai.libx.js adapters throw without
@@ -86,15 +114,19 @@ function InfoBadges({
86
114
  const onSubscription = engine === 'claude-code' && config.claudeAuthSource === 'subscription';
87
115
  // Tone: green = claude.ai login (no key); amber = provider key whose usage may be subscription-
88
116
  // covered (Cursor); rose = provider key that is genuinely metered (Anthropic/OpenAI/etc.).
89
- const billingTone = onSubscription ? 'sub' : billingProvider === 'cursor' ? 'plan' : 'metered';
117
+ const billingTone = !billingProvider ? 'unknown' : onSubscription ? 'sub' : billingProvider === 'cursor' ? 'plan' : 'metered';
90
118
  const billingClass =
91
- billingTone === 'sub'
119
+ billingTone === 'unknown'
120
+ ? 'bg-muted text-muted-foreground ring-border'
121
+ : billingTone === 'sub'
92
122
  ? 'bg-emerald-50 text-emerald-700 ring-emerald-600/20 dark:bg-emerald-950/50 dark:text-emerald-300 dark:ring-emerald-400/30'
93
123
  : billingTone === 'plan'
94
124
  ? 'bg-amber-50 text-amber-700 ring-amber-600/20 dark:bg-amber-950/50 dark:text-amber-300 dark:ring-amber-400/30'
95
125
  : 'bg-rose-50 text-rose-700 ring-rose-600/20 dark:bg-rose-950/50 dark:text-rose-300 dark:ring-rose-400/30';
96
126
  const billingTitle =
97
- billingTone === 'sub'
127
+ billingTone === 'unknown'
128
+ ? 'The engine that ran this turn was not recorded and the model id carries no provider prefix — the billed provider cannot be named from what was stored.'
129
+ : billingTone === 'sub'
98
130
  ? 'Claude.ai subscription (OAuth login) — no API key in use'
99
131
  : billingTone === 'plan'
100
132
  ? `Runs on your ${billingProvider} API key — usage may draw on your ${billingProvider} plan/subscription`
@@ -113,14 +145,17 @@ function InfoBadges({
113
145
 
114
146
  return (
115
147
  <div className="flex items-center gap-1.5 flex-wrap">
116
- <span className="inline-flex items-center rounded-md bg-blue-50 px-1.5 py-0.5 text-[10px] font-medium text-blue-700 ring-1 ring-inset ring-blue-600/20 dark:bg-blue-950/50 dark:text-blue-300 dark:ring-blue-400/30">
117
- {model}
148
+ <span
149
+ title={runtimeTitle}
150
+ className={`inline-flex items-center rounded-md bg-blue-50 px-1.5 py-0.5 text-[10px] font-medium text-blue-700 ring-1 ring-inset ring-blue-600/20 dark:bg-blue-950/50 dark:text-blue-300 dark:ring-blue-400/30${pendingRing}`}
151
+ >
152
+ {pending ? `→ ${model}` : model}
118
153
  </span>
119
154
  <span
120
- title={billingTitle}
121
- className={`inline-flex items-center rounded-md px-1.5 py-0.5 text-[10px] font-medium ring-1 ring-inset ${billingClass}`}
155
+ title={pending ? `${runtimeTitle} ${billingTitle}` : billingTitle}
156
+ className={`inline-flex items-center rounded-md px-1.5 py-0.5 text-[10px] font-medium ring-1 ring-inset ${billingClass}${pendingRing}`}
122
157
  >
123
- {onSubscription ? 'sub' : `API·${billingProvider}`}
158
+ {billingTone === 'unknown' ? 'API·?' : onSubscription ? 'sub' : `API·${billingProvider}`}
124
159
  </span>
125
160
  {engineMismatch && (
126
161
  <span
@@ -65,6 +65,7 @@ export const SchedulesManager = forwardRef<SchedulesManagerHandle, Props>(functi
65
65
  {creating ? (
66
66
  <ScheduleEditor
67
67
  key="__new__"
68
+ getToken={getToken}
68
69
  onSave={async (s) => { await create(s); closeEditor(); }}
69
70
  onCancel={closeEditor}
70
71
  skills={skills}
@@ -73,6 +74,7 @@ export const SchedulesManager = forwardRef<SchedulesManagerHandle, Props>(functi
73
74
  ) : editing ? (
74
75
  <ScheduleEditor
75
76
  key={editing.id}
77
+ getToken={getToken}
76
78
  initial={editing}
77
79
  onSave={async (s) => { await update(editing.id, s); closeEditor(); }}
78
80
  onCancel={closeEditor}
@@ -5,8 +5,11 @@ import { Button } from '../ui/button';
5
5
  import { Input } from '../ui/input';
6
6
  import { Textarea } from '../ui/textarea';
7
7
  import { AutocompleteTextarea } from '../AutocompleteTextarea';
8
+ import { useEngines } from '@/hooks/useEngines';
9
+ import { readRuntimeDirective, writeRuntimeDirective } from '@/lib/prompt-directives';
8
10
 
9
11
  interface Props {
12
+ getToken: () => Promise<string | null>;
10
13
  initial?: Schedule;
11
14
  onSave: (s: Partial<Schedule>) => Promise<void>;
12
15
  onCancel: () => void;
@@ -63,7 +66,65 @@ function MatchEditor({ match, onChange }: { match: Record<string, string>; onCha
63
66
  );
64
67
  }
65
68
 
66
- export function ScheduleEditor({ initial, onSave, onCancel, skills = [], workspaceFiles = [] }: Props) {
69
+ /** Engine + model picker for a prompt task. There is no stored field behind it: the leading
70
+ * `[engine:…,model:…]` directive in the prompt IS the selection, so this reads that directive and
71
+ * rewrites it in place. A directive typed by hand therefore shows up here, and a pin can never
72
+ * drift out of sync with the prompt the way a shadow `task.model` did. */
73
+ function RuntimePicker({ prompt, onChange, getToken }: { prompt: string; onChange: (p: string) => void; getToken: () => Promise<string | null> }) {
74
+ const { engines } = useEngines(getToken);
75
+ // Until the registry has loaded we cannot tell a bare model token from prose, so a rewrite could
76
+ // leave a stale duplicate pin behind. Read-only until then.
77
+ const ready = engines.length > 0;
78
+ const sel = useMemo(() => readRuntimeDirective(prompt), [prompt, ready]);
79
+ // Narrow the model list only to an engine the directive actually NAMES. An engine merely implied
80
+ // by the model would otherwise trap a model-only pin — 11 of the 15 live schedules — inside that
81
+ // engine's list, with no way to reach another engine's models.
82
+ const engineInfo = sel.engineExplicit ? engines.find((e) => e.name === sel.engine) : undefined;
83
+ const options = engineInfo
84
+ ? [{ engine: engineInfo.name, models: engineInfo.models }]
85
+ : engines.map((e) => ({ engine: e.name, models: e.models }));
86
+ // A <select> whose value matches no option silently renders the FIRST option while the state says
87
+ // otherwise — so a hand-typed directive naming something this engine doesn't list would be shown
88
+ // as a different selection than the one that will actually run. Surface it instead of lying.
89
+ const listed = (v: string | undefined, all: string[]) => !v || all.includes(v);
90
+ const modelListed = listed(sel.model, options.flatMap((o) => o.models.map((m) => m.value)));
91
+ const engineListed = listed(sel.engine, engines.map((e) => e.name));
92
+ // Only an engine the directive NAMES is carried forward. An inferred one belongs to the model it
93
+ // was inferred from: re-emitting it while the user picks another engine's model would silently
94
+ // pin a pairing nobody chose (agentx running a claude-code model).
95
+ const set = (next: { engine?: string; model?: string }) =>
96
+ onChange(writeRuntimeDirective(prompt, { engine: sel.engineExplicit ? sel.engine : undefined, model: sel.model, ...next }));
97
+ const selectClass = 'h-8 rounded-md border border-input bg-background px-2 text-xs';
98
+
99
+ return (
100
+ <div className="flex flex-wrap items-center gap-2">
101
+ <label className="text-xs text-muted-foreground">Runtime</label>
102
+ <select
103
+ className={selectClass}
104
+ disabled={!ready}
105
+ value={sel.engine ?? ''}
106
+ // Switching engine drops the model pin: the old model belongs to the old engine's list.
107
+ onChange={(e) => set({ engine: e.target.value || undefined, model: undefined })}
108
+ >
109
+ <option value="">Default engine (agent config)</option>
110
+ {!engineListed && <option value={sel.engine}>{sel.engine} — not registered on this server</option>}
111
+ {engines.map((e) => <option key={e.name} value={e.name}>{e.name}</option>)}
112
+ </select>
113
+ <select className={selectClass} disabled={!ready} value={sel.model ?? ''} onChange={(e) => set({ model: e.target.value || undefined })}>
114
+ <option value="">Default model (agent config)</option>
115
+ {!modelListed && <option value={sel.model}>{sel.model} — not offered by this engine</option>}
116
+ {options.map(({ engine, models }) => (
117
+ <optgroup key={engine} label={engine}>
118
+ {models.filter((m) => m.value).map((m) => <option key={`${engine}:${m.value}`} value={m.value}>{m.label || m.value}</option>)}
119
+ </optgroup>
120
+ ))}
121
+ </select>
122
+ {!ready && <span className="text-[10px] text-muted-foreground">loading engines…</span>}
123
+ </div>
124
+ );
125
+ }
126
+
127
+ export function ScheduleEditor({ getToken, initial, onSave, onCancel, skills = [], workspaceFiles = [] }: Props) {
67
128
  const tz = useMemo(() => Intl.DateTimeFormat().resolvedOptions().timeZone, []);
68
129
  const [name, setName] = useState(initial?.name ?? 'New schedule');
69
130
  const [enabled, setEnabled] = useState(initial?.enabled ?? true);
@@ -225,6 +286,8 @@ export function ScheduleEditor({ initial, onSave, onCancel, skills = [], workspa
225
286
  ))}
226
287
  </div>
227
288
  {task.kind === 'prompt' ? (
289
+ <>
290
+ <RuntimePicker prompt={task.prompt} getToken={getToken} onChange={(prompt) => setTask({ ...task, prompt })} />
228
291
  <AutocompleteTextarea
229
292
  rows={5}
230
293
  value={task.prompt}
@@ -235,6 +298,7 @@ export function ScheduleEditor({ initial, onSave, onCancel, skills = [], workspa
235
298
  autoResize={false}
236
299
  className="flex min-h-[80px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
237
300
  />
301
+ </>
238
302
  ) : task.kind === 'bash' ? (
239
303
  <Textarea
240
304
  rows={3}
@@ -0,0 +1,52 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { setModelResolver } from '../../server/directives.ts';
3
+ import { makeModelResolver } from '../../server/engine/model-resolver.ts';
4
+
5
+ export interface EngineModel {
6
+ value: string;
7
+ label: string;
8
+ provider?: string;
9
+ }
10
+
11
+ export interface EngineInfo {
12
+ name: string;
13
+ models: EngineModel[];
14
+ }
15
+
16
+ /**
17
+ * The live engine registry (`GET /api/engines`) — so a picker can never offer an engine or model
18
+ * this server doesn't actually have.
19
+ *
20
+ * It also installs the model resolver the directive parser uses, seeded from the same response.
21
+ * Without it the client reads `[composer-2.5]` as prose while the server reads it as a model on
22
+ * the agentx engine — and an editor that disagrees with the parser corrupts what it rewrites.
23
+ */
24
+ // One process-wide registry cache behind one resolver installed at import time. Per-hook install/
25
+ // teardown would let one component's unmount clear the resolver another is still parsing with.
26
+ let registry: EngineInfo[] = [];
27
+ setModelResolver(makeModelResolver(() => registry));
28
+
29
+ export function useEngines(getToken: () => Promise<string | null>, enabled = true) {
30
+ const [engines, setEngines] = useState<EngineInfo[]>(registry);
31
+ const [multiEngine, setMultiEngine] = useState(false);
32
+
33
+ useEffect(() => {
34
+ if (!enabled) return;
35
+ let alive = true;
36
+ getToken().then((token) => {
37
+ if (!token || !alive) return;
38
+ fetch('/api/engines', { headers: { Authorization: `Bearer ${token}` } })
39
+ .then((r) => r.json())
40
+ .then((data) => {
41
+ if (!alive) return;
42
+ registry = data.engines ?? [];
43
+ setEngines(registry);
44
+ setMultiEngine(data.multiEngine ?? false);
45
+ })
46
+ .catch((e) => console.warn('[engines] load failed', e));
47
+ });
48
+ return () => { alive = false; };
49
+ }, [enabled, getToken]);
50
+
51
+ return { engines, multiEngine };
52
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Reading and rewriting the leading `[engine:…,model:…]` runtime directive of a prompt.
3
+ *
4
+ * The directive IS the selection — there is no separate stored field to shadow it — so the
5
+ * schedule editor's runtime picker is just a view onto this text. Both halves reuse the server's
6
+ * own parser (`src/server/directives.ts`), never a second one: a divergent client opinion would
7
+ * either strip a token the server still reads or keep one it doesn't.
8
+ */
9
+ import { parseDirectives, splitDirectiveGroups, resolveModelAlias } from '../../server/directives.ts';
10
+
11
+ export interface RuntimeSelection {
12
+ engine?: string;
13
+ model?: string;
14
+ }
15
+
16
+ export interface ReadSelection extends RuntimeSelection {
17
+ /** Was the engine WRITTEN as `engine:…`, or merely implied by an engine-owned model token?
18
+ * An implied engine must not be treated as a choice — narrowing a model picker to it would trap
19
+ * a model-only directive inside one engine's list, with no way to pick another engine's model. */
20
+ engineExplicit: boolean;
21
+ }
22
+
23
+ /** What the server will actually run this prompt on, as written. Includes a directive the user
24
+ * typed by hand, and an engine implied by a bare engine-owned model token (`[composer-2.5]`). */
25
+ export function readRuntimeDirective(text: string): ReadSelection {
26
+ const { directives } = parseDirectives(text);
27
+ const engineExplicit = splitDirectiveGroups(text, { strict: true }).groups.some((g) =>
28
+ g.split(',').some((t) => /^\s*engine\s*:/i.test(t)));
29
+ return { engine: directives.engine, model: directives.model, engineExplicit };
30
+ }
31
+
32
+ const RUNTIME_KEY_RE = /^(engine|model)\s*:/i;
33
+
34
+ /**
35
+ * Replaces the runtime selection in place, preserving every other directive (turns, thinking,
36
+ * effort) and the prompt body verbatim.
37
+ *
38
+ * Strict group scanning: unlike the server parser — which by contract consumes its first bracket
39
+ * group even when it is nonsense — this only touches groups that actually READ as directives, so a
40
+ * prompt whose body legitimately opens with `[WARN] …` is left alone and gets the pin prepended.
41
+ */
42
+ export function writeRuntimeDirective(text: string, sel: RuntimeSelection): string {
43
+ const { groups, body } = splitDirectiveGroups(text, { strict: true });
44
+ const kept: string[] = [];
45
+ // Mirrors the parser's positional rule: only the FIRST positional token can be a model.
46
+ let seenPositional = false;
47
+ for (const group of groups) {
48
+ for (const raw of group.split(',').map((t) => t.trim()).filter(Boolean)) {
49
+ if (RUNTIME_KEY_RE.test(raw)) continue; // the pin being replaced
50
+ if (raw.includes(':')) { kept.push(raw); continue; }
51
+ const isBareModel = !seenPositional && !!resolveModelAlias(raw);
52
+ seenPositional = true;
53
+ if (!isBareModel) kept.push(raw); // `120` (turns), `think` — not ours to touch
54
+ }
55
+ }
56
+ // An engine the chosen model already implies is not written: `[model:cursor/composer-2.5]` and
57
+ // `[engine:agentx,model:cursor/composer-2.5]` mean the same thing to the parser, and emitting the
58
+ // longer one would stamp a pin the user never chose onto every schedule that carries a model-only
59
+ // directive (11 of the 15 live ones) the moment the picker is touched.
60
+ const impliedEngine = sel.model ? resolveModelAlias(sel.model)?.engine : undefined;
61
+ const engine = sel.engine && sel.engine !== impliedEngine ? sel.engine : undefined;
62
+ const pins = [engine && `engine:${engine}`, sel.model && `model:${sel.model}`].filter(Boolean) as string[];
63
+ const tokens = [...pins, ...kept];
64
+ return tokens.length ? `[${tokens.join(',')}] ${body}` : body;
65
+ }
@@ -5,8 +5,8 @@ export type Trigger =
5
5
  | { kind: 'event'; source: string; match?: Record<string, string> };
6
6
 
7
7
  export type Task =
8
- | { kind: 'prompt'; prompt: string; model?: string; engine?: string }
9
- | { kind: 'bash'; command: string; model?: string; engine?: string }
8
+ | { kind: 'prompt'; prompt: string }
9
+ | { kind: 'bash'; command: string }
10
10
  | { kind: 'job'; command: string };
11
11
 
12
12
  export type Scope = 'system' | 'user';
@@ -72,7 +72,13 @@ export function extractCommitSubject(raw: string): string {
72
72
  * guard aborts the WHOLE commit, so one churn file stalls every other file's sync indefinitely —
73
73
  * measured 2026-09-07: repeated BLOCKED alerts and a 27-commit push backlog behind two task files. */
74
74
  export function isChurnPath(file: string): boolean {
75
- return /(^|\/)workspace\/[^/]+\/workers\/(logs|tasks)\//.test(file) || /\.bak(-|\.|$)/.test(file);
75
+ return /(^|\/)workspace\/[^/]+\/workers\/(logs|tasks)\//.test(file)
76
+ // Per-job runtime records (one .json/.log/.status triple per background job), written on
77
+ // dispatch and garbage-collected once the job is done. 54 live files churning constantly, so
78
+ // their deletion is the design, not a regression — the integrity audit reported dozens of
79
+ // "missing … in reference but not HEAD" lines for a routine GC pass.
80
+ || /(^|\/)jobs\/job-[^/]+\.(json|log|status)$/.test(file)
81
+ || /\.bak(-|\.|$)/.test(file);
76
82
  }
77
83
 
78
84
  export class DataSyncOptions {
@@ -45,22 +45,27 @@ export type ModelResolver = (token: string) => { model: string; engine?: string
45
45
  let modelResolver: ModelResolver | null = null;
46
46
  export function setModelResolver(fn: ModelResolver | null): void { modelResolver = fn; }
47
47
 
48
- /** Alias table first (canonical shorthands win), then the engine registry. */
48
+ /** Alias table first (canonical shorthands win), then the engine registry. Exported because the
49
+ * client's directive editor needs the SAME verdict on "is this bare token a model?" as the parser —
50
+ * a second opinion there would strip a token the server still reads, or keep one it doesn't. */
51
+ export function resolveModelAlias(token: string): { model: string; engine?: string } | null {
52
+ const v = token.trim().toLowerCase();
53
+ if (MODEL_ALIASES[v]) return { model: MODEL_ALIASES[v] };
54
+ return modelResolver?.(v) ?? null;
55
+ }
56
+
49
57
  function resolveModelToken(d: Directives, val: string): boolean {
50
- if (MODEL_ALIASES[val]) { d.model = MODEL_ALIASES[val]; return true; }
51
- const hit = modelResolver?.(val);
52
- if (hit) {
53
- d.model = hit.model;
54
- // An engine-owned model implies its engine but never override an explicit `[engine:x]`.
55
- if (hit.engine && !d.engine) d.engine = hit.engine;
56
- return true;
57
- }
58
- return false;
58
+ const hit = resolveModelAlias(val);
59
+ if (!hit) return false;
60
+ d.model = hit.model;
61
+ // An engine-owned model implies its engine — but never override an explicit `[engine:x]`.
62
+ if (hit.engine && !d.engine) d.engine = hit.engine;
63
+ return true;
59
64
  }
60
65
 
61
66
  /** Does a bracket group look like directives (vs. prompt text that happens to start with `[`)?
62
67
  * Every token must be a known key:value or a known positional, else we leave the group alone. */
63
- function isDirectiveGroup(raw: string): boolean {
68
+ export function isDirectiveGroup(raw: string): boolean {
64
69
  const tokens = raw.split(',').map((t) => t.trim()).filter(Boolean);
65
70
  if (!tokens.length) return false;
66
71
  return tokens.every((t) => {
@@ -71,23 +76,31 @@ function isDirectiveGroup(raw: string): boolean {
71
76
  });
72
77
  }
73
78
 
74
- export function parseDirectives(text: string): ParsedPrompt {
75
- // Consume EVERY consecutive leading [..] group, not just the first. runner.ts prepends
76
- // `[model] ` onto prompts that may already open with `[turns:120]`, so a single-group parse
77
- // silently dropped the second a schedule pinned to opus quietly ran on the config default
78
- // for three days. Groups that don't parse as directives are left as prompt text.
79
+ /** Splits the leading `[..]` directive groups off a prompt — the single answer to "where do the
80
+ * directives end". Consumes EVERY consecutive leading group, not just the first: a prompt may
81
+ * legitimately open with `[engine:x,model:y] [turns:120] …`, and a single-group scan silently
82
+ * dropped the second (a schedule pinned to opus quietly ran on the config default for three days).
83
+ *
84
+ * `strict` requires every group — the first included — to actually look like directives. The parser
85
+ * stays lenient there (long-standing contract: `[unknown] hi` strips the group and warns), but an
86
+ * EDITOR rewriting the directive in place must never swallow a prompt whose body legitimately opens
87
+ * with bracketed prose. */
88
+ export function splitDirectiveGroups(text: string, opts?: { strict?: boolean }): { groups: string[]; body: string } {
79
89
  let rest = text;
80
90
  const groups: string[] = [];
81
91
  for (;;) {
82
92
  const m = rest.match(DIRECTIVE_RE);
83
93
  if (!m) break;
84
94
  const g = m[1].trim();
85
- // The first group is always consumed (long-standing contract: `[unknown] hi` strips and warns).
86
- // Later groups must actually look like directives, so prompt text such as `[WARN] …` survives.
87
- if (groups.length && g && !isDirectiveGroup(g)) break;
95
+ if ((groups.length || opts?.strict) && g && !isDirectiveGroup(g)) break;
88
96
  groups.push(g);
89
97
  rest = m[2];
90
98
  }
99
+ return { groups, body: rest };
100
+ }
101
+
102
+ export function parseDirectives(text: string): ParsedPrompt {
103
+ const { groups, body: rest } = splitDirectiveGroups(text);
91
104
  if (!groups.length) return { prompt: text, directives: {} };
92
105
 
93
106
  const raw = groups.filter(Boolean).join(',');
@@ -5,6 +5,7 @@ export { ClaudeCodeEngine } from './claude-code.ts';
5
5
  import { registerEngine, getEngine, getAvailableEngines, hasEngine } from './registry.ts';
6
6
  import { ClaudeCodeEngine } from './claude-code.ts';
7
7
  import { setModelResolver } from '../directives.ts';
8
+ import { makeModelResolver } from './model-resolver.ts';
8
9
 
9
10
  let _initialized = false;
10
11
 
@@ -20,25 +21,9 @@ export async function initEngines(): Promise<void> {
20
21
  registerEngine(new ClaudeCodeEngine());
21
22
 
22
23
  // Let `[<model>]` name ANY registered engine's model (e.g. `[composer-2.5]`) and imply its engine.
23
- // Resolved lazily on each parse so engines an add-on registers later are covered too.
24
- // `[composer-2.5]` must reach the engine's real id (`cursor/composer-2.5`), so an exact match is
25
- // tried first, then the bare suffix after the provider prefix — and ONLY when it is unambiguous
26
- // across engines, so a name two engines share is never silently routed to whichever came first.
27
- setModelResolver((token) => {
28
- const t = token.toLowerCase();
29
- const hits: { model: string; engine: string }[] = [];
30
- for (const name of getAvailableEngines()) {
31
- for (const m of getEngine(name).getModels()) {
32
- if (!m.value) continue;
33
- const v = m.value.toLowerCase();
34
- if (v === t) return { model: m.value, engine: name };
35
- if (v.slice(v.lastIndexOf('/') + 1) === t) hits.push({ model: m.value, engine: name });
36
- }
37
- }
38
- if (hits.length === 1) return hits[0];
39
- if (hits.length > 1) console.warn(`[directives] Ambiguous model "${token}" (${hits.map((h) => `${h.engine}:${h.model}`).join(', ')}) — use the full id`);
40
- return null;
41
- });
24
+ // Resolved lazily on each parse so engines an add-on registers later are covered too. The rule
25
+ // itself is shared with the client's directive editor see makeModelResolver.
26
+ setModelResolver(makeModelResolver(() => getAvailableEngines().map((name) => ({ name, models: getEngine(name).getModels() }))));
42
27
 
43
28
  console.log(`[engine] Available engines: ${getAvailableEngines().join(', ')}`);
44
29
  }
@@ -0,0 +1,36 @@
1
+ import type { ModelResolver } from '../directives.ts';
2
+
3
+ /** Engine → the model ids it advertises. Exactly what `GET /api/engines` serves, so the client
4
+ * can build the SAME resolver the server parses with. */
5
+ export interface EngineModels {
6
+ name: string;
7
+ models: { value: string }[];
8
+ }
9
+
10
+ /**
11
+ * Lets `[<model>]` name ANY registered engine's model (e.g. `[composer-2.5]`) and imply its engine.
12
+ *
13
+ * `[composer-2.5]` must reach the engine's real id (`cursor/composer-2.5`), so an exact match is
14
+ * tried first, then the bare suffix after the provider prefix — and ONLY when it is unambiguous
15
+ * across engines, so a name two engines share is never silently routed to whichever came first.
16
+ *
17
+ * Pure and dependency-free: the server passes a live view of its registry (so engines an add-on
18
+ * registers later are covered), the client passes what `/api/engines` returned.
19
+ */
20
+ export function makeModelResolver(engines: () => EngineModels[]): ModelResolver {
21
+ return (token) => {
22
+ const t = token.toLowerCase();
23
+ const hits: { model: string; engine: string }[] = [];
24
+ for (const { name, models } of engines()) {
25
+ for (const m of models) {
26
+ if (!m.value) continue;
27
+ const v = m.value.toLowerCase();
28
+ if (v === t) return { model: m.value, engine: name };
29
+ if (v.slice(v.lastIndexOf('/') + 1) === t) hits.push({ model: m.value, engine: name });
30
+ }
31
+ }
32
+ if (hits.length === 1) return hits[0];
33
+ if (hits.length > 1) console.warn(`[directives] Ambiguous model "${token}" (${hits.map((h) => `${h.engine}:${h.model}`).join(', ')}) — use the full id`);
34
+ return null;
35
+ };
36
+ }