shraga 0.1.99 → 0.1.101
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/defaults/skills/scheduler.md +1 -1
- package/defaults/system-prompt.md +1 -1
- package/dist/client/assets/{index-DlgCLI89.css → index-CibL2gLy.css} +1 -1
- package/dist/client/assets/{index-BZnO7Mn0.js → index-DWt6xZV9.js} +1 -1
- package/dist/client/index.html +2 -2
- package/package.json +1 -1
- package/src/client/components/ConversationHeader.tsx +1 -1
- package/src/client/components/schedules/ScheduleEditor.tsx +1 -1
- package/src/client/hooks/useEngines.ts +1 -1
- package/src/client/lib/prompt-directives.ts +1 -1
- package/src/server/background-jobs.ts +5 -5
- package/src/server/boot.ts +137 -78
- package/src/server/directives.ts +1 -1
- package/src/server/engine/index.ts +1 -1
- package/src/server/listen-retry.ts +1 -1
- package/src/server/paths.ts +1 -1
- package/src/server/port-reclaim.ts +2 -2
- package/src/server/scheduler/builtins.ts +1 -1
- package/src/server/self-upgrade/supervisor.sh +1 -1
|
@@ -54,7 +54,7 @@ export function writeRuntimeDirective(text: string, sel: RuntimeSelection): stri
|
|
|
54
54
|
}
|
|
55
55
|
}
|
|
56
56
|
// An engine the chosen model already implies is not written: `[model:cursor/composer-2.5]` and
|
|
57
|
-
// `[engine:
|
|
57
|
+
// `[engine:ext-agent,model:cursor/composer-2.5]` mean the same thing to the parser, and emitting the
|
|
58
58
|
// longer one would stamp a pin the user never chose onto every schedule that carries a model-only
|
|
59
59
|
// directive (11 of the 15 live ones) the moment the picker is touched.
|
|
60
60
|
const impliedEngine = sel.model ? resolveModelAlias(sel.model)?.engine : undefined;
|
|
@@ -6,10 +6,10 @@
|
|
|
6
6
|
// polling is not the fix; something has to WAKE the session when the process exits.
|
|
7
7
|
//
|
|
8
8
|
// Shape:
|
|
9
|
-
// • The registry is owned by the SERVER (module singleton), not by the turn.
|
|
9
|
+
// • The registry is owned by the SERVER (module singleton), not by the turn. An add-on engine builds a fresh
|
|
10
10
|
// `Agent` per turn, so a registry created there would die with the turn — the very failure above.
|
|
11
11
|
// Hosts hand each turn a thin per-session VIEW (`sessionJobRegistry`) over this one store; the
|
|
12
|
-
// view is duck-compatible with
|
|
12
|
+
// view is duck-compatible with the add-on engine's `ShellJobRegistry`, so `Shell({background:true})` and the
|
|
13
13
|
// ShellOutput/ShellStatus/ShellKill tools light up with no folklore `nohup`.
|
|
14
14
|
// • On exit we report through wake.ts — the same "close then report" path polls.ts already uses in
|
|
15
15
|
// production, so a job outcome lands wherever the session speaks (Slack thread / web UI).
|
|
@@ -193,10 +193,10 @@ function pidAgeSeconds(pid: number): number | null {
|
|
|
193
193
|
/**
|
|
194
194
|
* Env for a job's shell: the server's own, minus anything that looks like a credential.
|
|
195
195
|
*
|
|
196
|
-
*
|
|
196
|
+
* An add-on engine's foreground `Bash` redacts these by default (RealShellOptions.redactEnv). Backgrounding a
|
|
197
197
|
* command must not be a way around that — otherwise `Bash({background:true})` becomes a strictly
|
|
198
198
|
* weaker sandbox than `Bash({})`, and `echo $ANTHROPIC_API_KEY` lands in a job log we then feed back
|
|
199
|
-
* into the model. Mirrors
|
|
199
|
+
* into the model. Mirrors the add-on engine's SECRET_ENV_RE; a job that genuinely needs a credential gets it
|
|
200
200
|
* explicitly via `JobOwner.env`.
|
|
201
201
|
*/
|
|
202
202
|
const SECRET_ENV_RE = /(API_KEY|_TOKEN|_SECRET|_PASSWORD|_PRIVATE_KEY|^AWS_|^GITHUB_TOKEN$|^OPENAI_|^ANTHROPIC_|^GOOGLE_|^GEMINI_|^GROQ_|^NPM_TOKEN$|^SLACK_)/i;
|
|
@@ -519,7 +519,7 @@ function pruneOld(): void {
|
|
|
519
519
|
// ── The per-turn view handed to the agent engine ───────────────────────────────
|
|
520
520
|
|
|
521
521
|
/**
|
|
522
|
-
* A session-scoped facade over the process-wide store, duck-compatible with
|
|
522
|
+
* A session-scoped facade over the process-wide store, duck-compatible with the add-on engine's
|
|
523
523
|
* `ShellJobRegistry` — pass it as `makeRealShellTool({ registry })` and to `makeShellJobTools`.
|
|
524
524
|
*
|
|
525
525
|
* Cheap to build per turn (it holds no state); the jobs it starts belong to the server, so they
|
package/src/server/boot.ts
CHANGED
|
@@ -1022,84 +1022,146 @@ function abortUpgrade(socket: import('node:stream').Duplex, code: number, messag
|
|
|
1022
1022
|
catch { socket.destroy(); }
|
|
1023
1023
|
}
|
|
1024
1024
|
|
|
1025
|
-
|
|
1025
|
+
/** Claude Code speaks the kitty keyboard protocol, where `CSI <code>;<mods> u` IS a modified keypress —
|
|
1026
|
+
* `CSI 107;9u` is cmd+k, which clears its screen and, arriving twice, runs `/clear` and destroys the
|
|
1027
|
+
* conversation. Our terminal is xterm.js 5.5, which has no kitty support and can never emit that byte,
|
|
1028
|
+
* so anything shaped like it on this socket is spurious, not a keystroke the user made. Drop it and say
|
|
1029
|
+
* where it came from — repeated unexplained `/clear`s cost real sessions. Remove once a client that
|
|
1030
|
+
* genuinely speaks the protocol attaches here. */
|
|
1031
|
+
const CSI_U_KEY = /\x1b\[\d+;\d+u/;
|
|
1032
|
+
function isSpuriousKittyKey(data: import('ws').RawData, isBinary: boolean): string | null {
|
|
1033
|
+
if (isBinary) return null;
|
|
1034
|
+
try {
|
|
1035
|
+
const msg = JSON.parse(data.toString());
|
|
1036
|
+
if (msg?.type !== 'input' || typeof msg.data !== 'string') return null;
|
|
1037
|
+
const input = Buffer.from(msg.data, 'base64').toString();
|
|
1038
|
+
return CSI_U_KEY.test(input) ? input : null;
|
|
1039
|
+
} catch { return null; }
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
function proxySidecarWebSocket(req: import('node:http').IncomingMessage, socket: import('node:stream').Duplex, head: Buffer, port: number, authed: Promise<import('./auth.ts').AuthUser | null>) {
|
|
1026
1043
|
const targetUrl = `ws://127.0.0.1:${port}${req.url}`;
|
|
1027
1044
|
sidecarWss.handleUpgrade(req, socket as any, head, (clientWs) => {
|
|
1028
|
-
//
|
|
1029
|
-
//
|
|
1030
|
-
//
|
|
1031
|
-
//
|
|
1045
|
+
// Belt-and-braces: nothing pauses this socket any more (the auth gap moved past the handshake, see
|
|
1046
|
+
// AUTH GATE below), but an explicitly paused socket does not re-enter flowing mode just because a
|
|
1047
|
+
// listener was added — so if anything upstream ever pauses again, resuming here is what keeps every
|
|
1048
|
+
// buffered byte and every subsequent keystroke from sitting unread forever.
|
|
1032
1049
|
socket.resume();
|
|
1033
|
-
const targetWs = new WebSocket(targetUrl);
|
|
1034
|
-
let opened = false;
|
|
1035
1050
|
|
|
1036
|
-
//
|
|
1037
|
-
//
|
|
1038
|
-
//
|
|
1039
|
-
//
|
|
1040
|
-
|
|
1051
|
+
// AUTH GATE — deliberately AFTER the handshake, not before it. The upgrade must complete in the
|
|
1052
|
+
// SAME tick as the 'upgrade' event: on Bun (reproduced on 1.4.0) a `handleUpgrade` deferred past a
|
|
1053
|
+
// macrotask — exactly what awaiting a token verification costs on a cold key cache — lands in ws's
|
|
1054
|
+
// `completeUpgrade` abort path, and ws's own `abortHandshake` then throws
|
|
1055
|
+
// (`undefined is not an object (evaluating 'message')`). The browser sees a bare close, caddy logs
|
|
1056
|
+
// `502 EOF`, and every terminal pane stays blank. Isolated repro: a microtask-deferred handleUpgrade
|
|
1057
|
+
// succeeds, a `setTimeout(…, 0)`-deferred one throws — which is why this only bit when auth actually
|
|
1058
|
+
// hit the network. Security is unchanged: NOTHING is dialed or forwarded until the token verifies,
|
|
1059
|
+
// and a failed check closes the socket with 1008 before a single byte reaches the sidecar.
|
|
1060
|
+
let targetWs: WebSocket;
|
|
1061
|
+
let opened = false;
|
|
1062
|
+
// Anything typed between the 101 and the auth verdict. Bounded like `pending` below; on rejection it
|
|
1063
|
+
// is dropped with the socket.
|
|
1064
|
+
const preAuth: Array<{ data: import('ws').RawData; isBinary: boolean }> = [];
|
|
1065
|
+
let gateOpen = false;
|
|
1041
1066
|
clientWs.on('message', (data, isBinary) => {
|
|
1042
|
-
|
|
1043
|
-
//
|
|
1044
|
-
//
|
|
1045
|
-
//
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
// outcome — the client then reconnects rather than trusting a dead pipe.
|
|
1050
|
-
if (targetWs.readyState === WebSocket.OPEN) targetWs.send(data, { binary: isBinary });
|
|
1051
|
-
else if (!opened && pending.length < 256) pending.push({ data, isBinary }); // bounded: never buffer unboundedly
|
|
1052
|
-
});
|
|
1053
|
-
|
|
1054
|
-
targetWs.on('open', () => {
|
|
1055
|
-
opened = true;
|
|
1056
|
-
for (const m of pending) {
|
|
1057
|
-
if (targetWs.readyState === WebSocket.OPEN) targetWs.send(m.data, { binary: m.isBinary });
|
|
1067
|
+
if (gateOpen || preAuth.length >= 256) return; // bounded: never buffer unboundedly
|
|
1068
|
+
// Same kitty-key filter the bridged handler applies. These frames are flushed straight into
|
|
1069
|
+
// `pending`, so without it a spurious `CSI …u` sent during the auth window would reach the shell
|
|
1070
|
+
// by the one path that skips the guard.
|
|
1071
|
+
if (isSpuriousKittyKey(data, isBinary)) {
|
|
1072
|
+
console.warn('[ws-proxy] BLOCKED kitty key input (pre-auth)', JSON.stringify({ url: req.url?.split('?')[0], ua: req.headers['user-agent'] }));
|
|
1073
|
+
return;
|
|
1058
1074
|
}
|
|
1059
|
-
|
|
1060
|
-
targetWs.on('message', (data, isBinary) => {
|
|
1061
|
-
if (clientWs.readyState === WebSocket.OPEN) clientWs.send(data, { binary: isBinary });
|
|
1062
|
-
});
|
|
1075
|
+
preAuth.push({ data, isBinary });
|
|
1063
1076
|
});
|
|
1064
|
-
|
|
1065
|
-
// Keepalive: a proxied sidecar socket carries no app-level heartbeat, so an idle WS gets silently dropped
|
|
1066
|
-
// by an intermediary (Cloudflare tunnel idles WS at ~100s) leaving the BROWSER half-open — readyState
|
|
1067
|
-
// stays OPEN, no onclose fires, the "connected" dot stays green and keystrokes vanish into a dead pipe.
|
|
1068
|
-
// Ping the client (browsers auto-pong at the protocol level) to keep intermediaries from idling us out,
|
|
1069
|
-
// and terminate a peer that misses a pong so the client gets a real close → its reconnect kicks in.
|
|
1070
|
-
// Tolerate ONE missed pong before terminating (~2 intervals of grace): a backgrounded mobile tab is
|
|
1071
|
-
// JS/network-frozen and can't auto-pong for a cycle, so a 1-strike policy force-closed it every 30s and
|
|
1072
|
-
// churned reconnects. Two strikes lets a brief freeze ride through; a truly dead pipe still gets cut.
|
|
1073
|
-
let missedPongs = 0;
|
|
1074
|
-
clientWs.on('pong', () => { missedPongs = 0; });
|
|
1075
|
-
const pingInterval = setInterval(() => {
|
|
1077
|
+
void authed.then((user) => {
|
|
1076
1078
|
if (clientWs.readyState !== WebSocket.OPEN) return;
|
|
1077
|
-
if (
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
targetWs.on('close', () => { clearInterval(pingInterval); clientWs.close(); });
|
|
1083
|
-
targetWs.on('error', (e) => {
|
|
1084
|
-
// Pre-open failure = the sidecar daemon is unreachable (e.g. it idle-exited, or is not up yet
|
|
1085
|
-
// after a restart). This is TRANSIENT: the daemon (and its shells) survive a server/proxy blip, and
|
|
1086
|
-
// we revive it right below — so flag `fatal:false`. The client must keep the pane alive and re-attach
|
|
1087
|
-
// (a mobile client that backgrounded for minutes recovers its still-running sidecar on resume), NOT show a
|
|
1088
|
-
// permanent "session unavailable". Only the daemon's own `session not found` (post-open) is fatal.
|
|
1089
|
-
if (!opened) {
|
|
1090
|
-
if (clientWs.readyState === WebSocket.OPEN) {
|
|
1091
|
-
try { clientWs.send(JSON.stringify({ type: 'error', message: 'sidecar daemon unavailable', fatal: false })); } catch { /* socket gone */ }
|
|
1092
|
-
}
|
|
1093
|
-
} else {
|
|
1094
|
-
console.warn('[ws-proxy] target error:', e.message);
|
|
1079
|
+
if (!user) {
|
|
1080
|
+
console.warn(`[ws-proxy] rejected unauthenticated upgrade for ${req.url?.split('?')[0]}`);
|
|
1081
|
+
clientWs.close(1008, 'unauthorized');
|
|
1082
|
+
return;
|
|
1095
1083
|
}
|
|
1096
|
-
|
|
1084
|
+
gateOpen = true;
|
|
1085
|
+
startBridge();
|
|
1097
1086
|
});
|
|
1098
|
-
|
|
1099
|
-
|
|
1087
|
+
|
|
1088
|
+
function startBridge() {
|
|
1089
|
+
targetWs = new WebSocket(targetUrl);
|
|
1090
|
+
|
|
1091
|
+
// Bound IMMEDIATELY, not inside targetWs 'open': the BROWSER's socket is OPEN the moment
|
|
1092
|
+
// handleUpgrade returns, so it reports "connected" and starts sending while we're still dialing the
|
|
1093
|
+
// sidecar. Registering the listener on open discarded everything typed in that window — silently.
|
|
1094
|
+
// Queue instead, and flush in order once upstream is up.
|
|
1095
|
+
const pending: Array<{ data: import('ws').RawData; isBinary: boolean }> = preAuth.splice(0);
|
|
1096
|
+
clientWs.on('message', (data, isBinary) => {
|
|
1097
|
+
// App-level liveness probe (Layer 2): the client can't read protocol pongs from JS, so it sends
|
|
1098
|
+
// `{type:'ping'}` and expects `{type:'pong'}`. We RELAY it — we must not answer it here. A
|
|
1099
|
+
// proxy-local reply only proves THIS hop is alive: if the proxy→sidecar leg is half-open, or the
|
|
1100
|
+
// sidecar has already dropped this client from its subscriber set, the browser still gets pongs,
|
|
1101
|
+
// keeps `readyState === OPEN`, shows a green "connected" dot, and every keystroke disappears.
|
|
1102
|
+
// The probe is only worth anything end-to-end, so the sidecar owns the reply (para-pty answers in
|
|
1103
|
+
// its ws message handler); a sidecar that doesn't reply fails the probe, which is the honest
|
|
1104
|
+
// outcome — the client then reconnects rather than trusting a dead pipe.
|
|
1105
|
+
const spurious = isSpuriousKittyKey(data, isBinary);
|
|
1106
|
+
if (spurious) {
|
|
1107
|
+
console.warn('[ws-proxy] BLOCKED kitty key input', JSON.stringify({
|
|
1108
|
+
url: req.url?.split('?')[0], seq: spurious, ua: req.headers['user-agent'], ref: req.headers.referer,
|
|
1109
|
+
}));
|
|
1110
|
+
return;
|
|
1111
|
+
}
|
|
1112
|
+
if (targetWs.readyState === WebSocket.OPEN) targetWs.send(data, { binary: isBinary });
|
|
1113
|
+
else if (!opened && pending.length < 256) pending.push({ data, isBinary }); // bounded: never buffer unboundedly
|
|
1114
|
+
});
|
|
1115
|
+
|
|
1116
|
+
targetWs.on('open', () => {
|
|
1117
|
+
opened = true;
|
|
1118
|
+
for (const m of pending) {
|
|
1119
|
+
if (targetWs.readyState === WebSocket.OPEN) targetWs.send(m.data, { binary: m.isBinary });
|
|
1120
|
+
}
|
|
1121
|
+
pending.length = 0;
|
|
1122
|
+
targetWs.on('message', (data, isBinary) => {
|
|
1123
|
+
if (clientWs.readyState === WebSocket.OPEN) clientWs.send(data, { binary: isBinary });
|
|
1124
|
+
});
|
|
1125
|
+
});
|
|
1126
|
+
|
|
1127
|
+
// Keepalive: a proxied sidecar socket carries no app-level heartbeat, so an idle WS gets silently dropped
|
|
1128
|
+
// by an intermediary (Cloudflare tunnel idles WS at ~100s) leaving the BROWSER half-open — readyState
|
|
1129
|
+
// stays OPEN, no onclose fires, the "connected" dot stays green and keystrokes vanish into a dead pipe.
|
|
1130
|
+
// Ping the client (browsers auto-pong at the protocol level) to keep intermediaries from idling us out,
|
|
1131
|
+
// and terminate a peer that misses a pong so the client gets a real close → its reconnect kicks in.
|
|
1132
|
+
// Tolerate ONE missed pong before terminating (~2 intervals of grace): a backgrounded mobile tab is
|
|
1133
|
+
// JS/network-frozen and can't auto-pong for a cycle, so a 1-strike policy force-closed it every 30s and
|
|
1134
|
+
// churned reconnects. Two strikes lets a brief freeze ride through; a truly dead pipe still gets cut.
|
|
1135
|
+
let missedPongs = 0;
|
|
1136
|
+
clientWs.on('pong', () => { missedPongs = 0; });
|
|
1137
|
+
const pingInterval = setInterval(() => {
|
|
1138
|
+
if (clientWs.readyState !== WebSocket.OPEN) return;
|
|
1139
|
+
if (missedPongs >= 2) { console.warn('[ws-proxy] client missed pongs — terminating (likely backgrounded/frozen client)'); clientWs.terminate(); return; }
|
|
1140
|
+
missedPongs++;
|
|
1141
|
+
clientWs.ping();
|
|
1142
|
+
}, WS_PING_INTERVAL);
|
|
1143
|
+
|
|
1144
|
+
targetWs.on('close', () => { clearInterval(pingInterval); clientWs.close(); });
|
|
1145
|
+
targetWs.on('error', (e) => {
|
|
1146
|
+
// Pre-open failure = the sidecar daemon is unreachable (e.g. it idle-exited, or is not up yet
|
|
1147
|
+
// after a restart). This is TRANSIENT: the daemon (and its shells) survive a server/proxy blip, and
|
|
1148
|
+
// we revive it right below — so flag `fatal:false`. The client must keep the pane alive and re-attach
|
|
1149
|
+
// (a mobile client that backgrounded for minutes recovers its still-running sidecar on resume), NOT show a
|
|
1150
|
+
// permanent "session unavailable". Only the daemon's own `session not found` (post-open) is fatal.
|
|
1151
|
+
if (!opened) {
|
|
1152
|
+
if (clientWs.readyState === WebSocket.OPEN) {
|
|
1153
|
+
try { clientWs.send(JSON.stringify({ type: 'error', message: 'sidecar daemon unavailable', fatal: false })); } catch { /* socket gone */ }
|
|
1154
|
+
}
|
|
1155
|
+
} else {
|
|
1156
|
+
console.warn('[ws-proxy] target error:', e.message);
|
|
1157
|
+
}
|
|
1158
|
+
clientWs.close();
|
|
1159
|
+
});
|
|
1160
|
+
clientWs.on('close', () => { clearInterval(pingInterval); if (targetWs.readyState === WebSocket.OPEN) targetWs.close(); });
|
|
1161
|
+
clientWs.on('error', (e) => { console.error(`[ws-proxy] client error:`, e.message); targetWs.close(); });
|
|
1162
|
+
}
|
|
1100
1163
|
});
|
|
1101
1164
|
}
|
|
1102
|
-
|
|
1103
1165
|
server.on('upgrade', (req, socket, head) => {
|
|
1104
1166
|
if (req.url === '/ws') {
|
|
1105
1167
|
wss.handleUpgrade(req, socket as any, head, (ws) => {
|
|
@@ -1125,7 +1187,6 @@ server.on('upgrade', (req, socket, head) => {
|
|
|
1125
1187
|
// is reading it while we verify; pausing makes that explicit and guarantees bytes the client sends
|
|
1126
1188
|
// between the handshake and our decision are buffered, not dropped. proxySidecarWebSocket resumes
|
|
1127
1189
|
// it once ws owns the socket.
|
|
1128
|
-
socket.pause();
|
|
1129
1190
|
// Fail CLOSED on anything: a rejected/thrown/slow auth must destroy the socket, never leave it
|
|
1130
1191
|
// dangling. Without the catch a throw in authenticateWsUpgrade (token store unavailable, malformed
|
|
1131
1192
|
// header) produced an unhandled rejection AND an open, unauthenticated, un-proxied socket; without
|
|
@@ -1139,17 +1200,15 @@ server.on('upgrade', (req, socket, head) => {
|
|
|
1139
1200
|
// upgrade even when auth resolved in milliseconds.
|
|
1140
1201
|
let authTimer: ReturnType<typeof setTimeout> | undefined;
|
|
1141
1202
|
const timedOut = new Promise<null>((resolve) => { authTimer = setTimeout(() => resolve(null), WS_AUTH_TIMEOUT_MS); authTimer.unref?.(); });
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
proxySidecarWebSocket(req, socket, head, port);
|
|
1152
|
-
});
|
|
1203
|
+
const verdict = Promise.race([authed, timedOut]).then((user) => { if (authTimer) clearTimeout(authTimer); return user; });
|
|
1204
|
+
// The handshake is NOT deferred behind that verification: on Bun (reproduced on 1.4.0) a
|
|
1205
|
+
// `handleUpgrade` deferred past a macrotask lands in ws's `completeUpgrade` abort path, and ws's
|
|
1206
|
+
// own `abortHandshake` then throws (`undefined is not an object (evaluating 'message')`). The
|
|
1207
|
+
// browser sees a bare close, caddy logs `502 EOF`, and every terminal pane stays blank — and an
|
|
1208
|
+
// UNAUTHENTICATED upgrade hangs instead of being refused. Complete the upgrade now, hand the
|
|
1209
|
+
// in-flight verification down, and let it decide whether to bridge or close (see the AUTH GATE
|
|
1210
|
+
// note in proxySidecarWebSocket).
|
|
1211
|
+
proxySidecarWebSocket(req, socket, head, port, verdict);
|
|
1153
1212
|
} else {
|
|
1154
1213
|
// No sidecar registered for this path — a client error, not an auth failure.
|
|
1155
1214
|
abortUpgrade(socket, 400, 'Unknown upgrade path');
|
package/src/server/directives.ts
CHANGED
|
@@ -38,7 +38,7 @@ const DIRECTIVE_KEYS = ['model', 'turns', 'thinking', 'think', 'effort', 'engine
|
|
|
38
38
|
const isQualifiedModel = (v: string) => /^[a-z0-9._-]+\/[a-z0-9./_-]+$/.test(v);
|
|
39
39
|
|
|
40
40
|
/** Resolves a model token the alias table doesn't know against the REGISTERED engines' own model
|
|
41
|
-
* lists, and reports which engine owns it. Without this, `[composer-2.5]` (an
|
|
41
|
+
* lists, and reports which engine owns it. Without this, `[composer-2.5]` (an add-on engine's model) was
|
|
42
42
|
* warned about and dropped, so the turn silently ran on the previous engine/model. Injected by
|
|
43
43
|
* `initEngines()` — directives.ts stays pure and dependency-free for CE and for tests. */
|
|
44
44
|
export type ModelResolver = (token: string) => { model: string; engine?: string } | null;
|
|
@@ -70,7 +70,7 @@ export function resolveAndGetEngine(directives?: { engine?: string }, agentConfi
|
|
|
70
70
|
const name = resolveEngine(directives, agentConfig);
|
|
71
71
|
// An optional engine may be unregistered on a given boot (add-on not loaded, missing API key or
|
|
72
72
|
// failed init). Rerouting to claude-code here silently switched PROVIDER AND BILLING under the
|
|
73
|
-
// caller —
|
|
73
|
+
// caller — an add-on engine's run billed Anthropic while the UI still showed the cursor chips. Fail
|
|
74
74
|
// loudly instead; the degradation is surfaced to whoever asked (user, schedule) as a turn error.
|
|
75
75
|
if (!hasEngine(name)) throw new EngineUnavailableError(name, getAvailableEngines());
|
|
76
76
|
return getEngine(name);
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* `kickstart -k` starts the replacement while the old process drains (up to 90s), so the new one
|
|
5
5
|
* meets EADDRINUSE. Exiting immediately hands the problem to the service manager — which respawns
|
|
6
6
|
* on a ~10s throttle, meets the same still-draining owner, and exits again. MEASURED 2026-09-06 on
|
|
7
|
-
*
|
|
7
|
+
* a production box: one watchdog kick produced ~20 start/exit cycles over five minutes, every one of
|
|
8
8
|
* them `port already in use`, and every scheduled run in that window died with it. The manager was
|
|
9
9
|
* doing exactly what it was asked to; the retry it was standing in for just belonged here, where
|
|
10
10
|
* the wait is one process holding still instead of N processes racing.
|
package/src/server/paths.ts
CHANGED
|
@@ -68,7 +68,7 @@ export const APP_ROOT = resolveAppRoot();
|
|
|
68
68
|
* (shipped assets) explicitly.
|
|
69
69
|
*
|
|
70
70
|
* Aliased to APP_ROOT, not PACKAGE_ROOT, deliberately: every remaining external consumer of this
|
|
71
|
-
* export (shraga-ee
|
|
71
|
+
* export (shraga-ee's optional engines, e.g. `engine/cursor.ts`) uses it as the agent's project root —
|
|
72
72
|
* i.e. they meant APP_ROOT and were hitting the same npm-layout bug. Pointing the alias here fixes
|
|
73
73
|
* them without an EE change. Nothing in this package reads shipped assets through it.
|
|
74
74
|
*/
|
|
@@ -7,7 +7,7 @@ import { execFileSync } from 'node:child_process';
|
|
|
7
7
|
* when the server's launchd/systemd parent dies first: the server reparents to pid 1, nothing will
|
|
8
8
|
* ever signal it, and it holds the port FOREVER. The replacement then dies on every respawn while
|
|
9
9
|
* the orphan keeps serving the OLD code, so `kickstart -k` looks successful and changes nothing
|
|
10
|
-
* (observed on
|
|
10
|
+
* (observed on a production deployment 2026-08-28: a deploy "restarted" the service four times, and the process
|
|
11
11
|
* answering :3032 was three hours and one version old).
|
|
12
12
|
*
|
|
13
13
|
* Reclaiming is deliberately narrow — an over-broad "kill whatever holds my port" is how a deploy
|
|
@@ -22,7 +22,7 @@ export function reclaimStalePort(port: number, opts: { entrypoint?: string; cwd?
|
|
|
22
22
|
|
|
23
23
|
// Any state, not just LISTEN: an orphan that closed its listener inside a drain it never finished
|
|
24
24
|
// still OWNS the socket, and looking only for LISTEN made it invisible while it blocked every
|
|
25
|
-
// replacement (
|
|
25
|
+
// replacement (a production deployment 2026-09-06: ppid-1 orphan, listener closed, port held for six minutes and
|
|
26
26
|
// counting). `isStaleSelf` is what keeps this narrow — a bystander is never a candidate.
|
|
27
27
|
const holders = [...new Set([...listeners(port), ...portUsers(port)])]
|
|
28
28
|
.filter((pid) => pid !== process.pid && isStaleSelf(pid, entrypoint, cwd));
|
|
@@ -112,7 +112,7 @@ export function backfillScope(schedules: Schedule[]): void {
|
|
|
112
112
|
}
|
|
113
113
|
// Undo the `engine: 'claude-code'` pin reconcile stamped onto the failure notifier. It shipped on
|
|
114
114
|
// a premise the box's own logs disprove — nothing was ever misrouted; the deployment was simply
|
|
115
|
-
// globally on claude-code then and on
|
|
115
|
+
// globally on claude-code then and on an add-on engine now — and it made the alarm the second casualty of
|
|
116
116
|
// the real failure mode (an org cap on the pinned provider). Narrow on purpose: exactly the
|
|
117
117
|
// state that clause produced (this id, that engine, no model), so it cannot eat a real choice.
|
|
118
118
|
if (s.id === FAILURE_NOTIFIER_SCHEDULE_ID && task.engine === 'claude-code' && !task.model) {
|
|
@@ -139,7 +139,7 @@ wait_for_version() { # version timeout
|
|
|
139
139
|
# One dropped probe is not a crash-loop. This runs on a busy box where a single request can lose a
|
|
140
140
|
# 10s race (MCP mounts, a GC pause, the drain window of the restart we just did), and a zero-
|
|
141
141
|
# tolerance soak turns that blip into an automatic rollback of a perfectly good version — observed
|
|
142
|
-
# reverting 0.1.48 on
|
|
142
|
+
# reverting 0.1.48 on a production deployment while the process stayed up throughout. Require CONSECUTIVE misses, so
|
|
143
143
|
# a real crash (which never answers again) still fails fast.
|
|
144
144
|
SOAK_MISS_LIMIT="${SOAK_MISS_LIMIT:-3}"
|
|
145
145
|
soak() { # version seconds
|