shraga 0.1.87 → 0.1.89

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,7 +13,7 @@
13
13
  <link rel="preconnect" href="https://fonts.googleapis.com" />
14
14
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
15
15
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet" />
16
- <script type="module" crossorigin src="/assets/index-BSvY0squ.js"></script>
16
+ <script type="module" crossorigin src="/assets/index-B-8NBGtJ.js"></script>
17
17
  <link rel="stylesheet" crossorigin href="/assets/index-J2NH6FvE.css">
18
18
  </head>
19
19
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shraga",
3
- "version": "0.1.87",
3
+ "version": "0.1.89",
4
4
  "description": "The teammate you delegate coding to — a self-hostable, multi-user AI coding agent web UI (Claude Code, with a pluggable engine seam).",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
package/src/cli.ts CHANGED
@@ -29,19 +29,23 @@ Subcommands:
29
29
  ingress Run the host-header TCP router (INGRESS_PORT, default 3100)
30
30
  for previews + blue-green flips. Own process, survives restarts.
31
31
  user add <email> <pw> Seed a local username/password user
32
- para post [text] Push a message into a linked para.li conversation.
33
- Text from [text], --file <path>, or stdin (preferred for
34
- multi-line reports). --conn <id> picks a link when several exist.
32
+ <add-on> Any other subcommand is handed to SHRAGA_CLI_EXT, if set.
35
33
 
36
34
  Environment:
37
35
  CLOUDFLARE_TUNNEL_TOKEN If set, starts a Cloudflare Tunnel alongside the server.
38
36
  Get the token from Cloudflare Zero Trust > Tunnels > Configure.
39
37
  ANTHROPIC_API_KEY Claude API key (or use \`claude auth login\` for subscription auth)
40
38
  VITE_FIREBASE_CONFIG_PROD Firebase config JSON for auth (prod project)
39
+ SHRAGA_CLI_EXT Absolute path to an add-on CLI module. Any subcommand this CLI
40
+ does not own is handed to it before the server boots. See the
41
+ CLI EXTENSION SEAM comment in src/cli.ts.
41
42
  `.trim());
42
43
  process.exit(0);
43
44
  }
44
45
 
46
+ /** Subcommands the core owns. Everything else is offered to SHRAGA_CLI_EXT (see below). */
47
+ const CORE_SUBCOMMANDS = new Set(['user', 'ingress']);
48
+
45
49
  const port = flag('port', 'p');
46
50
  const dataDir = flag('data-dir', 'd');
47
51
 
@@ -62,58 +66,31 @@ if (args[0] === 'user' && args[1] === 'add') {
62
66
  process.exit(0);
63
67
  }
64
68
 
65
- // `shraga para post [text]` — the PROACTIVE half of the para.li agent lane.
69
+ // ── CLI EXTENSION SEAM ───────────────────────────────────────────────────────
66
70
  //
67
- // The reactive half (para asks, shraga answers) is driven by para.li calling `/api/para/turn`, and
68
- // the only other outbound path is the deploy-notice bus subscriber in `para/feature.ts` which is
69
- // gated on `kind === 'deploy'`. So a SCHEDULED run (a daily digest) had no way to reach the lane at
70
- // all: it composes text on its own clock with no inbound turn to answer and no deploy event to ride.
71
- // This is that door, and it is a CLI rather than a route because the caller is the agent itself,
72
- // running Bash on this very box: a local process reading the same `para-links.json` the feature
73
- // writes needs no listener, no API key, and no second copy of the callback secret.
71
+ // The twin of the `registerFeature` / SHRAGA_OVERLAY seam, for the command line. The core owns a
72
+ // short, fixed list of subcommands; ANY other one is handed to the module named by SHRAGA_CLI_EXT,
73
+ // so a downstream distribution can ship its own commands without the core naming them.
74
74
  //
75
- // Text comes from stdin by default. A digest is multi-line markdown with backticks and emoji, and
76
- // making a model shell-quote that into argv is a defect generator; `... | shraga para post` is not.
77
- if (args[0] === 'para' && args[1] === 'post') {
78
- const { loadLinks } = await import('./server/para/feature.ts');
79
- const { postProactive } = await import('./server/para/streamer.ts');
80
-
81
- const links = loadLinks();
82
- const wanted = flag('conn');
83
- const ids = Object.keys(links);
84
- // Refuse to guess. Picking "the first" would silently deliver a private report to whichever
85
- // connection happened to sort first the day a second one is added.
86
- const connId = wanted ?? (ids.length === 1 ? ids[0] : undefined);
87
- if (!connId || !links[connId]) {
88
- console.error(ids.length
89
- ? `usage: shraga para post --conn <connId> (linked: ${ids.join(', ')})`
90
- : 'no para link yet — send one message from para.li to this agent first, then retry.');
91
- process.exit(1);
92
- }
93
- const link = links[connId];
94
-
95
- const file = flag('file');
96
- let text: string;
97
- if (file) {
98
- text = (await import('node:fs')).readFileSync(file, 'utf-8');
99
- } else {
100
- // A bare positional (not a flag, and not a flag's VALUE) is accepted for one-liners.
101
- const flagVals = new Set<string>();
102
- for (let i = 0; i < args.length; i++) if (args[i].startsWith('--')) flagVals.add(args[i + 1]);
103
- const positional = args.slice(2).find((a) => !a.startsWith('--') && !flagVals.has(a));
104
- text = positional ?? await new Response(Bun.stdin.stream()).text();
105
- }
106
- if (!text.trim()) {
107
- console.error('nothing to post: text was empty (pipe it on stdin, pass --file, or give it as an argument)');
75
+ // CONTRACT. The module is imported for side effects, with `process.argv` untouched it reads argv
76
+ // itself, and calls `process.exit()` when it has handled the command. If it returns without
77
+ // exiting, we fall through to the normal server boot, which is exactly what an unrecognised
78
+ // subcommand does today. It runs AFTER env resolution (so DATA_DIR/PORT are settled and the
79
+ // extension sees the same data dir the server would) and BEFORE any server boot.
80
+ //
81
+ // WHY AN ENV VAR AND NOT A REGISTRY. There is no server process to register against — this is a
82
+ // one-shot CLI. The path must come from the deployment, and the deployment already sets DATA_DIR
83
+ // and friends the same way.
84
+ const cliExt = process.env.SHRAGA_CLI_EXT?.trim();
85
+ if (cliExt && args[0] && !args[0].startsWith('-') && !CORE_SUBCOMMANDS.has(args[0])) {
86
+ const { pathToFileURL } = await import('node:url');
87
+ const { resolve } = await import('node:path');
88
+ try {
89
+ await import(pathToFileURL(resolve(cliExt)).href);
90
+ } catch (err) {
91
+ console.error(`[cli-ext] failed to load ${cliExt}:`, (err as Error)?.stack || err);
108
92
  process.exit(1);
109
93
  }
110
-
111
- const ok = await postProactive({ url: link.url, secret: link.secret, connId }, link.convId, text);
112
- // Exit code is the point: `postProactive` swallows transport failures into `false`, so a caller
113
- // that only looked at stdout would read a silent drop as a successful delivery.
114
- if (!ok) { console.error(`\u2716 para post FAILED \u2192 ${link.convId}`); process.exit(1); }
115
- console.log(`\u2705 posted to ${link.convId}`);
116
- process.exit(0);
117
94
  }
118
95
 
119
96
  // `shraga ingress` — host-header TCP router for previews + blue-green flips.
@@ -11,11 +11,45 @@ interface SessionDirectives {
11
11
  engine?: string;
12
12
  }
13
13
 
14
+ /** What the chips must report: the engine/model that ACTUALLY ran, and whether that disagrees with
15
+ * what this session asks for. Pure, so the rule is testable without a DOM.
16
+ *
17
+ * The bug this replaces: the old code inferred the engine from the model id's SHAPE (bare ⇒ native)
18
+ * and threw away the runtime-recorded model whenever the shape disagreed with the requested engine —
19
+ * i.e. precisely in the case where a run had silently switched provider. The header was structurally
20
+ * incapable of reporting a fallback, so a run that billed Anthropic still showed the Cursor chips.
21
+ * Runtime ground truth now arrives as a self-consistent (engine, model) PAIR, so nothing is inferred. */
22
+ export function deriveRuntimeBadges(input: {
23
+ requestedEngine?: string;
24
+ requestedModel?: string;
25
+ /** Engine that ran the last turn (session meta `lastEngine`). */
26
+ actualEngine?: string;
27
+ /** Model that engine resolved (session meta `lastModel`). */
28
+ actualModel?: string;
29
+ }) {
30
+ const requestedEngine = input.requestedEngine || 'claude-code';
31
+ const engine = input.actualEngine || requestedEngine;
32
+ // A mismatch is a fact worth showing, not something to launder away: the last turn ran somewhere
33
+ // other than where this session currently asks to run.
34
+ const engineMismatch = input.actualEngine && input.actualEngine !== requestedEngine ? requestedEngine : undefined;
35
+ const engineIsNative = engine === 'claude-code' || engine === 'cursor';
36
+ // Only trust the recorded model when we also know which engine recorded it — the pair, or neither.
37
+ const rawModel =
38
+ (input.actualEngine ? input.actualModel : undefined) ||
39
+ input.requestedModel ||
40
+ (engine === 'cursor' ? 'cursor/composer-2.5' : 'sonnet-4-6');
41
+ // Provider = the model's prefix; a bare id belongs to the engine that ran it (claude-code ⇒ anthropic,
42
+ // an add-on engine ⇒ that engine's own provider) — never assume anthropic just because a prefix is absent.
43
+ const billingProvider = rawModel.includes('/') ? rawModel.split('/')[0] : engine === 'claude-code' ? 'anthropic' : engine;
44
+ return { engine, engineIsNative, engineMismatch, rawModel, billingProvider };
45
+ }
46
+
14
47
  function InfoBadges({
15
48
  sessionId,
16
49
  config,
17
50
  sessionDirectives,
18
51
  actualModel,
52
+ actualEngine,
19
53
  scheduleId,
20
54
  onScheduleClick,
21
55
  }: {
@@ -24,19 +58,19 @@ function InfoBadges({
24
58
  sessionDirectives?: SessionDirectives;
25
59
  /** Model the engine actually resolved at runtime (session meta `lastModel`) — beats configured/requested. */
26
60
  actualModel?: string;
61
+ /** Engine that actually ran the last turn (session meta `lastEngine`). Ground truth, arriving in the
62
+ * same `model_resolved` event as `actualModel` — so the pair never has to be inferred from a shape. */
63
+ actualEngine?: string;
27
64
  scheduleId?: string;
28
65
  onScheduleClick?: () => void;
29
66
  }) {
30
67
  const [copied, setCopied] = useState(false);
31
- const engine = sessionDirectives?.engine || config.engine || 'claude-code';
32
- // The native Claude Code engine records a bare `claude-*` id; a multi-provider add-on engine records
33
- // a `provider/model` id. Trust the recorded model only when its shape matches the current engine
34
- // family, or a session that has since switched engines would show the previous engine's model.
35
- const recordedByNative = actualModel ? !actualModel.includes('/') : undefined;
36
- const engineIsNative = engine === 'claude-code' || engine === 'cursor';
37
- const trustedActual = recordedByNative !== undefined && recordedByNative === engineIsNative ? actualModel : undefined;
38
- const rawModel =
39
- trustedActual || sessionDirectives?.model || config.model || (engine === 'cursor' ? 'cursor/composer-2.5' : 'sonnet-4-6');
68
+ const { engine, engineIsNative, engineMismatch, rawModel, billingProvider } = deriveRuntimeBadges({
69
+ requestedEngine: sessionDirectives?.engine || config.engine,
70
+ requestedModel: sessionDirectives?.model || config.model,
71
+ actualEngine,
72
+ actualModel,
73
+ });
40
74
  // A multi-provider add-on engine runs any provider's model through its own loop, so it must be
41
75
  // distinguishable from a native runtime running the same model. Prefix such a model with the engine
42
76
  // name; native engines (claude-code, cursor) show the model plainly. Engine name comes from data.
@@ -46,8 +80,6 @@ function InfoBadges({
46
80
  // engine / provider-prefixed model runs on that provider's key (ai.libx.js adapters throw without
47
81
  // one). What that key COSTS is plan-dependent and NOT knowable here (Anthropic API is metered;
48
82
  // a Cursor key may draw on a Cursor subscription) — so we label the mechanism, not the billing.
49
- // Provider = the model's prefix (bare ⇒ anthropic).
50
- const billingProvider = rawModel.includes('/') ? rawModel.split('/')[0] : 'anthropic';
51
83
  const onSubscription = engine === 'claude-code' && config.claudeAuthSource === 'subscription';
52
84
  // Tone: green = claude.ai login (no key); amber = provider key whose usage may be subscription-
53
85
  // covered (Cursor); rose = provider key that is genuinely metered (Anthropic/OpenAI/etc.).
@@ -87,6 +119,14 @@ function InfoBadges({
87
119
  >
88
120
  {onSubscription ? 'sub' : `API·${billingProvider}`}
89
121
  </span>
122
+ {engineMismatch && (
123
+ <span
124
+ title={`This session requests the "${engineMismatch}" engine, but the last turn actually ran on "${engine}" — so the chips above report ${engine}, and that provider was billed.`}
125
+ className="inline-flex items-center rounded-md bg-rose-50 px-1.5 py-0.5 text-[10px] font-medium text-rose-700 ring-1 ring-inset ring-rose-600/20 dark:bg-rose-950/50 dark:text-rose-300 dark:ring-rose-400/30"
126
+ >
127
+ ≠ {engineMismatch}
128
+ </span>
129
+ )}
90
130
  <span className="inline-flex items-center rounded-md bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium text-amber-700 ring-1 ring-inset ring-amber-600/20 dark:bg-amber-950/50 dark:text-amber-300 dark:ring-amber-400/30">
91
131
  {permLabel}
92
132
  </span>
@@ -127,6 +167,7 @@ export interface ConversationHeaderProps {
127
167
  agentConfig: AgentConfig;
128
168
  sessionDirectives?: SessionDirectives;
129
169
  sessionLastModel?: string;
170
+ sessionLastEngine?: string;
130
171
  sessionScheduleId?: string;
131
172
  artifactCount: number;
132
173
  getToken: () => Promise<string | null>;
@@ -143,6 +184,7 @@ export function ConversationHeader({
143
184
  agentConfig,
144
185
  sessionDirectives,
145
186
  sessionLastModel,
187
+ sessionLastEngine,
146
188
  sessionScheduleId,
147
189
  artifactCount,
148
190
  getToken,
@@ -159,6 +201,7 @@ export function ConversationHeader({
159
201
  config={agentConfig}
160
202
  sessionDirectives={sessionDirectives}
161
203
  actualModel={sessionLastModel}
204
+ actualEngine={sessionLastEngine}
162
205
  scheduleId={sessionScheduleId}
163
206
  onScheduleClick={onScheduleClick}
164
207
  />
@@ -43,6 +43,9 @@ export function ConversationPane({ nodeId, sessionId }: { nodeId: string; sessio
43
43
  const [sessionDirectives, setSessionDirectives] = useState<SessionDirectives | undefined>();
44
44
  const [sessionScheduleId, setSessionScheduleId] = useState<string | undefined>();
45
45
  const [sessionLastModel, setSessionLastModel] = useState<string | undefined>();
46
+ // Engine that ACTUALLY ran the last turn — always set/cleared together with sessionLastModel, since
47
+ // the pair is the runtime ground truth the header reports (neither half may be inferred).
48
+ const [sessionLastEngine, setSessionLastEngine] = useState<string | undefined>();
46
49
  const [multiParticipant, setMultiParticipant] = useState(false);
47
50
 
48
51
  const artifacts = useArtifacts(currentSessionId, ws.getToken, ws.token);
@@ -66,9 +69,10 @@ export function ConversationPane({ nodeId, sessionId }: { nodeId: string; sessio
66
69
  } else if (event.type === 'directives') {
67
70
  // Optimistic-then-confirm pill: drop stale ground-truth so the pill shows the just-requested
68
71
  // model. Untagged event — gate on this pane being mid-turn so idle panes don't flicker.
69
- if (busyRef.current) setSessionLastModel(undefined);
72
+ if (busyRef.current) { setSessionLastModel(undefined); setSessionLastEngine(undefined); }
70
73
  } else if (event.type === 'model_resolved' && event.sessionId === currentSessionIdRef.current) {
71
74
  setSessionLastModel(event.model);
75
+ setSessionLastEngine(event.engine);
72
76
  }
73
77
  },
74
78
  [ws, nodeId, artifacts],
@@ -113,6 +117,7 @@ export function ConversationPane({ nodeId, sessionId }: { nodeId: string; sessio
113
117
  setSessionDirectives(undefined);
114
118
  setSessionScheduleId(undefined);
115
119
  setSessionLastModel(undefined);
120
+ setSessionLastEngine(undefined);
116
121
  return;
117
122
  }
118
123
  apiFetch(`/api/sessions/${currentSessionId}/meta`, ws.getToken)
@@ -121,6 +126,7 @@ export function ConversationPane({ nodeId, sessionId }: { nodeId: string; sessio
121
126
  setSessionDirectives(meta.directives);
122
127
  setSessionScheduleId(meta.scheduleId);
123
128
  setSessionLastModel(meta.lastModel);
129
+ setSessionLastEngine(meta.lastEngine);
124
130
  if (meta.runStatus === 'running') conv.setBusy(true);
125
131
  })
126
132
  .catch(() => {});
@@ -221,6 +227,7 @@ export function ConversationPane({ nodeId, sessionId }: { nodeId: string; sessio
221
227
  agentConfig={ws.agentConfig}
222
228
  sessionDirectives={sessionDirectives}
223
229
  sessionLastModel={sessionLastModel}
230
+ sessionLastEngine={sessionLastEngine}
224
231
  sessionScheduleId={sessionScheduleId}
225
232
  artifactCount={artifacts.artifacts.length}
226
233
  getToken={ws.getToken}
@@ -26,7 +26,7 @@ export type ServerEvent =
26
26
  | { type: 'session_id'; sessionId: string }
27
27
  | { type: 'forked'; sourceSessionId: string; sessionId: string }
28
28
  | { type: 'done'; sessionId: string; stopReason?: 'end_turn' | 'max_turns_reached' | (string & {}) }
29
- | { type: 'model_resolved'; sessionId: string; model: string }
29
+ | { type: 'model_resolved'; sessionId: string; model: string; engine: string }
30
30
  | { type: 'error'; message: string; sessionId?: string }
31
31
  | { type: 'workspace_change'; action: 'created' | 'modified' | 'deleted'; path: string }
32
32
  | { type: 'disconnected' }
@@ -25,7 +25,6 @@ import { streamChat, consumeStream, getAgentConfig, saveAgentConfig, getClaudeAu
25
25
  import { mountFeatures, registerFeature, resumeFeatureSession, collectFeatureFlags, collectSidecarRoutes } from './features.ts';
26
26
  import { registerSpaCatchAll } from './spa-catchall.ts';
27
27
  import { slackFeature } from './slack/feature.ts';
28
- import { paraFeature } from './para/feature.ts';
29
28
  import { dataPath } from './paths.ts';
30
29
  import { getAllSessions, getSession, getSessionHistory, upsertSession, appendMessage, saveConversation, loadConversation, setSessionDirectives, getAutoApprove, setAutoApprove, getSessionsByScheduleId, getSessionsVisibleTo, isSessionVisibleTo, setRunStatus, incrementRetryCount, getRunningSessions, getActiveLockCount, updateScheduledSessionStatus, setShuttingDown, backfillSessionVisibility, writePartial, readPartial, clearPartial, registerLivePartial, unregisterLivePartial, readLivePartial, acquireSessionLock, releaseSessionLock, replaceSessionLock, isSessionLocked, getSessionAbortController, forkSession, generateSessionTitle, type ConvBlock, type ConvMessage, type SessionMeta } from './sessions.ts';
31
30
  import { setBroadcaster } from './session-bus.ts';
@@ -113,7 +112,15 @@ const PASSIVE_FLAG = process.env.SHRAGA_PASSIVE ?? process.env.UNCLAW_PASSIVE;
113
112
  const PASSIVE = PASSIVE_FLAG === '1' || PASSIVE_FLAG === 'true';
114
113
  if (PASSIVE) console.log('[server] PASSIVE mode — schedulers, consumers and background writers disabled');
115
114
 
115
+ // Local-only prep (git config, gitignore, untracking). The NETWORK half — fetch/merge — is
116
+ // deliberately NOT awaited here; it runs via dataSync.syncOnBoot() AFTER listen(). See syncOnBoot().
116
117
  if (!PASSIVE) await dataSync.init();
118
+
119
+ // Kick the boot-time data sync detached, AFTER the port is bound. syncOnBoot() never rejects, and the
120
+ // extra .catch() is belt-and-braces: an unhandled rejection here would hit the process-level handler.
121
+ function bootDataSync(): void {
122
+ dataSync.syncOnBoot().catch(err => console.error('[data-sync] boot sync error:', (err as Error).message));
123
+ }
117
124
  await loadShragaConfig();
118
125
  // Programmatic engines register through the same seam an overlay uses — BEFORE initEngines() so
119
126
  // getAvailableEngines() includes them and a directive can resolve to one immediately.
@@ -993,8 +1000,8 @@ function proxySidecarWebSocket(req: import('node:http').IncomingMessage, socket:
993
1000
  // proxy-local reply only proves THIS hop is alive: if the proxy→sidecar leg is half-open, or the
994
1001
  // sidecar has already dropped this client from its subscriber set, the browser still gets pongs,
995
1002
  // keeps `readyState === OPEN`, shows a green "connected" dot, and every keystroke disappears.
996
- // The probe is only worth anything end-to-end, so the sidecar owns the reply (para-pty answers in
997
- // its ws message handler); a sidecar that doesn't reply fails the probe, which is the honest
1003
+ // The probe is only worth anything end-to-end, so the sidecar owns the reply (it answers in its
1004
+ // own ws message handler); a sidecar that doesn't reply fails the probe, which is the honest
998
1005
  // outcome — the client then reconnects rather than trusting a dead pipe.
999
1006
  if (targetWs.readyState === WebSocket.OPEN) targetWs.send(data, { binary: isBinary });
1000
1007
  else if (!opened && pending.length < 256) pending.push({ data, isBinary }); // bounded: never buffer unboundedly
@@ -1058,7 +1065,7 @@ server.on('upgrade', (req, socket, head) => {
1058
1065
  } else {
1059
1066
  const port = req.url ? resolveSidecarPort(req.url) : null;
1060
1067
  if (port) {
1061
- // A sidecar socket is a LIVE ATTACHED SHELL (para-pty) — read/write on the user's terminals. It
1068
+ // A sidecar socket is a LIVE ATTACHED SHELL (a terminal daemon) — read/write on the user's terminals. It
1062
1069
  // must be authenticated BEFORE we bridge it, like the HTTP routes (`requireAuth`) and the `/ws`
1063
1070
  // control socket (its `auth` message). NOTE the limit of this gate: it is IDENTITY-ONLY. It proves
1064
1071
  // the token belongs to a valid user; it does NOT check that the user owns (or may access) the
@@ -1215,9 +1222,6 @@ if (process.env.SHRAGA_OVERLAY) {
1215
1222
  // so their routes mount ahead of the SPA fallback, identical to the overlay path.
1216
1223
  for (const f of __reg.features ?? []) registerFeature(f);
1217
1224
  registerFeature(slackFeature);
1218
- // para-li external-agent lane — a second medium alongside Slack; both subscribe the owner-notice
1219
- // bus independently, so neither affects the other.
1220
- registerFeature(paraFeature);
1221
1225
  mountFeatures({ app, requireAuth, broadcast, passive: PASSIVE });
1222
1226
  // Fold in feature-contributed sidecar WS proxy routes (the core names none; each add-on adds its own).
1223
1227
  Object.assign(WS_PROXY_ROUTES, collectSidecarRoutes());
@@ -1233,6 +1237,7 @@ async function activateConsumers() {
1233
1237
  activated = true;
1234
1238
  console.log('[server] ACTIVATING — starting consumers and background writers');
1235
1239
  await dataSync.init();
1240
+ bootDataSync();
1236
1241
  syncVendorRepos().catch(err => console.warn('[vendor-sync] error:', (err as Error).message));
1237
1242
  recordBootGap();
1238
1243
  startHeartbeat();
@@ -2150,6 +2155,7 @@ await new Promise<void>((resolve) => {
2150
2155
  catch (err) { console.warn('[self-upgrade] could not deliver report:', (err as Error).message); }
2151
2156
  startSidecars().catch(err => console.error('[sidecar] startup error:', err));
2152
2157
  recoverInterruptedSessions().catch(err => console.error('[recovery] failed:', err));
2158
+ bootDataSync();
2153
2159
  // The disk MCP catalog is warmed off the turn path by whichever engine consumes it — the CE default
2154
2160
  // (Claude Code) hands MCP servers straight to its SDK and needs no catalog. An add-on engine that
2155
2161
  // uses the shared catalog registers its own boot/interval warm-up through the overlay.
@@ -81,7 +81,7 @@ export type WsEvent =
81
81
  | { type: 'question_request'; id: string; questions: AskQuestion[] }
82
82
  | { type: 'thinking_delta'; text: string }
83
83
  | { type: 'done'; sessionId: string; stopReason?: 'end_turn' | 'max_turns_reached' | (string & {}); builtinHandled?: boolean }
84
- | { type: 'model_resolved'; sessionId: string; model: string }
84
+ | { type: 'model_resolved'; sessionId: string; model: string; engine: string }
85
85
  | { type: 'error'; message: string }
86
86
  | { type: 'stats'; sample: { t: number; cpu: number; mem: number; load: number; disk: number; diskUsedBytes?: number; diskTotalBytes?: number } };
87
87
  // Add-on engines/features emit their OWN events (e.g. a duplex voice brain's `duplex_*`) through the
@@ -356,8 +356,19 @@ export async function* streamChat(opts: {
356
356
  console.log(`[stream] turn-context injected (${turnContext.length} chars) for session=${sessionId}`);
357
357
  }
358
358
 
359
- // Resolve engine and delegate
360
- const engine = resolveAndGetEngine(directives as any, config);
359
+ // Resolve engine and delegate. An unregistered engine is a HARD stop: rerouting to claude-code
360
+ // would switch provider and billing under the caller while the UI still showed the requested one.
361
+ // Surfaced as a turn `error` event (not a throw) so every transport — WS, Slack, scheduler, MCP,
362
+ // webhook — reports it the same way and a scheduled run is marked failed instead of dying.
363
+ let engine: ReturnType<typeof resolveAndGetEngine>;
364
+ try {
365
+ engine = resolveAndGetEngine(directives as any, config);
366
+ } catch (err) {
367
+ const message = (err as Error).message;
368
+ console.error(`[stream] ${message} (user=${opts.uid} session=${sessionId})`);
369
+ yield { type: 'error', message };
370
+ return;
371
+ }
361
372
  console.log(`[stream] engine=${engine.name} user=${opts.uid} session=${sessionId}`);
362
373
 
363
374
  yield* engine.stream({
@@ -179,8 +179,31 @@ export class DataSync {
179
179
  await this.untrackIgnored();
180
180
  this.ready = true;
181
181
  }
182
+ }
182
183
 
183
- await this.pull();
184
+ /**
185
+ * Boot-time NETWORK sync: the remote fetch/merge plus the deferred conflict-scan + integrity audit.
186
+ * Split out of init() so the HTTP port can bind BEFORE any of this runs.
187
+ *
188
+ * Why: this used to be the tail of init(), which bootServer awaits before listen(). A stalled pull
189
+ * (observed on liv-mac-1: `Could not resolve host: github.com`, plus a 60s LLM commit-message
190
+ * timeout) therefore held the ENTIRE boot — process alive, no listener on any port, 502s for
191
+ * minutes. The port watchdog then SIGTERM/SIGKILLed the boot (LastExitStatus=9) and the next boot
192
+ * re-entered the same stall. Serving possibly-stale data and refreshing a moment later is strictly
193
+ * better than not serving at all.
194
+ *
195
+ * Never rejects: the caller runs this detached, and an unhandled rejection here would reach the
196
+ * process-level handler.
197
+ */
198
+ async syncOnBoot(): Promise<void> {
199
+ if (!this.ready) return; // init() disabled/aborted — nothing to sync against
200
+ console.log(`${TAG} Boot sync started (background — the server is already serving)`);
201
+ try {
202
+ await this.pull();
203
+ console.log(`${TAG} Boot sync complete`);
204
+ } catch (err) {
205
+ console.error(`${TAG} Boot sync FAILED — serving stale data until the next pull:`, (err as Error).message);
206
+ }
184
207
  // Defer heavy sync I/O (reads all tracked files + execSync) to avoid blocking
185
208
  // WS connections and page loads during startup.
186
209
  setTimeout(() => {
@@ -189,7 +212,8 @@ export class DataSync {
189
212
  // execSync inside the audit blocks too — keep it off the scan's tick so the two
190
213
  // never add up into one long freeze.
191
214
  .then(() => new Promise<void>(r => setImmediate(r)))
192
- .then(() => this.runIntegrityAudit());
215
+ .then(() => this.runIntegrityAudit())
216
+ .catch(err => console.warn(`${TAG} Post-init integrity audit failed:`, (err as Error).message));
193
217
  }, 60_000);
194
218
  }
195
219
 
@@ -10,7 +10,7 @@ import { registerProactiveMessage } from '../slack/sessions.ts';
10
10
  import { registerPoll } from '../polls.ts';
11
11
  import { getSession, setSessionModel, getSessionModel, type ConvMessage } from '../sessions.ts';
12
12
  import { DEFAULT_MODEL } from '../directives.ts';
13
- import { resolveModelSwitch } from '../model-aliases.ts';
13
+ import { resolveModelSwitch, MODEL_ALIASES } from '../model-aliases.ts';
14
14
  import type { WsEvent, AskQuestion, QuestionAnswers, QuestionHandler } from '../claude.ts';
15
15
  import type { AgentEngine, EngineStreamOpts, EngineModel } from './types.ts';
16
16
  import { getPromptSuffix } from '../prompt-suffix.ts';
@@ -41,6 +41,16 @@ const DESTRUCTIVE_DATA_PATTERNS = [
41
41
  />\s*data\/(conversations?|sessions?|schedules?)\//i,
42
42
  ];
43
43
 
44
+ /** Is `id` a model this engine can actually run? Anything else — notably a foreign id like
45
+ * `cursor/composer-2.5` or the bare `composer-2.5` a schedule pinned for another engine — used to be
46
+ * forwarded straight to the Anthropic SDK, billing Anthropic for a model the caller never asked it
47
+ * for. Shape-based rather than an exact list so dated ids (`claude-sonnet-4-5-20250929`) still pass.
48
+ * An explicit `anthropic/` prefix is this engine's own provider and is stripped first. */
49
+ function isOwnModel(id: string): boolean {
50
+ const bare = id.startsWith('anthropic/') ? id.slice('anthropic/'.length) : id;
51
+ return bare.startsWith('claude-') || bare.toLowerCase() in MODEL_ALIASES;
52
+ }
53
+
44
54
  type DenyResult = { behavior: 'deny'; message: string };
45
55
 
46
56
  function checkSensitiveAccess(toolName: string, input: Record<string, unknown>): DenyResult | null {
@@ -303,7 +313,20 @@ export class ClaudeCodeEngine implements AgentEngine {
303
313
 
304
314
  // Always pass an explicit model — left unset, the CLI applies its own default
305
315
  // (observed: Opus 4.7), not what the UI's "Default" label promises.
306
- options['model'] = directives.model ?? config.model ?? DEFAULT_MODEL;
316
+ const requestedModel = directives.model || config.model || DEFAULT_MODEL;
317
+ // Refuse a model that isn't ours instead of posting it to Anthropic. Substituting our default
318
+ // silently would be the same billing lie in a different costume, so this ends the turn.
319
+ if (!isOwnModel(requestedModel)) {
320
+ const message =
321
+ `Model "${requestedModel}" does not belong to the ${this.name} engine, so this run was stopped ` +
322
+ `rather than billed to Anthropic under another provider's model name. Pick a Claude model, or ` +
323
+ `request the engine that owns it (e.g. \`[engine:cursor,model:${requestedModel}]\`) and make sure ` +
324
+ `that engine is registered on this server.`;
325
+ console.error(`[claude] ${message}`);
326
+ yield { type: 'error', message };
327
+ return;
328
+ }
329
+ options['model'] = requestedModel;
307
330
  const thinkingMode = directives.thinking ?? config.thinking;
308
331
  if (thinkingMode) options['thinking'] = thinkingMode === 'enabled' ? { type: 'enabled' } : { type: thinkingMode };
309
332
  const effort = directives.effort ?? config.effort;
@@ -412,11 +435,11 @@ export class ClaudeCodeEngine implements AgentEngine {
412
435
  prior: opts.sessionId ? getSessionModel(opts.sessionId) : undefined,
413
436
  });
414
437
  if (sw.notice) yield { type: 'text_delta', text: sw.notice };
415
- if (opts.sessionId) setSessionModel(opts.sessionId, m.model);
438
+ if (opts.sessionId) setSessionModel(opts.sessionId, m.model, this.name);
416
439
  // Live ground-truth so the header pill confirms the actually-resolved model mid-turn
417
440
  // (catches inline overrides like [opus] and silent rate-limit fallbacks) instead of
418
441
  // only updating on session reload.
419
- yield { type: 'model_resolved', sessionId: opts.sessionId ?? '', model: m.model };
442
+ yield { type: 'model_resolved', sessionId: opts.sessionId ?? '', model: m.model, engine: this.name };
420
443
  }
421
444
  const servers = m.mcp_servers;
422
445
  if (Array.isArray(servers) && servers.length > 0) {
@@ -15,7 +15,8 @@ export async function initEngines(): Promise<void> {
15
15
  // Core always registers the Claude Code engine (the CE default — @anthropic-ai/claude-agent-sdk).
16
16
  // Optional engines are registered by an add-on through the same `registerEngine` seam when the
17
17
  // SHRAGA_OVERLAY loads (it's imported before the server serves any turn). Bare CE runs Claude Code
18
- // only; a directive requesting an unregistered engine falls back to claude-code (resolveAndGetEngine).
18
+ // only; a directive requesting an unregistered engine FAILS the turn (resolveAndGetEngine) rather
19
+ // than rerouting to another vendor's billing.
19
20
  registerEngine(new ClaudeCodeEngine());
20
21
 
21
22
  // Let `[<model>]` name ANY registered engine's model (e.g. `[composer-2.5]`) and imply its engine.
@@ -49,15 +50,26 @@ export function resolveEngine(directives?: { engine?: string }, agentConfig?: {
49
50
  return 'claude-code';
50
51
  }
51
52
 
53
+ /** Requested engine isn't registered on this boot. Carries an actionable message; callers surface it
54
+ * as a turn error (see streamClaude) — never as a reroute to a different vendor's engine/billing. */
55
+ export class EngineUnavailableError extends Error {
56
+ constructor(public readonly engine: string, available: string[]) {
57
+ super(
58
+ `Engine "${engine}" is not available on this server. Registered engines: ${available.join(', ') || 'none'}. ` +
59
+ `Optional engines register only when enabled at boot (AGENT_ENGINES must list the engine; the native ` +
60
+ `cursor engine also needs CURSOR_API_KEY) — check the server env and startup log, then retry. ` +
61
+ `The run was stopped rather than silently re-routed to another provider's billing.`,
62
+ );
63
+ this.name = 'EngineUnavailableError';
64
+ }
65
+ }
66
+
52
67
  export function resolveAndGetEngine(directives?: { engine?: string }, agentConfig?: { engine?: string }) {
53
68
  const name = resolveEngine(directives, agentConfig);
54
69
  // An optional engine may be unregistered on a given boot (add-on not loaded, missing API key or
55
- // failed init). Don't let that throw and kill every run including scheduled jobs like the daily
56
- // digest, which resolve the engine from the global agent-config. Fall back to the always-present
57
- // claude-code engine with a warning instead.
58
- if (!hasEngine(name)) {
59
- console.warn(`[engine] "${name}" not registered (available: ${getAvailableEngines().join(', ') || 'none'}) — falling back to claude-code`);
60
- return getEngine('claude-code');
61
- }
70
+ // failed init). Rerouting to claude-code here silently switched PROVIDER AND BILLING under the
71
+ // caller a cursor/agentx run billed Anthropic while the UI still showed the cursor chips. Fail
72
+ // loudly instead; the degradation is surfaced to whoever asked (user, schedule) as a turn error.
73
+ if (!hasEngine(name)) throw new EngineUnavailableError(name, getAvailableEngines());
62
74
  return getEngine(name);
63
75
  }
@@ -19,10 +19,10 @@ export function ownerEmails(): string[] {
19
19
  /** Is this email address an owner of this deployment?
20
20
  *
21
21
  * Exported because a medium that is not Slack cannot use `resolveOwners`: that returns Slack ids
22
- * (OWNERS ∩ contacts WITH a Slack id), which is a Slack-shaped answer. The para lane holds a
23
- * shraga uid + the email of the API key that opened the link, so it joins on the email instead.
24
- * An empty/unknown address is NOT an owner — the fail-closed direction, since the alternative is
25
- * fanning a deploy report out to whoever happened to link a para. */
22
+ * (OWNERS ∩ contacts WITH a Slack id), which is a Slack-shaped answer. A webhook-lane add-on, for
23
+ * instance, holds a shraga uid + the email of the API key that opened the link, so it joins on the
24
+ * email instead. An empty/unknown address is NOT an owner — the fail-closed direction, since the
25
+ * alternative is fanning a deploy report out to whoever happened to open a link. */
26
26
  export function isOwnerEmail(email: string | undefined | null): boolean {
27
27
  const e = String(email ?? '').trim().toLowerCase();
28
28
  return !!e && ownerEmails().includes(e);
@@ -48,8 +48,9 @@ export function senderStamp(): string {
48
48
  * side.
49
49
  *
50
50
  * Returns NOTHING, on purpose. It used to return "was there anyone to tell", which was a truthful
51
- * answer only while Slack was the sole medium: `resolveOwners` filters on a SLACK id, so once para
52
- * subscribes the same bus an empty owner list means "no Slack owner", not "nobody was notified".
51
+ * answer only while Slack was the sole medium: `resolveOwners` filters on a SLACK id, so once another
52
+ * medium subscribes the same bus an empty owner list means "no Slack owner", not "nobody was
53
+ * notified".
53
54
  * Rather than keep a boolean whose meaning depends on which features happen to be registered — no
54
55
  * caller reads it (`data-sync.ts`, `self-upgrade/index.ts`) — the notice is emitted unconditionally
55
56
  * and each subscriber decides for itself. The Slack subscriber already no-ops on an empty
@@ -138,7 +138,11 @@ export function ensureBuiltinSchedules(schedules: Schedule[]): Schedule[] {
138
138
  match: { status: 'error' },
139
139
  throttle: { byFields: ['name', 'error'], windowSec: 21600 },
140
140
  },
141
- task: { kind: 'prompt', prompt: FAILURE_NOTIFIER_PROMPT },
141
+ // Pinned to the always-registered engine ON PURPOSE. Left unpinned, this run inherits whatever
142
+ // agent-config.json's global `engine` happens to be — and an optional engine can simply not
143
+ // register on a given boot (add-on absent, missing API key), which is exactly the failure this
144
+ // job exists to report. Its own alert must not be the second casualty.
145
+ task: { kind: 'prompt', prompt: FAILURE_NOTIFIER_PROMPT, engine: 'claude-code' },
142
146
  scope: 'system',
143
147
  createdBy: { uid: SYSTEM_UID, email: 'system@shraga.local' },
144
148
  createdAt: now,
@@ -163,6 +167,16 @@ export function ensureBuiltinSchedules(schedules: Schedule[]): Schedule[] {
163
167
  existing.scope = builtin.scope;
164
168
  existing.trigger = existing.trigger ?? builtin.trigger;
165
169
  existing.createdBy = builtin.createdBy;
170
+ // A builtin's stored task is preserved (deployments edit the prompt) — with one exception: an
171
+ // engine/model pin is only meaningful as a PAIR. A stored task with a `model` but no `engine`
172
+ // was resolved against whatever the ambient global config was at the time, which is how the
173
+ // failure notifier ended up pinned to `composer-2.5` while running on claude-code. So when the
174
+ // builtin pins an engine and the stored task pins NONE, adopt the builtin's pair wholesale.
175
+ // An explicit stored `engine` is a real operator choice and is left untouched.
176
+ if (builtin.task.kind !== 'job' && existing.task.kind !== 'job' && builtin.task.engine && !existing.task.engine) {
177
+ existing.task.engine = builtin.task.engine;
178
+ existing.task.model = builtin.task.model;
179
+ }
166
180
  } else {
167
181
  schedules.push(builtin);
168
182
  }