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.
- package/dist/client/assets/{index-B--gTLyT.css → index-CJDfTuzn.css} +1 -1
- package/dist/client/assets/{index-C3l1Wz_q.js → index-D2xviJL0.js} +238 -238
- package/dist/client/index.html +2 -2
- package/package.json +1 -1
- package/src/client/components/ConfigPanel.tsx +2 -20
- 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/data-sync.ts +7 -1
- package/src/server/directives.ts +32 -19
- package/src/server/engine/index.ts +4 -19
- package/src/server/engine/model-resolver.ts +36 -0
- 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
package/dist/client/index.html
CHANGED
|
@@ -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-
|
|
17
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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.
|
|
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
|
|
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
|
-
/**
|
|
15
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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 === '
|
|
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 === '
|
|
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
|
|
117
|
-
{
|
|
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
|
-
|
|
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/data-sync.ts
CHANGED
|
@@ -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)
|
|
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 {
|
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(',');
|
|
@@ -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
|
+
}
|