shraga 0.1.94 → 0.1.96

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.
@@ -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';
@@ -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(',');
@@ -207,7 +207,8 @@ export class ClaudeCodeEngine implements AgentEngine {
207
207
  getModels(): EngineModel[] {
208
208
  return [
209
209
  { value: '', label: `Default (${DEFAULT_MODEL})` },
210
- { value: 'claude-fable-5', label: 'Fable 5 — frontier, most capable' },
210
+ { value: 'claude-fable-5-1', label: 'Fable 5.1 — frontier, most capable' },
211
+ { value: 'claude-fable-5', label: 'Fable 5' },
211
212
  { value: 'claude-opus-5', label: 'Opus 5 — most capable' },
212
213
  { value: 'claude-opus-4-8', label: 'Opus 4.8' },
213
214
  { value: 'claude-opus-4-7', label: 'Opus 4.7' },
@@ -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
+ }
@@ -8,7 +8,8 @@
8
8
 
9
9
  /** Canonical short aliases → concrete Anthropic model ids. */
10
10
  export const MODEL_ALIASES: Record<string, string> = {
11
- fable: 'claude-fable-5',
11
+ fable: 'claude-fable-5-1',
12
+ 'fable-5-1': 'claude-fable-5-1',
12
13
  'fable-5': 'claude-fable-5',
13
14
  opus: 'claude-opus-5',
14
15
  'opus-5': 'claude-opus-5',
@@ -11,6 +11,7 @@
11
11
  * Only state.json answers "is the module on".
12
12
  * - Reconcile is idempotent: write-if-changed skills, upserts preserve enabled/runCount/lastRun.
13
13
  */
14
+ import { foldLegacyRuntimePin } from '../scheduler/builtins.ts';
14
15
  import { mkdirSync, readdirSync, readFileSync, writeFileSync, existsSync, unlinkSync, renameSync, rmSync, copyFileSync, statSync, appendFileSync } from 'node:fs';
15
16
  import path from 'node:path';
16
17
  import { dataPath, DATA_DIR, PACKAGE_ROOT } from '../paths.ts';
@@ -305,6 +306,9 @@ function applyModule(rec: InstalledModule): void {
305
306
  const t = rendered.task as Record<string, unknown>;
306
307
  if (k in t && !t[k]) delete t[k];
307
308
  }
309
+ // A runtime knob is now expressed as the prompt's own `[engine:…,model:…]` directive, not as a
310
+ // shadow field — fold whatever the manifest templated into it.
311
+ foldLegacyRuntimePin(rendered.task as Record<string, any>);
308
312
  const existing = scheduler.getSchedule(id);
309
313
  const now = Date.now();
310
314
  const schedule: Schedule = {
@@ -56,6 +56,30 @@ const FAILURE_NOTIFIER_PROMPT = [
56
56
  const LEGACY_SESSION_LINE = /^.*<deployment URL>\/\?session=<sessionId>(.*)$/gm;
57
57
  const SESSION_LINE_FIX = " *Session:* <the payload's sessionUrl, verbatim — omit this line if absent>";
58
58
 
59
+ /**
60
+ * One source of truth for a prompt run's runtime: the leading `[engine:…,model:…]` directive of the
61
+ * prompt. `task.model`/`task.engine` were an INVISIBLE second copy — no UI ever showed them, which
62
+ * is how nine live schedules drifted to a half-pin (`model` set, `engine` not) that nobody could
63
+ * see or fix. Fold such a pin into the prompt exactly as runner.ts used to synthesize it, so the
64
+ * run is byte-identical for a `prompt` task and the selection is now editable where the prompt is.
65
+ * NOT byte-identical for a `promptFile` task: runner.ts treated `task.prompt` as dead text once
66
+ * `promptFile` was set, and it now survives as the directive-carrying prefix. No live schedule uses
67
+ * `promptFile`, and dropping the prefix instead would silently lose the pin these lines exist to
68
+ * preserve — so the prefix is the deliberate choice, not an oversight.
69
+ *
70
+ * Applied to schedules loaded from disk AND to a module's freshly rendered task, so a manifest that
71
+ * still templates a `model` knob keeps working instead of silently losing its pin.
72
+ * `bash`/`job` tasks have no prompt to hold a directive, so their (unreadable) pin is just dropped.
73
+ */
74
+ export function foldLegacyRuntimePin(task: Record<string, any>): void {
75
+ if (task.kind === 'prompt' && (task.model || task.engine)) {
76
+ const pins = [task.engine && `engine:${task.engine}`, task.model && `model:${task.model}`].filter(Boolean);
77
+ task.prompt = `[${pins.join(',')}] ${task.prompt ?? ''}`;
78
+ }
79
+ delete task.model;
80
+ delete task.engine;
81
+ }
82
+
59
83
  export function isSystemSchedule(schedule: Schedule): boolean {
60
84
  return schedule.scope === 'system';
61
85
  }
@@ -86,6 +110,16 @@ export function backfillScope(schedules: Schedule[]): void {
86
110
  if (task.kind === 'job' && task.command === 'bun run summarize:conversations') {
87
111
  task.command = SUMMARIZER_CMD;
88
112
  }
113
+ // Undo the `engine: 'claude-code'` pin reconcile stamped onto the failure notifier. It shipped on
114
+ // a premise the box's own logs disprove — nothing was ever misrouted; the deployment was simply
115
+ // globally on claude-code then and on agentx now — and it made the alarm the second casualty of
116
+ // the real failure mode (an org cap on the pinned provider). Narrow on purpose: exactly the
117
+ // state that clause produced (this id, that engine, no model), so it cannot eat a real choice.
118
+ if (s.id === FAILURE_NOTIFIER_SCHEDULE_ID && task.engine === 'claude-code' && !task.model) {
119
+ delete task.engine;
120
+ }
121
+ foldLegacyRuntimePin(task);
122
+
89
123
  // Heal a persisted failure-notifier prompt still carrying the un-supplied "<deployment URL>"
90
124
  // placeholder — reconcile won't touch task.prompt, so this is the only path that reaches it.
91
125
  if (s.id === FAILURE_NOTIFIER_SCHEDULE_ID && typeof task.prompt === 'string') {
@@ -138,11 +172,7 @@ export function ensureBuiltinSchedules(schedules: Schedule[]): Schedule[] {
138
172
  match: { status: 'error' },
139
173
  throttle: { byFields: ['name', 'error'], windowSec: 21600 },
140
174
  },
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' },
175
+ task: { kind: 'prompt', prompt: FAILURE_NOTIFIER_PROMPT },
146
176
  scope: 'system',
147
177
  createdBy: { uid: SYSTEM_UID, email: 'system@shraga.local' },
148
178
  createdAt: now,
@@ -167,16 +197,8 @@ export function ensureBuiltinSchedules(schedules: Schedule[]): Schedule[] {
167
197
  existing.scope = builtin.scope;
168
198
  existing.trigger = existing.trigger ?? builtin.trigger;
169
199
  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
- }
200
+ // A builtin's stored task is otherwise preserved — deployments edit the prompt (and with it
201
+ // the runtime directive), and those edits must survive an upgrade.
180
202
  } else {
181
203
  schedules.push(builtin);
182
204
  }
@@ -9,6 +9,17 @@ import type { Schedule, ScheduleRunSummary } from './types.ts';
9
9
  import { updateRunLockPid, clearRunningMarker, loadSchedules } from './storage.ts';
10
10
  import { readOutcome, clearOutcome, pendingDeadline, outcomePrompt, MAX_PENDING_MS } from './outcome.ts';
11
11
  import { addUnread } from '../unread.ts';
12
+ import { splitDirectiveGroups } from '../directives.ts';
13
+
14
+ /** The prompt a `prompt` task runs, from wherever it is stored. Its leading `[…]` directive is the
15
+ * task's runtime selection — there is no separate stored pin.
16
+ * With a `promptFile`, `prompt` is a PREFIX rather than dead text: the directive has to live
17
+ * somewhere the editor can rewrite, and that must not be someone's workspace file. */
18
+ function taskPromptText(task: { prompt?: string; promptFile?: string }): string {
19
+ if (!task.promptFile) return task.prompt ?? '';
20
+ const body = readFileSync(resolvePromptFile(task.promptFile), 'utf-8').trim();
21
+ return task.prompt ? `${task.prompt.trim()} ${body}` : body;
22
+ }
12
23
 
13
24
  export interface RunContext {
14
25
  sessionId: string;
@@ -194,13 +205,19 @@ export async function runSchedule(
194
205
 
195
206
  let prompt: string;
196
207
  if (resume) {
197
- // The original task prompt is already in the conversation from the interrupted run.
208
+ // The original task prompt is already in the conversation from the interrupted run — but a
209
+ // resume prompt is a fresh "continue where you left off" string, not the saved original, so the
210
+ // task's own leading `[engine:…,model:…]` directive has to be carried onto it. Without that the
211
+ // resumed turn runs on the global default: a different engine, and a different vendor's bill.
198
212
  prompt = resume.prompt;
213
+ const groups = task.kind === 'prompt' ? splitDirectiveGroups(taskPromptText(task), { strict: true }).groups : [];
214
+ const raw = groups.filter(Boolean).join(',');
215
+ if (raw) prompt = `[${raw}] ${prompt}`;
199
216
  } else if (task.kind === 'bash') {
200
217
  const cmd = override || task.command;
201
218
  prompt = `Run exactly this bash command and report the result concisely:\n\n\`\`\`bash\n${cmd}\n\`\`\``;
202
219
  } else {
203
- let base = task.promptFile ? readFileSync(resolvePromptFile(task.promptFile), 'utf-8').trim() : (task.prompt ?? '');
220
+ let base = taskPromptText(task);
204
221
  if (override) base = `${base}\n\n---\nAdditional instructions for this run:\n${override}`;
205
222
  if (eventCtx) base = `${base}\n\n---\n${formatEventBlock(eventCtx)}`;
206
223
  prompt = base;
@@ -216,15 +233,6 @@ export async function runSchedule(
216
233
  prompt = `${prompt}\n\n---\n${outcomePrompt(sessionId)}`;
217
234
  }
218
235
 
219
- // task.engine/task.model ride the same prompt-directive channel users type by hand —
220
- // parseDirectives strips them and resolves aliases. Prepending (vs new plumbing) also persists the
221
- // choice into the saved prompt, so the session UI shows what the schedule actually requested.
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}`;
227
-
228
236
  // Save the synthesized user prompt to the conversation (skip on resume — task prompt already persisted).
229
237
  if (!resume) {
230
238
  appendMessage(sessionId, {
@@ -19,11 +19,16 @@ export interface EventThrottle {
19
19
  windowSec: number;
20
20
  }
21
21
 
22
- /** `engine`/`model` pin the runtime a run uses (e.g. engine 'agentx' + model
23
- * 'cursor/composer-2.5'); both ride the prompt-directive channel — see runner.ts. */
22
+ /** The runtime a prompt run uses is NOT a field here: it is the leading `[engine:…,model:…]`
23
+ * directive of the prompt itself (or of the promptFile's contents) — one source of truth, editable
24
+ * and visible in the same place the prompt is. A separate stored pin shadowed it invisibly, which
25
+ * is how nine live schedules ended up half-pinned (`model` set, `engine` not) with no UI to see it.
26
+ * `bash` and `job` carry no selector: `bash` has no prompt to hold a directive (its prompt is a
27
+ * fixed synthesized wrapper) and `job` never reaches an agent at all — both follow agent config.
28
+ * Pin a shell command's runtime by writing a prompt task that runs it. */
24
29
  export type Task =
25
- | { kind: 'prompt'; prompt?: string; promptFile?: string; model?: string; engine?: string }
26
- | { kind: 'bash'; command: string; model?: string; engine?: string }
30
+ | { kind: 'prompt'; prompt?: string; promptFile?: string }
31
+ | { kind: 'bash'; command: string }
27
32
  | { kind: 'job'; command: string };
28
33
 
29
34
  /** Visibility: 'system' schedules + their sessions are shared with all whitelisted users; 'user' is private to createdBy. */