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.
- package/defaults/skills/platform.md +1 -1
- package/dist/client/assets/{index-C3l1Wz_q.js → index-519evGGj.js} +238 -238
- package/dist/client/assets/{index-B--gTLyT.css → index-CJDfTuzn.css} +1 -1
- package/dist/client/index.html +2 -2
- package/package.json +1 -1
- package/src/client/components/ConfigPanel.tsx +4 -21
- package/src/client/components/ConversationHeader.tsx +62 -27
- package/src/client/components/SchedulesManager.tsx +2 -0
- package/src/client/components/schedules/ScheduleEditor.tsx +65 -1
- package/src/client/hooks/useEngines.ts +52 -0
- package/src/client/lib/prompt-directives.ts +65 -0
- package/src/client/lib/schedule-types.ts +2 -2
- package/src/server/directives.ts +32 -19
- package/src/server/engine/claude-code.ts +2 -1
- package/src/server/engine/index.ts +4 -19
- package/src/server/engine/model-resolver.ts +36 -0
- package/src/server/model-aliases.ts +2 -1
- package/src/server/modules/service.ts +4 -0
- package/src/server/scheduler/builtins.ts +37 -15
- package/src/server/scheduler/runner.ts +19 -11
- package/src/server/scheduler/types.ts +9 -4
|
@@ -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
|
-
|
|
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
|
|
9
|
-
| { kind: 'bash'; command: 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';
|
package/src/server/directives.ts
CHANGED
|
@@ -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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
25
|
-
|
|
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
|
-
|
|
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
|
|
171
|
-
//
|
|
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 =
|
|
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
|
-
/**
|
|
23
|
-
*
|
|
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
|
|
26
|
-
| { kind: 'bash'; command: 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. */
|