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.
- package/README.md +2 -0
- package/dist/client/assets/{index-BSvY0squ.js → index-B-8NBGtJ.js} +38 -38
- package/dist/client/index.html +1 -1
- package/package.json +1 -1
- package/src/cli.ts +28 -51
- package/src/client/components/ConversationHeader.tsx +54 -11
- package/src/client/components/ConversationPane.tsx +8 -1
- package/src/client/lib/ws.ts +1 -1
- package/src/server/boot.ts +13 -7
- package/src/server/claude.ts +14 -3
- package/src/server/data-sync.ts +26 -2
- package/src/server/engine/claude-code.ts +27 -4
- package/src/server/engine/index.ts +20 -8
- package/src/server/notify-owners.ts +7 -6
- package/src/server/scheduler/builtins.ts +15 -1
- package/src/server/scheduler/runner.ts +5 -4
- package/src/server/sessions.ts +11 -3
- package/src/server/webhook-lane/feature.ts +227 -0
- package/src/server/{para → webhook-lane}/streamer.ts +90 -60
- package/src/server/para/feature.ts +0 -229
package/dist/client/index.html
CHANGED
|
@@ -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-
|
|
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.
|
|
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
|
-
|
|
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
|
-
//
|
|
69
|
+
// ── CLI EXTENSION SEAM ───────────────────────────────────────────────────────
|
|
66
70
|
//
|
|
67
|
-
// The
|
|
68
|
-
//
|
|
69
|
-
//
|
|
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
|
-
//
|
|
76
|
-
//
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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}
|
package/src/client/lib/ws.ts
CHANGED
|
@@ -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' }
|
package/src/server/boot.ts
CHANGED
|
@@ -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 (
|
|
997
|
-
//
|
|
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 (
|
|
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.
|
package/src/server/claude.ts
CHANGED
|
@@ -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
|
-
|
|
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({
|
package/src/server/data-sync.ts
CHANGED
|
@@ -179,8 +179,31 @@ export class DataSync {
|
|
|
179
179
|
await this.untrackIgnored();
|
|
180
180
|
this.ready = true;
|
|
181
181
|
}
|
|
182
|
+
}
|
|
182
183
|
|
|
183
|
-
|
|
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
|
-
|
|
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
|
|
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).
|
|
56
|
-
//
|
|
57
|
-
//
|
|
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.
|
|
23
|
-
* shraga uid + the email of the API key that opened the link, so it joins on the
|
|
24
|
-
* An empty/unknown address is NOT an owner — the fail-closed direction, since the
|
|
25
|
-
* fanning a deploy report out to whoever happened to
|
|
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
|
|
52
|
-
* subscribes the same bus an empty owner list means "no Slack owner", not "nobody was
|
|
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
|
-
|
|
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
|
}
|