shraga 0.1.3 → 0.1.5

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.
@@ -0,0 +1,1815 @@
1
+ import './env-resolve.ts'; // resolve named .env file (must be first — before any config read)
2
+ import './env-sanitize.ts'; // strip unresolved ${VAR} placeholders before any config is read
3
+
4
+ const SUPPRESSED_ERRORS = /NGHTTP2|h2 is not supported|socket disconnected before secure TLS/i;
5
+ process.on('uncaughtException', (err) => {
6
+ if (SUPPRESSED_ERRORS.test(err.message ?? '')) return;
7
+ console.error('[server] Uncaught exception (kept alive):', err.message ?? err);
8
+ });
9
+ process.on('unhandledRejection', (reason) => {
10
+ const msg = (reason as Error)?.message ?? String(reason);
11
+ if (SUPPRESSED_ERRORS.test(msg)) return;
12
+ console.error('[server] Unhandled rejection (kept alive):', msg);
13
+ });
14
+ import { createServer } from 'node:http';
15
+ import { execSync } from 'node:child_process';
16
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync } from 'node:fs';
17
+ import path from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+ import express from 'express';
20
+ import { WebSocketServer, WebSocket } from 'ws';
21
+ import { requireAuth, verifyBearer, AUTH_PROVIDER, localLogin, addLocalUser, localUserCount } from './auth.ts';
22
+ import { getMcpConfig, getRawMcpConfig, getResolvedMcpConfig, getGlobalMcpConfig, saveMcpConfig, maskEnvValues, mergeWithOriginal, type McpConfig } from './mcp.ts';
23
+ import { streamChat, consumeStream, getAgentConfig, saveAgentConfig, type AgentConfig, type PermissionHandler, type QuestionHandler, type QuestionAnswers, type AttachmentMeta, type WsEvent } from './claude.ts';
24
+ import { mountFeatures, registerFeature, resumeFeatureSession, collectFeatureFlags, collectSidecarRoutes } from './features.ts';
25
+ import { registerSpaCatchAll } from './spa-catchall.ts';
26
+ import { slackFeature } from './slack/feature.ts';
27
+ import { dataPath } from './paths.ts';
28
+ import { getAllSessions, getSession, getSessionHistory, upsertSession, appendMessage, saveConversation, loadConversation, setSessionDirectives, getAutoApprove, setAutoApprove, getSessionsByScheduleId, getSessionsVisibleTo, isSessionVisibleTo, setRunStatus, incrementRetryCount, resetRetryCount, getRunningSessions, 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';
29
+ import { setBroadcaster } from './session-bus.ts';
30
+ import * as scheduler from './scheduler/index.ts';
31
+ import { initPolls } from './polls.ts';
32
+ import { pushEnabled } from './push/push.ts';
33
+ import { upsertToken, removeToken } from './push/store.ts';
34
+ import { initPushTriggers, pushTurnDone, pushQuestion } from './push/triggers.ts';
35
+ import type { Schedule } from './scheduler/index.ts';
36
+ import { listSkills, listMcpCommands, getSkill, saveSkill, deleteSkill, duplicateSkill, renameSkill, getDefaultSkills, setDefaultSkills, resolveDefaultSkillsContent, purgeExpiredSkills, lintSkills } from './skills.ts';
37
+ import { listWorkspaceTree, listWorkspaceDir, readWorkspaceFile, safeResolve as resolveWorkspacePath, watchWorkspace, ensureDir as ensureWorkspaceDir } from './workspace.ts';
38
+ import { seedDefaults, getBuiltinSkillNames } from './seed.ts';
39
+ import { hydrateSlackUserToken } from './slack/oauth.ts';
40
+ import { registerMcpOAuthRoutes } from './mcp-oauth.ts';
41
+ import { registerEventRoutes } from './events/routes.ts';
42
+ import { startEventDispatcher } from './events/dispatcher.ts';
43
+ import { seedOperators } from './contacts.ts';
44
+ import { dataSync } from './data-sync.ts';
45
+ import { mountMcpServer } from './mcp-server.ts';
46
+ import { lookupIdempotent, rememberIdempotent } from './idempotency.ts';
47
+ import { createApiKey, deleteApiKey, listApiKeys } from './api-keys.ts';
48
+ import { addUnread, markRead as markUnread, getUnreads } from './unread.ts';
49
+
50
+ import { loadShragaConfig } from './shraga-config.ts';
51
+ import { startSidecars, stopSidecars } from './mcp-sidecar.ts';
52
+ import { syncVendorRepos } from './vendor-sync.ts';
53
+ import { initEngines, getAvailableEngines, getEngine } from './engine/index.ts';
54
+ import { statsSampler } from './stats.ts';
55
+ import { getAll as getAllContacts } from './contacts.ts';
56
+ import { artifactsRouter } from './artifacts/artifacts.routes.ts';
57
+ import { handleArtifactToolUse } from './artifacts/artifacts.handler.ts';
58
+ import { registerEngine } from './engine/index.ts';
59
+ import { subscribeEvent } from './events/bus.ts';
60
+ import type { AgentEngine } from './engine/types.ts';
61
+ import type { ServerFeature } from './features.ts';
62
+ import type { Server as HttpServer } from 'node:http';
63
+ import type { Express } from 'express';
64
+ import { emitEvent } from './events/bus.ts';
65
+ import type { ExtRegisterFn } from './extensions.ts';
66
+ import type { WebhookOptions } from './events/webhook.ts';
67
+ import type { ShragaEvent, PayloadOf } from './events/types.ts';
68
+
69
+ export interface BootRegistrations {
70
+ features?: ServerFeature[];
71
+ engines?: AgentEngine[];
72
+ extensions?: ExtRegisterFn[];
73
+ eventSubs?: Array<{ source: string; handler: (payload: any, evt: any) => void }>;
74
+ }
75
+
76
+ export interface ServerHandle {
77
+ app: Express;
78
+ server: HttpServer;
79
+ port: number;
80
+ url: string;
81
+ /** Publish an event onto the in-process bus (same fn extensions get as ctx.emitEvent). */
82
+ emitEvent: typeof emitEvent;
83
+ /** Register an extension AFTER start() — mounts onto the live extension Router (before the SPA
84
+ * catch-all), the same seam file-based *.ext.ts drop-ins hot-load through. OPT-IN: throws unless
85
+ * ShragaOptions.runtimeRegistration is enabled. */
86
+ registerExtension: (fn: ExtRegisterFn) => Promise<void>;
87
+ /** Declare a verified vendor webhook AFTER start() (sugar over registerExtension — a webhook IS an
88
+ * extension). OPT-IN: throws unless runtimeRegistration is enabled. */
89
+ registerWebhook: <K extends string>(opts: WebhookOptions<K>) => Promise<void>;
90
+ /** Subscribe to a typed event source AFTER start(). Returns an unsubscribe fn. OPT-IN: throws
91
+ * unless runtimeRegistration is enabled. */
92
+ on: <K extends string>(source: K, handler: (payload: PayloadOf<K>, evt: ShragaEvent<K>) => void) => () => void;
93
+ /** Drain in-flight streams, stop consumers, close the server. Does NOT exit the process. */
94
+ stop: () => Promise<void>;
95
+ }
96
+
97
+ export async function bootServer(__reg: BootRegistrations = {}): Promise<ServerHandle> {
98
+ // Passive mode: HTTP serving only — no schedulers, event consumers, or background writers.
99
+ // Used by shadow-verify instances and warm-standby twins that share a live DATA_DIR
100
+ // (single-active-writer rule: the active instance is the only one mutating data/).
101
+ // `UNCLAW_PASSIVE` is the legacy name — still honoured so existing deploy recipes keep working.
102
+ const PASSIVE_FLAG = process.env.SHRAGA_PASSIVE ?? process.env.UNCLAW_PASSIVE;
103
+ const PASSIVE = PASSIVE_FLAG === '1' || PASSIVE_FLAG === 'true';
104
+ if (PASSIVE) console.log('[server] PASSIVE mode — schedulers, consumers and background writers disabled');
105
+
106
+ if (!PASSIVE) await dataSync.init();
107
+ await loadShragaConfig();
108
+ // Programmatic engines register through the same seam an overlay uses — BEFORE initEngines() so
109
+ // getAvailableEngines() includes them and a directive can resolve to one immediately.
110
+ for (const e of __reg.engines ?? []) registerEngine(e);
111
+ // Programmatic event subscribers land before the dispatcher starts, symmetric with an overlay.
112
+ for (const s of __reg.eventSubs ?? []) subscribeEvent(s.source, s.handler);
113
+ await initEngines();
114
+ if (!PASSIVE) syncVendorRepos().catch(err => console.warn('[vendor-sync] error:', (err as Error).message));
115
+ seedDefaults();
116
+ const purged = purgeExpiredSkills();
117
+ if (purged.length) console.log(`[skills] Purged ${purged.length} expired skill(s): ${purged.join(', ')}`);
118
+ for (const w of lintSkills()) console.warn(`[skills] lint: ${w}`);
119
+ hydrateSlackUserToken();
120
+
121
+ // Seed operator contacts from whitelist
122
+ try {
123
+ const wl = JSON.parse(readFileSync(dataPath('whitelist.json'), 'utf-8'));
124
+ if (Array.isArray(wl)) seedOperators(wl);
125
+ } catch (err) { console.warn('[contacts] Could not seed operators:', (err as Error).message); }
126
+
127
+ // Backfill visibleTo on legacy bot sessions (idempotent — skips already-patched)
128
+ backfillSessionVisibility(({ name }) => {
129
+ if (!name) return null;
130
+ const lower = name.toLowerCase();
131
+ return getAllContacts().find((c) => c.name.toLowerCase() === lower) ?? null;
132
+ });
133
+
134
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
135
+ const distPath = path.resolve(__dirname, '../../dist/client');
136
+
137
+ const app = express();
138
+ app.use(express.json({
139
+ limit: '20mb',
140
+ verify: (req, _res, buf) => { (req as any).rawBody = buf; },
141
+ }));
142
+ // Slack interactivity posts application/x-www-form-urlencoded; capture rawBody for signature verification.
143
+ app.use(express.urlencoded({
144
+ extended: true,
145
+ limit: '5mb',
146
+ verify: (req, _res, buf) => { (req as any).rawBody = buf; },
147
+ }));
148
+
149
+ app.use((req, _res, next) => {
150
+ const start = Date.now();
151
+ const orig = _res.end.bind(_res);
152
+ (_res as any).end = (...args: any[]) => {
153
+ console.log(`[http] ${req.method} ${req.url} → ${_res.statusCode} (${Date.now() - start}ms)`);
154
+ return orig(...args);
155
+ };
156
+ next();
157
+ });
158
+
159
+ const SERVER_BUILD_ID = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
160
+
161
+ // ── REST routes ───────────────────────────────────────────────────────────────
162
+
163
+ app.get('/api/version', (_req, res) => {
164
+ try {
165
+ const pkg = JSON.parse(readFileSync(path.resolve(__dirname, '../../package.json'), 'utf8'));
166
+ res.json({ version: pkg.version });
167
+ } catch { res.json({ version: 'unknown' }); }
168
+ });
169
+
170
+ // Cached host stats — returns the in-memory ring buffer (does NOT sample on request).
171
+ app.get('/api/stats', requireAuth, (_req, res) => {
172
+ res.json({ samples: statsSampler.getStats() });
173
+ });
174
+
175
+ app.get('/api/sessions', requireAuth, async (req, res) => {
176
+ const user = (req as any).user;
177
+ // Exclude PTY-only sessions — a standalone/terminal-first shell is not a conversation.
178
+ res.json(getSessionsVisibleTo(user.uid, user.isOwner, user.email).filter((s) => s.kind !== 'terminal'));
179
+ });
180
+
181
+ app.get('/api/sessions/:id/meta', requireAuth, async (req, res) => {
182
+ const user = (req as any).user;
183
+ const meta = getSession(String(req.params.id));
184
+ if (!meta) return res.status(404).json({ error: 'not found' });
185
+ if (!isSessionVisibleTo(meta, user.uid, user.isOwner, user.email)) return res.status(404).json({ error: 'not found' });
186
+ res.json(meta);
187
+ });
188
+
189
+ // Per-session runtime directives (engine/model/turns/thinking). The Agent Config panel writes these
190
+ // for the active session so a change applies to THIS conversation — session directives shadow the
191
+ // global agent-config at send time (see claude.ts), so editing the global config alone never affects
192
+ // an already-started session.
193
+ app.put('/api/sessions/:id/directives', requireAuth, (req, res) => {
194
+ const user = (req as any).user;
195
+ const sid = String(req.params.id);
196
+ const meta = getSession(sid);
197
+ if (!meta) return void res.status(404).json({ error: 'not found' });
198
+ if (!isSessionVisibleTo(meta, user.uid, user.isOwner, user.email)) return void res.status(404).json({ error: 'not found' });
199
+ // thinking is an untrusted request field; type it to the valid set (invalid strings fall through
200
+ // the `|| undefined` below). voiceModel/thinkModel are add-on passthrough keys the core doesn't name.
201
+ const body = (req.body ?? {}) as { engine?: string; model?: string; turns?: number; thinking?: 'enabled' | 'adaptive' | 'disabled'; voiceModel?: string; thinkModel?: string | false };
202
+ const next = {
203
+ ...meta.directives,
204
+ ...(body.engine !== undefined ? { engine: body.engine || undefined } : {}),
205
+ ...(body.model !== undefined ? { model: body.model || undefined } : {}),
206
+ ...(body.turns !== undefined ? { turns: body.turns } : {}),
207
+ ...(body.thinking !== undefined ? { thinking: body.thinking || undefined } : {}),
208
+ ...(body.voiceModel !== undefined ? { voiceModel: body.voiceModel || undefined } : {}),
209
+ // thinkModel: false = explicitly off (Think tier disabled); '' = unset → fall back to default.
210
+ ...(body.thinkModel !== undefined ? { thinkModel: body.thinkModel === false ? false : (body.thinkModel || undefined) } : {}),
211
+ };
212
+ setSessionDirectives(sid, next);
213
+ res.json({ directives: next });
214
+ });
215
+
216
+ app.get('/api/sessions/:id/messages', requireAuth, async (req, res) => {
217
+ const sid = String(req.params.id);
218
+ console.log(`[http] loading messages for session ${sid.slice(0, 8)}…`);
219
+ const session = getSession(sid);
220
+ const conv = loadConversation(sid);
221
+ if (conv.length > 0) {
222
+ const partial = readLivePartial(sid) ?? readPartial(sid);
223
+ if (partial?.length) {
224
+ conv.push({ id: `partial-${sid}`, role: 'assistant', blocks: partial, ts: Date.now() });
225
+ console.log(`[http] loaded ${conv.length} messages (incl. partial) from own store for ${sid.slice(0, 8)}`);
226
+ } else {
227
+ console.log(`[http] loaded ${conv.length} messages from own store for ${sid.slice(0, 8)}`);
228
+ }
229
+ const senders = new Set(conv.filter(m => m.role === 'user' && m.senderName).map(m => m.senderName));
230
+ if (session?.userName) senders.add(session.userName);
231
+ return res.json({ format: 'conv', messages: conv, busy: isSessionBusy(sid), participants: [...senders] });
232
+ }
233
+ const messages = await getSessionHistory(sid);
234
+ console.log(`[http] loaded ${messages.length} messages from Claude JSONL for ${sid.slice(0, 8)}`);
235
+ res.json({ format: 'jsonl', messages, busy: isSessionBusy(sid) });
236
+ });
237
+
238
+ app.post('/api/sessions/:id/fork', requireAuth, (req, res) => {
239
+ const user = (req as any).user as import('./auth.ts').AuthUser;
240
+ const sourceId = String(req.params.id);
241
+ const source = getSession(sourceId);
242
+ if (!source) return void res.status(404).json({ error: 'not found' });
243
+ if (!isSessionVisibleTo(source, user.uid, user.isOwner, user.email)) return void res.status(404).json({ error: 'not found' });
244
+ const { truncateAtIndex } = req.body as { truncateAtIndex?: number };
245
+ const newId = forkSession(sourceId, { uid: user.uid, email: user.email, name: user.email.split('@')[0] }, truncateAtIndex);
246
+ if (!newId) return void res.status(400).json({ error: 'nothing to fork' });
247
+ console.log(`[http] forked session ${sourceId.slice(0, 8)} → ${newId.slice(0, 8)} for ${user.email}`);
248
+ res.json({ sessionId: newId });
249
+ });
250
+
251
+ app.post('/api/sessions/:id/push', requireAuth, (req, res) => {
252
+ const sid = String(req.params.id);
253
+ const meta = getSession(sid);
254
+ if (!meta) return void res.status(404).json({ error: 'not found' });
255
+ const { message, source } = req.body as { message?: string; source?: 'proactive' | 'schedule' };
256
+ if (!message) return void res.status(400).json({ error: 'message required' });
257
+ appendMessage(sid, { id: crypto.randomUUID(), role: 'assistant', blocks: [{ type: 'text', text: message }], ts: Date.now() });
258
+ upsertSession(sid, meta.title, { uid: meta.uid, email: meta.userEmail });
259
+ notifyUnread(meta.uid, sid, message.slice(0, 120), source || 'proactive', meta.title);
260
+ broadcast({ type: 'session_messages_changed', sessionId: sid });
261
+ console.log(`[http] pushed message to ${sid.slice(0, 8)} for ${meta.userEmail}`);
262
+ res.json({ ok: true });
263
+ });
264
+
265
+ app.get('/api/mcps', requireAuth, (req, res) => {
266
+ const user = (req as any).user;
267
+ const globalNames = new Set(Object.keys(getGlobalMcpConfig()));
268
+ const resolved = maskEnvValues(getResolvedMcpConfig(user.uid));
269
+ const entries: Record<string, McpConfig[string] & { readonly?: boolean }> = {};
270
+ for (const [name, config] of Object.entries(resolved)) {
271
+ entries[name] = { ...config, readonly: globalNames.has(name) };
272
+ }
273
+ res.json(entries);
274
+ });
275
+
276
+ app.put('/api/mcps', requireAuth, (req, res) => {
277
+ const user = (req as any).user;
278
+ const globalNames = new Set(Object.keys(getGlobalMcpConfig()));
279
+ const incoming = req.body as McpConfig;
280
+ const userOnly: McpConfig = {};
281
+ for (const [name, config] of Object.entries(incoming)) {
282
+ if (!globalNames.has(name)) userOnly[name] = config;
283
+ }
284
+ const original = getRawMcpConfig(user.uid);
285
+ const merged = mergeWithOriginal(userOnly, original);
286
+ saveMcpConfig(user.uid, merged);
287
+ res.json({ ok: true });
288
+ });
289
+
290
+ app.get('/api/config', requireAuth, (_req, res) => {
291
+ res.json(getAgentConfig());
292
+ });
293
+
294
+ app.put('/api/config', requireAuth, (req, res) => {
295
+ saveAgentConfig(req.body as AgentConfig);
296
+ res.json({ ok: true });
297
+ });
298
+
299
+ app.get('/api/engines', requireAuth, (_req, res) => {
300
+ const engines = getAvailableEngines();
301
+ const result = engines.map(name => {
302
+ const engine = getEngine(name);
303
+ return { name, models: engine.getModels() };
304
+ });
305
+ res.json({ engines: result, multiEngine: engines.length > 1 });
306
+ });
307
+
308
+
309
+ app.get('/api/skills', requireAuth, (_req, res) => {
310
+ res.json({ skills: [...listSkills(), ...listMcpCommands(), 'compact'], builtins: getBuiltinSkillNames() });
311
+ });
312
+
313
+ app.get('/api/skills/:name', requireAuth, (req, res) => {
314
+ const skill = getSkill(String(req.params.name));
315
+ if (!skill) return res.status(404).json({ error: 'Not found' });
316
+ res.json(skill);
317
+ });
318
+
319
+ app.put('/api/skills/:name', requireAuth, (req, res) => {
320
+ try {
321
+ const { content } = req.body as { content: string };
322
+ saveSkill(String(req.params.name), content ?? '');
323
+ res.json({ ok: true });
324
+ } catch (e: any) { res.status(400).json({ error: e.message }); }
325
+ });
326
+
327
+ app.delete('/api/skills/:name', requireAuth, (req, res) => {
328
+ try {
329
+ deleteSkill(String(req.params.name));
330
+ res.json({ ok: true });
331
+ } catch (e: any) { res.status(400).json({ error: e.message }); }
332
+ });
333
+
334
+ app.post('/api/skills/:name/duplicate', requireAuth, (req, res) => {
335
+ try {
336
+ const { newName } = req.body as { newName: string };
337
+ const skill = duplicateSkill(String(req.params.name), newName);
338
+ res.json(skill);
339
+ } catch (e: any) { res.status(400).json({ error: e.message }); }
340
+ });
341
+
342
+ app.post('/api/skills/:name/rename', requireAuth, (req, res) => {
343
+ try {
344
+ const { newName } = req.body as { newName: string };
345
+ renameSkill(String(req.params.name), newName);
346
+ res.json({ ok: true });
347
+ } catch (e: any) { res.status(400).json({ error: e.message }); }
348
+ });
349
+
350
+ app.get('/api/skills-defaults', requireAuth, (_req, res) => {
351
+ res.json(getDefaultSkills());
352
+ });
353
+
354
+ app.put('/api/skills-defaults', requireAuth, (req, res) => {
355
+ setDefaultSkills(req.body);
356
+ res.json({ ok: true });
357
+ });
358
+
359
+ // ── Schedules ────────────────────────────────────────────────────────────────
360
+
361
+ function scheduleIfVisible(id: string, uid: string, isOwner = false): Schedule | undefined {
362
+ const s = scheduler.getSchedule(id);
363
+ if (!s) return undefined;
364
+ if (isOwner || s.scope === 'system' || s.createdBy.uid === uid) return s;
365
+ return undefined;
366
+ }
367
+
368
+ app.get('/api/schedules', requireAuth, (req, res) => {
369
+ const user = (req as any).user;
370
+ const schedules = scheduler.listSchedules().filter((s) => user.isOwner || s.scope === 'system' || s.createdBy.uid === user.uid);
371
+ const runningIds = scheduler.getRunningIds();
372
+ res.json({ schedules, runningIds });
373
+ });
374
+
375
+ app.get('/api/schedules/:id', requireAuth, (req, res) => {
376
+ const user = (req as any).user;
377
+ const s = scheduleIfVisible(String(req.params.id), user.uid, user.isOwner);
378
+ if (!s) return res.status(404).json({ error: 'Not found' });
379
+ res.json(s);
380
+ });
381
+
382
+ app.post('/api/schedules', requireAuth, (req, res) => {
383
+ const user = (req as any).user;
384
+ const body = req.body as Partial<Schedule>;
385
+ const now = Date.now();
386
+ const schedule: Schedule = {
387
+ id: crypto.randomUUID(),
388
+ name: body.name || 'Untitled schedule',
389
+ enabled: body.enabled ?? true,
390
+ trigger: body.trigger as Schedule['trigger'],
391
+ task: body.task as Schedule['task'],
392
+ scope: 'user',
393
+ createdBy: { uid: user.uid, email: user.email },
394
+ createdAt: now,
395
+ updatedAt: now,
396
+ runCount: 0,
397
+ };
398
+ const result = scheduler.upsertSchedule(schedule);
399
+ if (!result.ok) return res.status(400).json({ error: result.error });
400
+ res.json(result.schedule);
401
+ });
402
+
403
+ app.put('/api/schedules/:id', requireAuth, (req, res) => {
404
+ const user = (req as any).user;
405
+ const id = String(req.params.id);
406
+ const existing = scheduleIfVisible(id, user.uid, user.isOwner);
407
+ if (!existing) return res.status(404).json({ error: 'Not found' });
408
+ if (existing.createdBy.uid !== user.uid && !user.isOwner) return res.status(403).json({ error: 'Only the owner can edit this schedule' });
409
+ const body = req.body as Partial<Schedule>;
410
+ const updated: Schedule = {
411
+ ...existing,
412
+ name: body.name ?? existing.name,
413
+ enabled: body.enabled ?? existing.enabled,
414
+ trigger: (body.trigger ?? existing.trigger) as Schedule['trigger'],
415
+ task: (body.task ?? existing.task) as Schedule['task'],
416
+ };
417
+ const result = scheduler.upsertSchedule(updated);
418
+ if (!result.ok) return res.status(400).json({ error: result.error });
419
+ res.json(result.schedule);
420
+ });
421
+
422
+ app.delete('/api/schedules/:id', requireAuth, (req, res) => {
423
+ const user = (req as any).user;
424
+ const id = String(req.params.id);
425
+ const existing = scheduleIfVisible(id, user.uid, user.isOwner);
426
+ if (!existing) return res.status(404).json({ error: 'Not found' });
427
+ if (existing.createdBy.uid !== user.uid && !user.isOwner) return res.status(403).json({ error: 'Only the owner can delete this schedule' });
428
+ const ok = scheduler.deleteSchedule(id);
429
+ if (!ok) return res.status(404).json({ error: 'Not found' });
430
+ res.json({ ok: true });
431
+ });
432
+
433
+ app.post('/api/schedules/:id/toggle', requireAuth, (req, res) => {
434
+ const user = (req as any).user;
435
+ const id = String(req.params.id);
436
+ if (!scheduleIfVisible(id, user.uid, user.isOwner)) return res.status(404).json({ error: 'Not found' });
437
+ const s = scheduler.toggleSchedule(id, !!req.body.enabled);
438
+ if (!s) return res.status(404).json({ error: 'Not found' });
439
+ res.json(s);
440
+ });
441
+
442
+ app.post('/api/schedules/:id/run', requireAuth, (req, res) => {
443
+ const user = (req as any).user;
444
+ const id = String(req.params.id);
445
+ if (!scheduleIfVisible(id, user.uid, user.isOwner)) return res.status(404).json({ error: 'Not found' });
446
+ const override = typeof req.body?.override === 'string' ? req.body.override.trim() || undefined : undefined;
447
+ const sessionId = scheduler.runNow(id, override);
448
+ if (!sessionId) return res.status(404).json({ error: 'Not found' });
449
+ res.json({ sessionId });
450
+ });
451
+
452
+ app.post('/api/schedules/:id/cancel', requireAuth, (req, res) => {
453
+ const user = (req as any).user;
454
+ const id = String(req.params.id);
455
+ if (!scheduleIfVisible(id, user.uid, user.isOwner)) return res.status(404).json({ error: 'Not found' });
456
+ const ok = scheduler.cancelRun(id);
457
+ res.json({ ok });
458
+ });
459
+
460
+ app.get('/api/schedules/:id/runs', requireAuth, (req, res) => {
461
+ const user = (req as any).user;
462
+ const id = String(req.params.id);
463
+ if (!scheduleIfVisible(id, user.uid, user.isOwner)) return res.status(404).json({ error: 'Not found' });
464
+ res.json(getSessionsByScheduleId(id));
465
+ });
466
+
467
+ // ── REST chat endpoint (for automation / CLI triggers / agent-to-agent) ──────
468
+ /**
469
+ * Run a single chat turn with all its side effects (session lock, message
470
+ * persistence, run-status, unread notify, broadcast). Shared by the /api/chat
471
+ * route and the MCP streaming handler. Pass `hooks.onEvent` to observe the live
472
+ * agent stream (progress streaming). Returns a discriminated result so callers
473
+ * map it to their own transport (HTTP status / MCP frame).
474
+ */
475
+ type RunChatTurnResult =
476
+ | { status: 'busy' }
477
+ | { sessionId: string; text: string; blocks: ConvBlock[] }
478
+ | { sessionId: string; error: string };
479
+
480
+ async function runChatTurn(
481
+ opts: {
482
+ prompt: string;
483
+ sessionId?: string;
484
+ uid: string;
485
+ userEmail: string;
486
+ userName?: string;
487
+ abortController?: AbortController;
488
+ context?: Record<string, string>;
489
+ },
490
+ hooks?: { onEvent?: (ev: WsEvent) => void },
491
+ ): Promise<RunChatTurnResult> {
492
+ const { prompt, sessionId: reqSid, uid, userEmail } = opts;
493
+ const userName = opts.userName ?? userEmail.split('@')[0];
494
+ const sid = reqSid || `api-${crypto.randomUUID()}`;
495
+ const abortController = opts.abortController ?? new AbortController();
496
+
497
+ if (reqSid && !acquireSessionLock(sid, 'api', abortController)) {
498
+ return { status: 'busy' };
499
+ }
500
+ if (!reqSid) acquireSessionLock(sid, 'api', abortController);
501
+ upsertSession(sid, prompt, { uid, email: userEmail });
502
+ appendMessage(sid, { id: crypto.randomUUID(), role: 'user', blocks: [{ type: 'text', text: prompt }], channel: 'api', senderName: userName });
503
+ setRunStatus(sid, 'running', 'web');
504
+
505
+ try {
506
+ const blocks = await consumeStream(streamChat({
507
+ prompt,
508
+ sessionId: sid,
509
+ uid,
510
+ userEmail,
511
+ userName,
512
+ mcpServers: getMcpConfig(uid),
513
+ abortController,
514
+ context: opts.context ?? { source: 'api', user: userEmail },
515
+ onPermissionRequest: async () => ({ allow: true }),
516
+ }), hooks?.onEvent);
517
+ if (blocks.length) {
518
+ appendMessage(sid, { id: crypto.randomUUID(), role: 'assistant', blocks });
519
+ }
520
+ const text = blocks.filter(b => b.type === 'text').map(b => b.text).join('\n');
521
+ const meta = getSession(sid);
522
+ notifyUnread(uid, sid, text.slice(0, 120) || '(completed)', 'response', meta?.title);
523
+ broadcast({ type: 'session_messages_changed', sessionId: sid });
524
+ return { sessionId: sid, text, blocks };
525
+ } catch (err: any) {
526
+ console.error(`[chat-turn] error:`, err.message);
527
+ return { sessionId: sid, error: err.message };
528
+ } finally {
529
+ if (releaseSessionLock(sid, abortController)) {
530
+ setRunStatus(sid, 'idle');
531
+ }
532
+ }
533
+ }
534
+
535
+ app.post('/api/chat', requireAuth, async (req, res) => {
536
+ const user = (req as any).user as import('./auth.ts').AuthUser;
537
+ const { prompt, sessionId: reqSid, callbackUrl, sync, clientRequestId } = req.body as {
538
+ prompt?: string; sessionId?: string; callbackUrl?: string; sync?: boolean; clientRequestId?: string;
539
+ };
540
+ if (!prompt) return void res.status(400).json({ error: 'prompt required' });
541
+ if (callbackUrl) {
542
+ try { const u = new URL(callbackUrl); if (!['http:', 'https:'].includes(u.protocol)) throw 0; }
543
+ catch { return void res.status(400).json({ error: 'callbackUrl must be a valid HTTP(S) URL' }); }
544
+ }
545
+
546
+ // Idempotency: a retried submit with the same key reuses the session that first
547
+ // handled it (within TTL) instead of spawning a duplicate.
548
+ const idemKey = clientRequestId || (req.get('idempotency-key') || undefined);
549
+ if (idemKey) {
550
+ const existing = lookupIdempotent(user.uid, idemKey);
551
+ if (existing) return void res.json({ sessionId: existing, status: 'duplicate' });
552
+ }
553
+
554
+ const sid = reqSid || `api-${crypto.randomUUID()}`;
555
+ if (idemKey) rememberIdempotent(user.uid, idemKey, sid);
556
+ const apiAbortController = new AbortController();
557
+ const run = () => runChatTurn({
558
+ prompt,
559
+ sessionId: sid,
560
+ uid: user.uid,
561
+ userEmail: user.email,
562
+ abortController: apiAbortController,
563
+ context: { source: 'api', user: user.email },
564
+ });
565
+
566
+ if (sync) {
567
+ const result = await run();
568
+ if ('status' in result) return void res.status(409).json({ error: 'Session is already processing a request' });
569
+ if ('error' in result) return void res.status(500).json(result);
570
+ res.json(result);
571
+ } else {
572
+ // Reject a duplicate before responding 'accepted' (lock is acquired inside run()).
573
+ if (reqSid && isSessionLocked(sid)) {
574
+ return void res.status(409).json({ error: 'Session is already processing a request' });
575
+ }
576
+ res.json({ sessionId: sid, status: 'accepted' });
577
+ const result = await run();
578
+ if (callbackUrl) {
579
+ try {
580
+ await fetch(callbackUrl, {
581
+ method: 'POST',
582
+ headers: { 'Content-Type': 'application/json' },
583
+ body: JSON.stringify(result),
584
+ });
585
+ } catch (err: any) {
586
+ console.error(`[api-chat] callback failed (${callbackUrl}):`, err.message);
587
+ }
588
+ }
589
+ }
590
+ });
591
+
592
+ app.get('/api/workspace', requireAuth, (_req, res) => {
593
+ res.json({ entries: listWorkspaceTree() });
594
+ });
595
+
596
+ app.get('/api/workspace/ls', requireAuth, (req, res) => {
597
+ const dir = String(req.query.path ?? '');
598
+ res.json({ entries: listWorkspaceDir(dir) });
599
+ });
600
+
601
+ app.get('/api/workspace/file', requireAuth, (req, res) => {
602
+ const rel = String(req.query.path ?? '');
603
+ if (!rel) return res.status(400).json({ error: 'path required' });
604
+ const result = readWorkspaceFile(rel);
605
+ if (!result) return res.status(404).json({ error: 'Not found or invalid path' });
606
+ res.json(result);
607
+ });
608
+
609
+ app.get('/api/workspace/raw', requireAuth, (req, res) => {
610
+ const rel = String(req.query.path ?? '');
611
+ if (!rel) return res.status(400).json({ error: 'path required' });
612
+ const resolved = resolveWorkspacePath(rel);
613
+ if (!resolved || !existsSync(resolved)) return res.status(404).json({ error: 'Not found' });
614
+ try { if (!statSync(resolved).isFile()) return res.status(400).json({ error: 'Not a file' }); }
615
+ catch { return res.status(404).json({ error: 'Not found' }); }
616
+ if (req.query.dl) {
617
+ const filename = (rel.split('/').pop() || 'download').replace(/"/g, '\\"');
618
+ res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
619
+ }
620
+ res.sendFile(resolved);
621
+ });
622
+
623
+ app.use('/uploads/shared', express.static(dataPath('uploads/shared'), { dotfiles: 'deny', index: false }));
624
+ app.use('/uploads', requireAuth, express.static(dataPath('uploads'), { dotfiles: 'deny', index: false }));
625
+
626
+ app.post('/api/upload', requireAuth, express.raw({ type: '*/*', limit: '50mb' }), (req, res) => {
627
+ const sid = (req.headers['x-session-id'] as string) || 'shared';
628
+ const uploadsDir = dataPath(`uploads/${sid}`);
629
+ mkdirSync(uploadsDir, { recursive: true });
630
+ const raw = (req.headers['x-filename'] as string) || 'upload';
631
+ const safeName = path.basename(decodeURIComponent(raw));
632
+ const id = crypto.randomUUID().slice(0, 8);
633
+ const filename = `${id}-${safeName}`;
634
+ const dest = path.join(uploadsDir, filename);
635
+ writeFileSync(dest, req.body as Buffer);
636
+ const mimeType = (req.headers['content-type'] as string) || 'application/octet-stream';
637
+ res.json({ url: `/uploads/${sid}/${filename}`, path: dest, name: safeName, mimeType });
638
+ });
639
+
640
+ registerMcpOAuthRoutes(app);
641
+
642
+ app.get('/api/data-sync/log', requireAuth, async (_req, res) => {
643
+ const log = await dataSync.getLog();
644
+ res.json(log);
645
+ });
646
+
647
+ app.use(artifactsRouter);
648
+
649
+ // Runtime feature flags for the client (env-gated, never persisted to agent-config.json).
650
+ // ── Auth mode + local login (PUBLIC — no requireAuth) ────────────────────────
651
+ // The client asks /api/auth/mode to decide which login UI to render (local form vs
652
+ // Firebase Google). Local login/register only exist when AUTH_PROVIDER=local (default).
653
+ app.get('/api/auth/mode', (_req, res) => {
654
+ res.json({ provider: AUTH_PROVIDER, needsSetup: AUTH_PROVIDER === 'local' && localUserCount() === 0 });
655
+ });
656
+ app.post('/api/auth/login', (req, res) => {
657
+ if (AUTH_PROVIDER !== 'local') return void res.status(404).json({ error: 'local auth disabled' });
658
+ const { email, password } = (req.body ?? {}) as { email?: string; password?: string };
659
+ const token = email && password ? localLogin(email, password) : null;
660
+ if (!token) return void res.status(401).json({ error: 'Invalid credentials' });
661
+ res.json({ token, user: { uid: email, email } });
662
+ });
663
+ app.post('/api/auth/register', (req, res) => {
664
+ if (AUTH_PROVIDER !== 'local') return void res.status(404).json({ error: 'local auth disabled' });
665
+ // First-run bootstrap: allow creating the first user; after that require SHRAGA_ALLOW_SIGNUP=1.
666
+ if (localUserCount() > 0 && process.env.SHRAGA_ALLOW_SIGNUP !== '1') return void res.status(403).json({ error: 'Signup disabled' });
667
+ const { email, password } = (req.body ?? {}) as { email?: string; password?: string };
668
+ if (!email || !password) return void res.status(400).json({ error: 'email + password required' });
669
+ try { addLocalUser(email, password); } catch (e: any) { return void res.status(409).json({ error: e.message }); }
670
+ res.json({ token: localLogin(email, password), user: { uid: email, email } });
671
+ });
672
+
673
+ // Feature gates. Add-on surfaces ship OFF; enable per-deployment via SHRAGA_FEAT_* env
674
+ // (an optional add-on / downstream distribution sets them on). Single source of truth for the web UI.
675
+ const featEnabled = (k: string, def = false): boolean => {
676
+ const v = process.env[`SHRAGA_FEAT_${k}`];
677
+ return v === undefined ? def : v === '1' || v === 'true';
678
+ };
679
+ app.get('/api/features', requireAuth, (_req, res) => {
680
+ // Core flags (SHRAGA_FEAT_* env), then merge feature-contributed flags OVER them. The core names no
681
+ // add-on surface: add-on features declare their own capability flags through the seam (collectFeatureFlags).
682
+ res.json({
683
+ push: pushEnabled(),
684
+ workspace: featEnabled('WORKSPACE'), // multi-tab workspace (FlexLayout). Default = chat-only.
685
+ instances: featEnabled('INSTANCES'), // multi-instance (fleet) switcher
686
+ ...collectFeatureFlags(), // add-on surfaces declare their own flags here.
687
+ });
688
+ });
689
+
690
+ // ── Remote push (native appwrap wrappers register device tokens here) ──────────
691
+ // Gated by PUSH_ENABLED + provider creds; register is a no-op-OK when disabled.
692
+ app.post('/api/push/register', requireAuth, (req, res) => {
693
+ const uid = (req as any).user.uid as string;
694
+ const { token, platform, topic } = (req.body || {}) as { token?: string; platform?: string; topic?: string };
695
+ if (!pushEnabled()) return void res.json({ ok: true, enabled: false });
696
+ if (!token || (platform !== 'apns' && platform !== 'fcm')) {
697
+ return void res.status(400).json({ error: 'token + platform(apns|fcm) required' });
698
+ }
699
+ upsertToken(uid, token, platform, topic);
700
+ res.json({ ok: true });
701
+ });
702
+ app.post('/api/push/unregister', requireAuth, (req, res) => {
703
+ const uid = (req as any).user.uid as string;
704
+ const { token } = (req.body || {}) as { token?: string };
705
+ if (token) removeToken(uid, token);
706
+ res.json({ ok: true });
707
+ });
708
+
709
+ // ── API Keys ──────────────────────────────────────────────────────────────────
710
+ app.get('/api/api-keys', requireAuth, (req, res) => {
711
+ res.json({ keys: listApiKeys() });
712
+ });
713
+ app.post('/api/api-keys', requireAuth, (req, res) => {
714
+ const user = (req as any).user as import('./auth.ts').AuthUser;
715
+ const { label } = req.body as { label?: string };
716
+ const key = createApiKey(user.uid, user.email, label || 'Unnamed');
717
+ res.json(key);
718
+ });
719
+ app.delete('/api/api-keys/:id', requireAuth, (req: express.Request<{ id: string }>, res) => {
720
+ const user = (req as any).user as import('./auth.ts').AuthUser;
721
+ const ok = deleteApiKey(req.params.id, user.uid, user.isOwner);
722
+ if (ok === 'not_found') return void res.status(404).json({ error: 'Key not found' });
723
+ if (ok === 'forbidden') return void res.status(403).json({ error: 'Cannot delete another user\'s key' });
724
+ res.json({ ok: true });
725
+ });
726
+
727
+ // ── MCP Server ────────────────────────────────────────────────────────────────
728
+ mountMcpServer(app, { runChatTurn });
729
+
730
+ // Mount deployment drop-in routes from data/extensions/*.ext.ts (hot-reload, before catch-all).
731
+ const { loadExtensions, registerExtension } = await import('./extensions.ts');
732
+ // Programmatic extensions funnel through the SAME router+ctx as file-based *.ext.ts drop-ins
733
+ // (mounted before the SPA catch-all). Queue them before loadExtensions so it flushes them.
734
+ for (const fn of __reg.extensions ?? []) await registerExtension(fn);
735
+ await loadExtensions(app);
736
+
737
+ if (existsSync(distPath)) app.use(express.static(distPath));
738
+
739
+ // The SPA catch-all (`app.get('*')`) is registered LATER — after mountFeatures() — via
740
+ // registerSpaCatchAll(), so feature/extension GET routes are matched before falling through to
741
+ // index.html. It is re-registered on passive→active promotion (mountFeatures runs again there),
742
+ // each call splicing out the prior catch-all layer so it stays truly LAST in the router stack.
743
+ // In dev (no dist/) it is skipped entirely — Vite serves the SPA and a catch-all would 404-shadow it.
744
+ // Implementation + predicate live in ./spa-catchall.ts (unit-tested there).
745
+
746
+ // ── WebSocket + Server ───────────────────────────────────────────────────────
747
+
748
+ const server = createServer(app);
749
+
750
+ // ── WebSocket ────────────────────────────────────────────────────────────────
751
+
752
+ const wss = new WebSocketServer({ noServer: true });
753
+
754
+ interface WsSession {
755
+ uid: string;
756
+ email: string;
757
+ busySessions: Set<string>;
758
+ autoApprove: boolean;
759
+ abortControllers: Map<string, AbortController>;
760
+ pendingPermissions: Map<string, { resolve: (result: { allow: boolean }) => void; destructive?: boolean; tool?: string; input?: unknown; sessionId?: string }>;
761
+ pendingQuestions: Map<string, { resolve: (answers: QuestionAnswers | null) => void }>;
762
+ steerPending: Map<string, string>;
763
+ lastSessionId: string | null;
764
+ viewingSessionId: string | null;
765
+ focused: boolean;
766
+ }
767
+
768
+ function isUserViewingSession(uid: string, sessionId: string, excludeWs?: WebSocket): boolean {
769
+ for (const [ws, s] of activeConnections) {
770
+ if (ws === excludeWs) continue;
771
+ if (s.uid === uid && s.viewingSessionId === sessionId && s.focused) return true;
772
+ }
773
+ return false;
774
+ }
775
+
776
+ function isUserConnected(uid: string): boolean {
777
+ for (const [, s] of activeConnections) {
778
+ if (s.uid === uid) return true;
779
+ }
780
+ return false;
781
+ }
782
+
783
+ function notifyUnread(uid: string, sessionId: string, preview: string, source: 'response' | 'proactive' | 'schedule', title?: string, senderWs?: WebSocket) {
784
+ if (senderWs) {
785
+ const senderSession = activeConnections.get(senderWs);
786
+ if (senderSession?.viewingSessionId === sessionId && senderSession.focused) return;
787
+ }
788
+ if (isUserViewingSession(uid, sessionId)) return;
789
+ const entry = addUnread(uid, sessionId, preview, source, title);
790
+ console.log(`[unread] ${source} notification for ${uid.slice(0, 8)} session=${sessionId.slice(0, 8)} count=${entry.count}`);
791
+ for (const [ws, s] of activeConnections) {
792
+ if (s.uid === uid) {
793
+ send(ws, { type: 'unread', sessionId, count: entry.count, preview, source, title });
794
+ }
795
+ }
796
+ }
797
+
798
+ function sendUnreadSync(ws: WebSocket, uid: string) {
799
+ const unreads = getUnreads(uid);
800
+ if (Object.keys(unreads.sessions).length > 0) {
801
+ send(ws, { type: 'unread_sync', sessions: unreads.sessions });
802
+ }
803
+ }
804
+
805
+ // ── Sidecar WebSocket proxy ─────────────────────────────────────────────────
806
+
807
+ // Core sidecar WS proxy routes (url-prefix → localhost port). Feature-contributed routes
808
+ // (an add-on's prefix → its own daemon port) are folded in after feature registration below.
809
+ const WS_PROXY_ROUTES: Record<string, number> = { cursor: 3845 };
810
+
811
+ function resolveSidecarPort(urlPath: string): number | null {
812
+ const prefix = urlPath.split('/').filter(Boolean)[0];
813
+ return prefix ? WS_PROXY_ROUTES[prefix] ?? null : null;
814
+ }
815
+
816
+ const sidecarWss = new WebSocketServer({ noServer: true });
817
+
818
+ function proxySidecarWebSocket(req: import('node:http').IncomingMessage, socket: import('node:stream').Duplex, head: Buffer, port: number) {
819
+ const targetUrl = `ws://127.0.0.1:${port}${req.url}`;
820
+ sidecarWss.handleUpgrade(req, socket as any, head, (clientWs) => {
821
+ const targetWs = new WebSocket(targetUrl);
822
+ let opened = false;
823
+
824
+ targetWs.on('open', () => {
825
+ opened = true;
826
+ clientWs.on('message', (data, isBinary) => {
827
+ // App-level liveness probe (Layer 2): the client can't read protocol pongs from JS and the daemon
828
+ // doesn't speak ping, so answer `{type:'ping'}` here without forwarding. Lets the browser detect a
829
+ // half-open socket the server-side terminate can't reach (broken path) and force a reconnect.
830
+ // Cheap prefilter (small + contains "ping") so we don't JSON-parse every keystroke/paste frame.
831
+ if (!isBinary && (data as Buffer).length < 64) {
832
+ const s = data.toString();
833
+ if (s.includes('"ping"')) {
834
+ try {
835
+ if (JSON.parse(s).type === 'ping') {
836
+ if (clientWs.readyState === WebSocket.OPEN) clientWs.send(JSON.stringify({ type: 'pong' }));
837
+ return;
838
+ }
839
+ } catch { /* not JSON — fall through to relay */ }
840
+ }
841
+ }
842
+ if (targetWs.readyState === WebSocket.OPEN) targetWs.send(data, { binary: isBinary });
843
+ });
844
+ targetWs.on('message', (data, isBinary) => {
845
+ if (clientWs.readyState === WebSocket.OPEN) clientWs.send(data, { binary: isBinary });
846
+ });
847
+ });
848
+
849
+ // Keepalive: a proxied sidecar socket carries no app-level heartbeat, so an idle WS gets silently dropped
850
+ // by an intermediary (Cloudflare tunnel idles WS at ~100s) leaving the BROWSER half-open — readyState
851
+ // stays OPEN, no onclose fires, the "connected" dot stays green and keystrokes vanish into a dead pipe.
852
+ // Ping the client (browsers auto-pong at the protocol level) to keep intermediaries from idling us out,
853
+ // and terminate a peer that misses a pong so the client gets a real close → its reconnect kicks in.
854
+ // Tolerate ONE missed pong before terminating (~2 intervals of grace): a backgrounded mobile tab is
855
+ // JS/network-frozen and can't auto-pong for a cycle, so a 1-strike policy force-closed it every 30s and
856
+ // churned reconnects. Two strikes lets a brief freeze ride through; a truly dead pipe still gets cut.
857
+ let missedPongs = 0;
858
+ clientWs.on('pong', () => { missedPongs = 0; });
859
+ const pingInterval = setInterval(() => {
860
+ if (clientWs.readyState !== WebSocket.OPEN) return;
861
+ if (missedPongs >= 2) { console.warn('[ws-proxy] client missed pongs — terminating (likely backgrounded/frozen client)'); clientWs.terminate(); return; }
862
+ missedPongs++;
863
+ clientWs.ping();
864
+ }, WS_PING_INTERVAL);
865
+
866
+ targetWs.on('close', () => { clearInterval(pingInterval); clientWs.close(); });
867
+ targetWs.on('error', (e) => {
868
+ // Pre-open failure = the sidecar daemon is unreachable (e.g. it idle-exited, or is not up yet
869
+ // after a restart). This is TRANSIENT: the daemon (and its shells) survive a server/proxy blip, and
870
+ // we revive it right below — so flag `fatal:false`. The client must keep the pane alive and re-attach
871
+ // (a mobile client that backgrounded for minutes recovers its still-running sidecar on resume), NOT show a
872
+ // permanent "session unavailable". Only the daemon's own `session not found` (post-open) is fatal.
873
+ if (!opened) {
874
+ if (clientWs.readyState === WebSocket.OPEN) {
875
+ try { clientWs.send(JSON.stringify({ type: 'error', message: 'sidecar daemon unavailable', fatal: false })); } catch { /* socket gone */ }
876
+ }
877
+ } else {
878
+ console.warn('[ws-proxy] target error:', e.message);
879
+ }
880
+ clientWs.close();
881
+ });
882
+ clientWs.on('close', () => { clearInterval(pingInterval); if (targetWs.readyState === WebSocket.OPEN) targetWs.close(); });
883
+ clientWs.on('error', (e) => { console.error(`[ws-proxy] client error:`, e.message); targetWs.close(); });
884
+ });
885
+ }
886
+
887
+ server.on('upgrade', (req, socket, head) => {
888
+ if (req.url === '/ws') {
889
+ wss.handleUpgrade(req, socket as any, head, (ws) => {
890
+ const session: WsSession = { uid: '', email: '', busySessions: new Set(), autoApprove: false, abortControllers: new Map(), pendingPermissions: new Map(), pendingQuestions: new Map(), steerPending: new Map(), lastSessionId: null, viewingSessionId: null, focused: true };
891
+ handleConnection(ws, session);
892
+ });
893
+ } else {
894
+ const port = req.url ? resolveSidecarPort(req.url) : null;
895
+ if (port) {
896
+ proxySidecarWebSocket(req, socket, head, port);
897
+ } else {
898
+ socket.destroy();
899
+ }
900
+ }
901
+ });
902
+
903
+ function send(ws: WebSocket, data: object) {
904
+ if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(data));
905
+ }
906
+
907
+ const DESTRUCTIVE_PERMISSION_TTL = 10 * 60_000;
908
+ const WS_PING_INTERVAL = 30_000;
909
+ const activeConnections = new Map<WebSocket, WsSession>();
910
+ const globalPendingPermissions = new Map<string, { resolve: (r: { allow: boolean }) => void; sessionId: string; tool: string; input: unknown; uid: string }>();
911
+ function isSessionBusy(sid: string): boolean {
912
+ return isSessionLocked(sid);
913
+ }
914
+ function broadcast(data: object, exclude?: WebSocket) {
915
+ for (const ws of activeConnections.keys()) {
916
+ if (ws !== exclude) send(ws, data);
917
+ }
918
+ }
919
+
920
+ setBroadcaster(broadcast); // let session-bus push async events (e.g. an add-on's background re-voice) to clients
921
+ ensureWorkspaceDir();
922
+ watchWorkspace((event) => broadcast({ type: 'workspace_change', ...event }));
923
+ if (!PASSIVE) { scheduler.start(broadcast); startEventDispatcher(); }
924
+ // Host telemetry is read-only (no persistence, unref'd timer) — not a writer or consumer, so it
925
+ // runs in passive too. Otherwise a standby instance reports empty stats and /api/stats is a lie.
926
+ statsSampler.start(broadcast);
927
+ registerEventRoutes(app, requireAuth);
928
+ initPolls({
929
+ broadcast,
930
+ runTurn: ({ prompt, sessionId, uid, userEmail }) =>
931
+ consumeStream(streamChat({ prompt, sessionId, uid, userEmail, mcpServers: getMcpConfig(uid), abortController: new AbortController(), onPermissionRequest: async () => ({ allow: true }) })),
932
+ });
933
+ // Remote-push triggers: subscribe to schedule.finished and expose turn-done/question
934
+ // hooks. isForeground reuses the existing presence tracking (see isUserViewingSession).
935
+ initPushTriggers({
936
+ origin: process.env.PUBLIC_ORIGIN || '',
937
+ isForeground: (uid, sessionId) => isUserViewingSession(uid, sessionId),
938
+ });
939
+ // Optional add-ons (voice, github, gmail, fleet, …) mount here through the feature seam.
940
+ // The core registers none; an optional add-on calls registerFeature(...) before startup.
941
+ // SHRAGA_OVERLAY points at an external add-on module (outside the core tree) that imports
942
+ // features.ts and registerFeature(...)s its add-ons at import time. Guarded so a missing/broken
943
+ // add-on logs and never crashes the core.
944
+ if (process.env.SHRAGA_OVERLAY) {
945
+ try {
946
+ // Resolve relative to CWD (not this module) so a path like ../my-extensions/index.ts works as typed.
947
+ const overlaySpec = path.isAbsolute(process.env.SHRAGA_OVERLAY)
948
+ ? process.env.SHRAGA_OVERLAY
949
+ : path.resolve(process.cwd(), process.env.SHRAGA_OVERLAY);
950
+ await import(overlaySpec);
951
+ console.log(`[overlay] loaded ${process.env.SHRAGA_OVERLAY}`);
952
+ } catch (err) {
953
+ console.error(`[overlay] failed to load ${process.env.SHRAGA_OVERLAY}:`, (err as Error)?.stack || err);
954
+ }
955
+ }
956
+ // Slack ships in this app — register it through the same feature seam add-ons use.
957
+ // Programmatic features register through the same seam an overlay uses — BEFORE mountFeatures()
958
+ // so their routes mount ahead of the SPA fallback, identical to the overlay path.
959
+ for (const f of __reg.features ?? []) registerFeature(f);
960
+ registerFeature(slackFeature);
961
+ mountFeatures({ app, requireAuth, broadcast, passive: PASSIVE });
962
+ // Fold in feature-contributed sidecar WS proxy routes (the core names none; each add-on adds its own).
963
+ Object.assign(WS_PROXY_ROUTES, collectSidecarRoutes());
964
+
965
+ // SPA fallback — MUST be the last GET route so it never shadows real API/feature/extension routes.
966
+ registerSpaCatchAll(app, distPath);
967
+
968
+ // ── Runtime promotion (blue-green flip) ──────────────────────────────────────
969
+ // A passive instance can be promoted to active once traffic has been flipped to it:
970
+ // starts every consumer/writer that passive boot skipped. One-way; idempotent-guarded.
971
+ let activated = !PASSIVE;
972
+ async function activateConsumers() {
973
+ activated = true;
974
+ console.log('[server] ACTIVATING — starting consumers and background writers');
975
+ await dataSync.init();
976
+ syncVendorRepos().catch(err => console.warn('[vendor-sync] error:', (err as Error).message));
977
+ scheduler.start(broadcast);
978
+ startEventDispatcher();
979
+ mountFeatures({ app, requireAuth, broadcast, passive: false });
980
+ // Re-place the SPA catch-all AFTER the promotion's feature mount so newly-added GET routes win.
981
+ registerSpaCatchAll(app, distPath);
982
+ startSidecars().catch(err => console.error('[sidecar] startup error:', err));
983
+ recoverInterruptedSessions().catch(err => console.error('[recovery] failed:', err));
984
+ }
985
+ app.post('/internal/activate', async (req, res) => {
986
+ const token = req.headers['x-internal-token'] as string | undefined;
987
+ if (!token || token !== process.env.INTERNAL_API_TOKEN) return res.sendStatus(403);
988
+ if (activated) return res.status(409).json({ error: 'already active' });
989
+ await activateConsumers();
990
+ res.json({ ok: true });
991
+ });
992
+
993
+ app.post('/api/data-sync/webhook', async (req, res) => {
994
+ if (!activated || !dataSync.isEnabled()) return res.sendStatus(404);
995
+ const secret = process.env.DATA_SYNC_WEBHOOK_SECRET;
996
+ if (secret && req.headers['x-webhook-secret'] !== secret) return res.sendStatus(403);
997
+ await dataSync.pull();
998
+ res.sendStatus(200);
999
+ });
1000
+
1001
+ async function runStream(ws: WebSocket, session: WsSession, sid: string, promptText: string, attachments: AttachmentMeta[] | undefined, mcpServers: McpConfig, isSteerRestart = false, voiceMode = false, conversationReset = false, turnHints?: Record<string, unknown>) {
1002
+ const abortController = new AbortController();
1003
+ if (!isSteerRestart) {
1004
+ if (!acquireSessionLock(sid, 'web', abortController)) {
1005
+ console.warn(`[ws] Session ${sid.slice(0, 8)} already locked, rejecting`);
1006
+ send(ws, { type: 'error', message: 'Session is already processing a request (from another source)', sessionId: sid });
1007
+ session.busySessions.delete(sid);
1008
+ return;
1009
+ }
1010
+ } else {
1011
+ replaceSessionLock(sid, 'web', abortController);
1012
+ }
1013
+ session.abortControllers.set(sid, abortController);
1014
+ session.steerPending.delete(sid);
1015
+ send(ws, { type: 'session_busy', sessionId: sid, busy: true });
1016
+ broadcast({ type: 'session_busy', sessionId: sid, busy: true }, ws);
1017
+
1018
+ // Voice mode is unattended — nobody is watching the UI to click Allow, so auto-approve for the whole run.
1019
+ const unattended = voiceMode;
1020
+
1021
+ const onPermissionRequest: PermissionHandler = (id, tool, input) => {
1022
+ if (session.autoApprove || unattended) {
1023
+ console.log(`[ws] Auto-approved ${tool} id=${id}${unattended ? ' (voice mode)' : ''}`);
1024
+ return Promise.resolve({ allow: true });
1025
+ }
1026
+ if (ws.readyState !== WebSocket.OPEN) {
1027
+ console.log(`[ws] Auto-approved ${tool} id=${id} (client disconnected)`);
1028
+ return Promise.resolve({ allow: true });
1029
+ }
1030
+ return new Promise<{ allow: boolean }>((resolve) => {
1031
+ session.pendingPermissions.set(id, { resolve });
1032
+ send(ws, { type: 'permission_request', id, tool, input, sessionId: sid });
1033
+ console.log(`[ws] Permission request for ${tool} id=${id}`);
1034
+ });
1035
+ };
1036
+
1037
+ const onUserQuestion: QuestionHandler = (id, questions) => {
1038
+ if (ws.readyState !== WebSocket.OPEN) {
1039
+ console.log(`[ws] Question id=${id} skipped (client disconnected) — agent self-decides`);
1040
+ return Promise.resolve(null);
1041
+ }
1042
+ try { pushQuestion(getSession(sid)?.uid || session.uid, sid); }
1043
+ catch (err) { console.error('[push] question trigger failed:', err); }
1044
+ return new Promise<QuestionAnswers | null>((resolve) => {
1045
+ session.pendingQuestions.set(id, { resolve });
1046
+ send(ws, { type: 'question_request', id, questions, sessionId: sid });
1047
+ console.log(`[ws] Question request id=${id} (${questions.length}q)`);
1048
+ });
1049
+ };
1050
+
1051
+ const isFirstTurn = loadConversation(sid).length === 0;
1052
+ let assistantText = '';
1053
+ let thinkingText = '';
1054
+ const assistantBlocks: ConvBlock[] = [];
1055
+ let saved = false;
1056
+
1057
+ const collectPartialBlocks = () => [
1058
+ ...assistantBlocks,
1059
+ ...(thinkingText ? [{ type: 'thinking' as const, text: thinkingText }] : []),
1060
+ ...(assistantText ? [{ type: 'text' as const, text: assistantText }] : []),
1061
+ ];
1062
+
1063
+ const flushAssistant = () => {
1064
+ if (saved) return;
1065
+ saved = true;
1066
+ clearPartial(sid);
1067
+ if (thinkingText) { assistantBlocks.push({ type: 'thinking', text: thinkingText }); thinkingText = ''; }
1068
+ if (assistantText) assistantBlocks.push({ type: 'text', text: assistantText });
1069
+ if (assistantBlocks.length === 0) return;
1070
+ appendMessage(sid, { id: crypto.randomUUID(), role: 'assistant', blocks: assistantBlocks });
1071
+ console.log(`[ws] Saved assistant (${assistantBlocks.length} blocks) for ${sid.slice(0, 8)}`);
1072
+ };
1073
+
1074
+ registerLivePartial(sid, collectPartialBlocks);
1075
+ const partialInterval = setInterval(() => {
1076
+ const blocks = collectPartialBlocks();
1077
+ if (blocks.length) writePartial(sid, blocks);
1078
+ }, 5_000);
1079
+
1080
+ resetRetryCount(sid);
1081
+ setRunStatus(sid, 'running', 'web');
1082
+
1083
+ let stopReason = '';
1084
+ try {
1085
+ let eventCount = 0;
1086
+ for await (const event of streamChat({
1087
+ prompt: promptText,
1088
+ attachments,
1089
+ sessionId: sid,
1090
+ uid: session.uid,
1091
+ userEmail: session.email,
1092
+ userName: session.email.split('@')[0],
1093
+ mcpServers,
1094
+ abortController,
1095
+ voiceMode,
1096
+ conversationReset,
1097
+ turnHints,
1098
+ context: { source: 'web', user: session.email },
1099
+ onPermissionRequest,
1100
+ onUserQuestion,
1101
+ onDestructiveApproval: (id, tool, input) => {
1102
+ if (unattended) {
1103
+ console.log(`[ws] Auto-approved destructive ${tool} id=${id} (voice mode)`);
1104
+ return Promise.resolve({ allow: true });
1105
+ }
1106
+ return new Promise<{ allow: boolean }>((resolve) => {
1107
+ const ttl = ws.readyState !== WebSocket.OPEN ? DESTRUCTIVE_PERMISSION_TTL : undefined;
1108
+ session.pendingPermissions.set(id, { resolve, destructive: true, tool, input, sessionId: sid });
1109
+ if (ws.readyState === WebSocket.OPEN) {
1110
+ send(ws, { type: 'permission_request', id, tool, input, sessionId: sid });
1111
+ } else {
1112
+ globalPendingPermissions.set(id, { resolve, sessionId: sid, tool, input, uid: session.uid });
1113
+ }
1114
+ if (ttl) setTimeout(() => {
1115
+ if (globalPendingPermissions.delete(id)) {
1116
+ console.log(`[ws] Destructive ${tool} id=${id} denied after TTL (client didn't reconnect)`);
1117
+ resolve({ allow: false });
1118
+ }
1119
+ }, ttl);
1120
+ console.log(`[ws] Destructive op approval required for ${tool} id=${id}${ws.readyState !== WebSocket.OPEN ? ` (queued for reconnect, ${ttl! / 1000}s TTL)` : ''}`);
1121
+ });
1122
+ },
1123
+ })) {
1124
+ eventCount++;
1125
+ if (event.type !== 'done' && event.type !== 'error') send(ws, { ...event, sessionId: sid });
1126
+
1127
+ if (event.type === 'thinking_delta') {
1128
+ thinkingText += event.text;
1129
+ broadcast({ type: 'session_stream', sessionId: sid, event: { type: 'thinking_delta', text: event.text } }, ws);
1130
+ } else if (event.type === 'text_delta') {
1131
+ if (thinkingText) { assistantBlocks.push({ type: 'thinking', text: thinkingText }); thinkingText = ''; }
1132
+ assistantText += event.text;
1133
+ broadcast({ type: 'session_stream', sessionId: sid, event: { type: 'text_delta', text: event.text } }, ws);
1134
+ } else if (event.type === 'tool_use') {
1135
+ if (thinkingText) { assistantBlocks.push({ type: 'thinking', text: thinkingText }); thinkingText = ''; }
1136
+ if (assistantText) {
1137
+ assistantBlocks.push({ type: 'text', text: assistantText });
1138
+ assistantText = '';
1139
+ }
1140
+ assistantBlocks.push({ type: 'tool_use', tool: event.tool, toolUseId: event.toolUseId, input: event.input });
1141
+ broadcast({ type: 'session_stream', sessionId: sid, event: { type: 'tool_use', tool: event.tool, toolUseId: event.toolUseId, input: event.input } }, ws);
1142
+ const artifactEvent = handleArtifactToolUse(sid, event.tool, event.input);
1143
+ if (artifactEvent) send(ws, artifactEvent);
1144
+ } else if (event.type === 'tool_use_input') {
1145
+ const existing = assistantBlocks.find((b: any) => b.type === 'tool_use' && b.toolUseId === event.toolUseId) as any;
1146
+ if (existing) existing.input = event.input;
1147
+ broadcast({ type: 'session_stream', sessionId: sid, event: { type: 'tool_use_input', toolUseId: event.toolUseId, input: event.input } }, ws);
1148
+ if (existing?.tool) {
1149
+ const artifactEvent = handleArtifactToolUse(sid, existing.tool, event.input);
1150
+ if (artifactEvent) send(ws, artifactEvent);
1151
+ }
1152
+ } else if (event.type === 'tool_result') {
1153
+ assistantBlocks.push({ type: 'tool_result', toolUseId: event.toolUseId, output: event.output });
1154
+ broadcast({ type: 'session_stream', sessionId: sid, event: { type: 'tool_result', toolUseId: event.toolUseId, output: event.output } }, ws);
1155
+ } else if (event.type === 'tool_result_image') {
1156
+ assistantBlocks.push({ type: 'image', src: event.dataUrl });
1157
+ broadcast({ type: 'session_stream', sessionId: sid, event: { type: 'tool_result_image', toolUseId: event.toolUseId, dataUrl: event.dataUrl } }, ws);
1158
+ } else if (event.type === 'done') {
1159
+ stopReason = event.stopReason ?? 'end_turn';
1160
+ if (stopReason === 'max_turns_reached') {
1161
+ assistantBlocks.push({ type: 'text', text: '\n\n---\n⚠️ Reached the maximum number of steps for this turn. Send "continue" to pick up where I left off.' });
1162
+ }
1163
+ if (!assistantText && !thinkingText && assistantBlocks.length === 0 && !event.builtinHandled) {
1164
+ const fallback = '⚠️ No response was generated. Try rephrasing or sending again.';
1165
+ assistantText = fallback;
1166
+ send(ws, { type: 'text_delta', text: fallback, sessionId: sid });
1167
+ broadcast({ type: 'session_stream', sessionId: sid, event: { type: 'text_delta', text: fallback } }, ws);
1168
+ }
1169
+ flushAssistant();
1170
+ console.log(`[ws] Done for ${session.email}: sessionId=${sid.slice(0, 8)} events=${eventCount} stopReason=${stopReason}`);
1171
+ upsertSession(sid, promptText, { uid: session.uid, email: session.email });
1172
+ send(ws, { type: 'done', sessionId: sid, stopReason });
1173
+ broadcast({ type: 'session_messages_changed', sessionId: sid }, ws);
1174
+
1175
+ // Notify owner if they're not viewing this session
1176
+ const meta = getSession(sid);
1177
+ const preview = assistantText.slice(0, 120) || '(completed)';
1178
+ notifyUnread(session.uid, sid, preview, 'response', meta?.title, ws);
1179
+
1180
+ // Generate a better title after first turn
1181
+ if (isFirstTurn && assistantText) {
1182
+ generateSessionTitle(sid, promptText, assistantText).then((title) => {
1183
+ if (title) {
1184
+ send(ws, { type: 'session_title_updated', sessionId: sid, title });
1185
+ broadcast({ type: 'session_title_updated', sessionId: sid, title }, ws);
1186
+ }
1187
+ });
1188
+ }
1189
+ break;
1190
+ } else if (event.type === 'error') {
1191
+ if (session.steerPending.has(sid)) {
1192
+ console.log(`[ws] Suppressing error during steer for ${sid.slice(0, 8)}`);
1193
+ } else {
1194
+ console.error(`[ws] Error event for ${session.email}: ${event.message}`);
1195
+ send(ws, { type: 'error', message: event.message, sessionId: sid });
1196
+ }
1197
+ break;
1198
+ }
1199
+ }
1200
+ if (eventCount === 0) {
1201
+ console.warn(`[ws] Stream yielded 0 events for ${session.email}`);
1202
+ send(ws, { type: 'error', message: 'No response from agent — check server logs', sessionId: sid });
1203
+ }
1204
+ } catch (err: any) {
1205
+ if (session.steerPending.has(sid)) {
1206
+ console.log(`[ws] Stream aborted for steer in ${sid.slice(0, 8)}`);
1207
+ } else {
1208
+ stopReason = 'error';
1209
+ console.error(`[ws] Stream error for ${session.email}:`, err.message || err);
1210
+ send(ws, { type: 'error', message: err.message || String(err), sessionId: sid });
1211
+ }
1212
+ } finally {
1213
+ clearInterval(partialInterval);
1214
+ unregisterLivePartial(sid);
1215
+ flushAssistant();
1216
+ session.abortControllers.delete(sid);
1217
+
1218
+ const steerText = session.steerPending.get(sid);
1219
+ session.steerPending.delete(sid);
1220
+ if (steerText) {
1221
+ appendMessage(sid, { id: crypto.randomUUID(), role: 'user', blocks: [{ type: 'text', text: steerText }], channel: 'web', senderName: session.email.split('@')[0] });
1222
+ console.log(`[ws] Restarting stream with steer for ${sid.slice(0, 8)}`);
1223
+ // Preserve voice/unattended mode across a steer-restart, else auto-approve is lost mid-turn and prompts hang.
1224
+ await runStream(ws, session, sid, steerText, undefined, mcpServers, true, voiceMode);
1225
+ return;
1226
+ }
1227
+
1228
+ releaseSessionLock(sid);
1229
+ const okReasons = ['', 'end_turn', 'success'];
1230
+ const resolvedStop = stopReason === 'max_turns_reached' ? 'max_turns_reached'
1231
+ : (!okReasons.includes(stopReason)) ? 'error' : undefined;
1232
+ setRunStatus(sid, 'idle', undefined, resolvedStop);
1233
+ session.busySessions.delete(sid);
1234
+ broadcast({ type: 'session_busy', sessionId: sid, busy: false });
1235
+ // Turn-done remote push (owner only; suppressed if they're foregrounding this session).
1236
+ try { pushTurnDone(getSession(sid)?.uid || session.uid, sid); }
1237
+ catch (err) { console.error('[push] turn-done trigger failed:', err); }
1238
+ for (const [id, perm] of globalPendingPermissions) {
1239
+ if (perm.sessionId === sid) globalPendingPermissions.delete(id);
1240
+ }
1241
+ }
1242
+ }
1243
+
1244
+ function handleConnection(ws: WebSocket, session: WsSession) {
1245
+ console.log('[ws] New connection');
1246
+
1247
+ // Tolerate ONE missed pong before terminating (~2 intervals of grace) — see the ws-proxy keepalive note:
1248
+ // a 1-strike policy force-closed backgrounded/frozen mobile clients every 30s, triggering reconnect churn
1249
+ // (and, paired with an OS tab-discard, the reload + "Verifying" + sidecar re-attach the user saw).
1250
+ let missedPongs = 0;
1251
+ ws.on('pong', () => { missedPongs = 0; });
1252
+ const pingInterval = setInterval(() => {
1253
+ if (missedPongs >= 2) { console.warn(`[ws] client missed pongs — terminating (${session.email || 'unauth'})`); ws.terminate(); return; }
1254
+ missedPongs++;
1255
+ ws.ping();
1256
+ }, WS_PING_INTERVAL);
1257
+
1258
+ ws.on('message', async (raw) => {
1259
+ let msg: any;
1260
+ try {
1261
+ msg = JSON.parse(raw.toString());
1262
+ } catch {
1263
+ return send(ws, { type: 'error', message: 'Invalid JSON' });
1264
+ }
1265
+
1266
+ if (msg.type === 'auth') {
1267
+ try {
1268
+ const user = await verifyBearer(msg.token); // pluggable (local|firebase), not firebase-only
1269
+ session.uid = user.uid;
1270
+ session.email = user.email;
1271
+ session.autoApprove = getAutoApprove(user.uid);
1272
+ console.log(`[ws] Authenticated: ${user.email} (${user.uid}) autoApprove=${session.autoApprove}`);
1273
+ send(ws, { type: 'auth_ok', uid: user.uid, email: user.email, buildId: SERVER_BUILD_ID });
1274
+ activeConnections.set(ws, session);
1275
+ sendUnreadSync(ws, user.uid);
1276
+ // Re-send any orphaned permission requests waiting for this user
1277
+ for (const [id, perm] of globalPendingPermissions) {
1278
+ if (perm.uid === user.uid) {
1279
+ session.pendingPermissions.set(id, { resolve: perm.resolve, destructive: true });
1280
+ send(ws, { type: 'permission_request', id, tool: perm.tool, input: perm.input, sessionId: perm.sessionId });
1281
+ globalPendingPermissions.delete(id);
1282
+ console.log(`[ws] Re-sent orphaned permission ${id} (${perm.tool}) to reconnected ${user.email}`);
1283
+ }
1284
+ }
1285
+ } catch (err: any) {
1286
+ console.error(`[ws] Auth failed:`, err.message);
1287
+ send(ws, { type: 'auth_error', message: err.message });
1288
+ ws.close();
1289
+ }
1290
+ return;
1291
+ }
1292
+
1293
+ if (!session.uid) return send(ws, { type: 'error', message: 'Not authenticated' });
1294
+
1295
+ if (msg.type === 'permission_response') {
1296
+ if (msg.allowAll) {
1297
+ session.autoApprove = true;
1298
+ setAutoApprove(session.uid, true);
1299
+ console.log(`[ws] Auto-approve enabled and persisted for ${session.email}`);
1300
+ }
1301
+ const entry = session.pendingPermissions.get(msg.id);
1302
+ if (entry) {
1303
+ session.pendingPermissions.delete(msg.id);
1304
+ entry.resolve({ allow: !!msg.allow });
1305
+ }
1306
+ return;
1307
+ }
1308
+
1309
+ if (msg.type === 'question_response') {
1310
+ const entry = session.pendingQuestions.get(msg.id);
1311
+ if (entry) {
1312
+ session.pendingQuestions.delete(msg.id);
1313
+ entry.resolve((msg.answers as QuestionAnswers) ?? null);
1314
+ console.log(`[ws] Question ${msg.id} answered`);
1315
+ }
1316
+ return;
1317
+ }
1318
+
1319
+ if (msg.type === 'presence') {
1320
+ session.viewingSessionId = msg.sessionId || null;
1321
+ session.focused = !!msg.focused;
1322
+ return;
1323
+ }
1324
+
1325
+ // Native wrapper foreground signal (push suppression). Reuses the same presence
1326
+ // fields so triggers' isUserViewingSession() sees a native client like a web one.
1327
+ if (msg.type === 'client_presence') {
1328
+ session.viewingSessionId = msg.visible ? (msg.sessionId || null) : null;
1329
+ session.focused = !!msg.visible;
1330
+ return;
1331
+ }
1332
+
1333
+ if (msg.type === 'mark_read') {
1334
+ const sid = msg.sessionId;
1335
+ if (sid) {
1336
+ markUnread(session.uid, sid);
1337
+ for (const [otherWs, s] of activeConnections) {
1338
+ if (s.uid === session.uid && otherWs !== ws) {
1339
+ send(otherWs, { type: 'unread_cleared', sessionId: sid });
1340
+ }
1341
+ }
1342
+ }
1343
+ return;
1344
+ }
1345
+
1346
+ if (msg.type === 'steer') {
1347
+ const steerSid = msg.sessionId || session.lastSessionId;
1348
+ if (!steerSid) return;
1349
+ const localAc = session.abortControllers.get(steerSid);
1350
+ const globalAc = !localAc ? getSessionAbortController(steerSid) : null;
1351
+ const ac = localAc || globalAc;
1352
+ if (!ac) return;
1353
+ const steerText = msg.text ?? '';
1354
+ console.log(`[ws] Steer from ${session.email}: "${steerText.slice(0, 80)}" session=${steerSid.slice(0, 8)}`);
1355
+ if (localAc) {
1356
+ session.steerPending.set(steerSid, steerText);
1357
+ ac.abort();
1358
+ } else {
1359
+ ac.abort();
1360
+ appendMessage(steerSid, { id: crypto.randomUUID(), role: 'user', blocks: [{ type: 'text', text: steerText }], channel: 'web', senderName: session.email.split('@')[0] });
1361
+ console.log(`[ws] External steer takeover for ${steerSid.slice(0, 8)}`);
1362
+ session.busySessions.add(steerSid);
1363
+ session.lastSessionId = steerSid;
1364
+ runStream(ws, session, steerSid, steerText, undefined, getMcpConfig(session.uid), true);
1365
+ }
1366
+ return;
1367
+ }
1368
+
1369
+ if (msg.type === 'message') {
1370
+ if (_draining) return send(ws, { type: 'error', message: 'Server is restarting — please retry in a moment' });
1371
+ let sid = msg.sessionId || crypto.randomUUID();
1372
+ const wantsFork = typeof msg.truncateAt === 'number' && msg.truncateAt >= 0 && (session.busySessions.has(sid) || isSessionLocked(sid));
1373
+ if (!wantsFork && (session.busySessions.has(sid) || isSessionLocked(sid))) return send(ws, { type: 'error', message: 'Already processing a request', sessionId: sid });
1374
+ if (!wantsFork) session.busySessions.add(sid);
1375
+ const mcpServers = getMcpConfig(session.uid);
1376
+ // Voice greeting: a synthetic, agent-first opener. Strip the marker and DON'T
1377
+ // persist it as a user message — the spoken greeting is the agent's reply, not a user turn.
1378
+ const GREETING_SENTINEL = '__VOICE_GREETING__';
1379
+ const isGreeting = (msg.text ?? '').startsWith(GREETING_SENTINEL);
1380
+ const promptText = isGreeting ? (msg.text ?? '').slice(GREETING_SENTINEL.length).trimStart() : (msg.text ?? '');
1381
+ session.lastSessionId = sid;
1382
+ session.viewingSessionId = sid;
1383
+ session.focused = true;
1384
+ const isNew = !msg.sessionId;
1385
+ if (isNew) {
1386
+ upsertSession(sid, promptText, { uid: session.uid, email: session.email });
1387
+ send(ws, { type: 'session_id', sessionId: sid });
1388
+ // Immediate prompt-derived title so the tab renames off "New Chat" now; the LLM title refines it later.
1389
+ const t0 = getSession(sid)?.title;
1390
+ if (t0) send(ws, { type: 'session_title_updated', sessionId: sid, title: t0 });
1391
+ console.log(`[ws] New session ${sid.slice(0, 8)} for ${session.email}`);
1392
+ }
1393
+
1394
+ console.log(`[ws] Message from ${session.email}: "${promptText.slice(0, 100)}" session=${sid.slice(0, 8)}`);
1395
+
1396
+ // Truncate or fork conversation if replaying/editing a previous message
1397
+ if (typeof msg.truncateAt === 'number' && msg.truncateAt >= 0) {
1398
+ if (wantsFork) {
1399
+ // Session is busy — fork instead of destructive truncate to avoid race conditions
1400
+ let forkedId: string | null = null;
1401
+ if (msg.truncateAt > 0) {
1402
+ // forkSession truncateAtIndex is inclusive (slices to index+1), truncateAt is message count to keep
1403
+ forkedId = forkSession(sid, { uid: session.uid, email: session.email, name: session.email.split('@')[0] }, msg.truncateAt - 1);
1404
+ }
1405
+ if (!forkedId) {
1406
+ // truncateAt=0 (restart from scratch) or forkSession failed — create a fresh session
1407
+ forkedId = crypto.randomUUID();
1408
+ upsertSession(forkedId, promptText, { uid: session.uid, email: session.email });
1409
+ }
1410
+ console.log(`[ws] Forked busy session ${sid.slice(0, 8)} → ${forkedId.slice(0, 8)} (truncateAt=${msg.truncateAt})`);
1411
+ session.busySessions.add(forkedId);
1412
+ sid = forkedId;
1413
+ session.lastSessionId = forkedId;
1414
+ send(ws, { type: 'forked', sourceSessionId: msg.sessionId, sessionId: forkedId });
1415
+ } else {
1416
+ const existing = loadConversation(sid);
1417
+ saveConversation(sid, existing.slice(0, msg.truncateAt));
1418
+ console.log(`[ws] Truncated conversation ${sid.slice(0, 8)} to ${msg.truncateAt} messages`);
1419
+ }
1420
+ }
1421
+
1422
+ // Save user message to disk immediately
1423
+ const attachments: AttachmentMeta[] = msg.attachments ?? [];
1424
+ const attBlocks: ConvBlock[] = attachments.map((a: any) =>
1425
+ a.mimeType.startsWith('image/')
1426
+ ? { type: 'image' as const, src: a.url }
1427
+ : { type: 'file' as const, src: a.url, name: a.name, mimeType: a.mimeType }
1428
+ );
1429
+ if (!isGreeting) appendMessage(sid, { id: crypto.randomUUID(), role: 'user', blocks: [...attBlocks, { type: 'text', text: promptText }], channel: 'web', senderName: session.email.split('@')[0] });
1430
+
1431
+ const wasReset = typeof msg.truncateAt === 'number' && msg.truncateAt >= 0;
1432
+ // Opaque per-send bag from the client's send-options slot. The core forwards it verbatim to the
1433
+ // turn-context seam and interprets no key of it (a plain object only — never an array/primitive).
1434
+ const turnHints = msg.turnHints && typeof msg.turnHints === 'object' && !Array.isArray(msg.turnHints)
1435
+ ? (msg.turnHints as Record<string, unknown>) : undefined;
1436
+ await runStream(ws, session, sid, promptText, attachments, mcpServers, false, !!msg.voiceMode, wasReset, turnHints);
1437
+ }
1438
+
1439
+ if (msg.type === 'interruption_marker') {
1440
+ const sid = msg.sessionId || session.lastSessionId;
1441
+ if (sid && msg.revealedText) {
1442
+ const tail = msg.revealedText.length > 120
1443
+ ? '…' + msg.revealedText.slice(-120)
1444
+ : msg.revealedText;
1445
+ appendMessage(sid, {
1446
+ id: crypto.randomUUID(),
1447
+ role: 'system',
1448
+ blocks: [{ type: 'text', text: `[User interrupted voice playback — only heard up to: "${tail}"]` }],
1449
+ });
1450
+ }
1451
+ }
1452
+
1453
+ if (msg.type === 'cancel') {
1454
+ const cancelSid = msg.sessionId || session.lastSessionId;
1455
+ if (!cancelSid) return;
1456
+ const localAc = session.abortControllers.get(cancelSid);
1457
+ const globalAc = getSessionAbortController(cancelSid);
1458
+ const ac = localAc || globalAc;
1459
+ if (ac) {
1460
+ console.log(`[ws] Cancel requested by ${session.email} session=${cancelSid.slice(0, 8)}`);
1461
+ ac.abort();
1462
+ session.abortControllers.delete(cancelSid);
1463
+ }
1464
+ session.busySessions.delete(cancelSid);
1465
+ }
1466
+ });
1467
+
1468
+ ws.on('close', () => {
1469
+ clearInterval(pingInterval);
1470
+ console.log(`[ws] Disconnected: ${session.email || 'unauthenticated'}`);
1471
+ activeConnections.delete(ws);
1472
+ // Don't abort running processes — let them finish and save results.
1473
+ // Auto-approve regular permissions; queue destructive ones for reconnect.
1474
+ for (const [id, entry] of session.pendingPermissions) {
1475
+ if (entry.destructive && entry.tool && entry.sessionId) {
1476
+ globalPendingPermissions.set(id, { resolve: entry.resolve, sessionId: entry.sessionId, tool: entry.tool, input: entry.input, uid: session.uid });
1477
+ setTimeout(() => {
1478
+ if (globalPendingPermissions.delete(id)) {
1479
+ console.log(`[ws] Destructive ${entry.tool} id=${id} denied after TTL (client didn't reconnect)`);
1480
+ entry.resolve({ allow: false });
1481
+ }
1482
+ }, DESTRUCTIVE_PERMISSION_TTL);
1483
+ console.log(`[ws] Destructive permission ${id} (${entry.tool}) queued for reconnect (${DESTRUCTIVE_PERMISSION_TTL / 1000}s TTL)`);
1484
+ } else {
1485
+ console.log(`[ws] Auto-approving orphaned permission ${id} (client disconnected)`);
1486
+ entry.resolve({ allow: true });
1487
+ }
1488
+ }
1489
+ session.pendingPermissions.clear();
1490
+ // Orphaned questions: resolve null so the agent proceeds with its own judgement.
1491
+ for (const [id, entry] of session.pendingQuestions) {
1492
+ console.log(`[ws] Resolving orphaned question ${id} as null (client disconnected)`);
1493
+ entry.resolve(null);
1494
+ }
1495
+ session.pendingQuestions.clear();
1496
+ session.abortControllers.clear();
1497
+ });
1498
+
1499
+ ws.on('error', (err) => console.error(`[ws] Error (${session.email}):`, err.message));
1500
+ }
1501
+
1502
+ // ── Deploy recovery ──────────────────────────────────────────────────────────
1503
+
1504
+ async function retryWebSession(session: SessionMeta, prompt: string) {
1505
+ const sid = session.sessionId;
1506
+ console.log(`[recovery] retrying web session ${sid.slice(0, 8)}`);
1507
+ const recoveryAc = new AbortController();
1508
+ if (!acquireSessionLock(sid, 'web', recoveryAc)) {
1509
+ console.warn(`[recovery] session ${sid.slice(0, 8)} already locked, skipping`);
1510
+ return;
1511
+ }
1512
+ setRunStatus(sid, 'running', 'web');
1513
+ broadcast({ type: 'session_busy', sessionId: sid, busy: true });
1514
+
1515
+ try {
1516
+ let assistantText = '';
1517
+ const assistantBlocks: ConvBlock[] = [];
1518
+ const collectPartial = () => [
1519
+ ...assistantBlocks,
1520
+ ...(assistantText ? [{ type: 'text' as const, text: assistantText }] : []),
1521
+ ];
1522
+ registerLivePartial(sid, collectPartial);
1523
+ for await (const ev of streamChat({
1524
+ prompt,
1525
+ sessionId: sid,
1526
+ uid: session.uid,
1527
+ userEmail: session.userEmail,
1528
+ userName: session.userName || session.userEmail.split('@')[0],
1529
+ mcpServers: getMcpConfig(session.uid),
1530
+ abortController: recoveryAc,
1531
+ context: { source: 'web', user: session.userEmail },
1532
+ onPermissionRequest: async () => ({ allow: true }),
1533
+ })) {
1534
+ if (ev.type === 'text_delta') {
1535
+ assistantText += ev.text;
1536
+ broadcast({ type: 'session_stream', sessionId: sid, event: { type: 'text_delta', text: ev.text } });
1537
+ } else if (ev.type === 'tool_use') {
1538
+ if (assistantText) { assistantBlocks.push({ type: 'text', text: assistantText }); assistantText = ''; }
1539
+ assistantBlocks.push({ type: 'tool_use', tool: ev.tool, toolUseId: ev.toolUseId, input: ev.input });
1540
+ broadcast({ type: 'session_stream', sessionId: sid, event: { type: 'tool_use', tool: ev.tool, toolUseId: ev.toolUseId, input: ev.input } });
1541
+ } else if (ev.type === 'tool_use_input') {
1542
+ const existing = assistantBlocks.find((b: any) => b.type === 'tool_use' && b.toolUseId === ev.toolUseId) as any;
1543
+ if (existing) existing.input = ev.input;
1544
+ broadcast({ type: 'session_stream', sessionId: sid, event: { type: 'tool_use_input', toolUseId: ev.toolUseId, input: ev.input } });
1545
+ } else if (ev.type === 'tool_result') {
1546
+ assistantBlocks.push({ type: 'tool_result', toolUseId: ev.toolUseId, output: ev.output });
1547
+ } else if (ev.type === 'tool_result_image') {
1548
+ assistantBlocks.push({ type: 'image', src: ev.dataUrl });
1549
+ broadcast({ type: 'session_stream', sessionId: sid, event: { type: 'tool_result_image', toolUseId: ev.toolUseId, dataUrl: ev.dataUrl } });
1550
+ } else if (ev.type === 'done') {
1551
+ break;
1552
+ } else if (ev.type === 'error') {
1553
+ assistantText += `\n⚠️ ${ev.message}`;
1554
+ break;
1555
+ }
1556
+ }
1557
+ if (assistantText) assistantBlocks.push({ type: 'text', text: assistantText });
1558
+ if (assistantBlocks.length) {
1559
+ appendMessage(sid, { id: crypto.randomUUID(), role: 'assistant', blocks: assistantBlocks });
1560
+ broadcast({ type: 'session_messages_changed', sessionId: sid });
1561
+ const preview = assistantText.slice(0, 120) || '(completed)';
1562
+ notifyUnread(session.uid, sid, preview, 'response', session.title);
1563
+ }
1564
+ } catch (err: any) {
1565
+ console.error(`[recovery] web session error ${sid.slice(0, 8)}:`, err.message);
1566
+ } finally {
1567
+ unregisterLivePartial(sid);
1568
+ if (releaseSessionLock(sid, recoveryAc)) {
1569
+ setRunStatus(sid, 'idle');
1570
+ broadcast({ type: 'session_busy', sessionId: sid, busy: false });
1571
+ }
1572
+ }
1573
+ }
1574
+
1575
+ async function recoverInterruptedSessions() {
1576
+ const interrupted = getRunningSessions();
1577
+ if (!interrupted.length) return;
1578
+ console.log(`[recovery] Found ${interrupted.length} interrupted session(s)`);
1579
+
1580
+ for (const s of interrupted) {
1581
+ // Scheduler sessions: resume in-place on the SAME conversation (symmetric with web/slack),
1582
+ // instead of marking error and letting startup catch-up spawn a fresh convo — that re-fire
1583
+ // produced duplicate side-effects (e.g. a second Slack post).
1584
+ if (s.runOrigin === 'scheduler') {
1585
+ const partialBlocks = readPartial(s.sessionId);
1586
+ clearPartial(s.sessionId);
1587
+ const partialTexts = partialBlocks?.filter(b => b.type === 'text').map(b => (b as any).text) ?? [];
1588
+
1589
+ // Bail to the old mark-error path if we can't resume safely:
1590
+ // no schedule id to re-run, or we've already retried this run twice.
1591
+ const canResume = !!s.scheduleId && (s.runRetryCount ?? 0) < 2;
1592
+ if (!canResume) {
1593
+ if (partialTexts.length) {
1594
+ appendMessage(s.sessionId, {
1595
+ id: crypto.randomUUID(),
1596
+ role: 'assistant',
1597
+ blocks: [{ type: 'text', text: partialTexts.join('\n') + '\n\n[...interrupted by server restart]' }],
1598
+ });
1599
+ }
1600
+ setRunStatus(s.sessionId, 'idle');
1601
+ updateScheduledSessionStatus(s.sessionId, 'error');
1602
+ if (s.scheduleId) scheduler.clearRunningMarker(s.scheduleId);
1603
+ console.log(`[recovery] scheduler session ${s.sessionId.slice(0, 8)} (${s.scheduleId ?? '?'}) — not resumable (no schedule / retries exhausted), marked error`);
1604
+ continue;
1605
+ }
1606
+
1607
+ incrementRetryCount(s.sessionId);
1608
+ // Preserve the partial response with a cutoff marker, then continue in-place.
1609
+ const cutoff = partialTexts.length
1610
+ ? partialTexts.join('\n') + '\n\n[...response interrupted by server restart]'
1611
+ : '[...response interrupted by server restart]';
1612
+ appendMessage(s.sessionId, {
1613
+ id: crypto.randomUUID(),
1614
+ role: 'assistant',
1615
+ blocks: [{ type: 'text', text: cutoff }],
1616
+ });
1617
+ const resumePrompt = 'Your previous response was cut off by a server restart. The partial response has been preserved above. Continue from where you left off, and avoid repeating any side-effects (e.g. messages already sent) that may have completed before the interruption.';
1618
+ setRunStatus(s.sessionId, 'idle');
1619
+ scheduler.resumeRun(s.scheduleId!, s.sessionId, resumePrompt);
1620
+ console.log(`[recovery] resuming scheduler session ${s.sessionId.slice(0, 8)} (${s.scheduleId}) in-place`);
1621
+ continue;
1622
+ }
1623
+
1624
+ if (s.sessionId.startsWith('api-')) {
1625
+ console.log(`[recovery] Skip API session ${s.sessionId.slice(0, 12)} — no user waiting`);
1626
+ setRunStatus(s.sessionId, 'idle');
1627
+ continue;
1628
+ }
1629
+
1630
+ if ((s.runRetryCount ?? 0) >= 2) {
1631
+ console.log(`[recovery] Skip ${s.sessionId.slice(0, 8)} — max retries reached`);
1632
+ const partialBlocks = readPartial(s.sessionId);
1633
+ clearPartial(s.sessionId);
1634
+ const partialTexts = partialBlocks?.filter(b => b.type === 'text').map(b => (b as any).text) ?? [];
1635
+ appendMessage(s.sessionId, {
1636
+ id: crypto.randomUUID(),
1637
+ role: 'assistant',
1638
+ blocks: [{ type: 'text', text: (partialTexts.length ? partialTexts.join('\n') + '\n\n' : '') + '[Interrupted by server restart — please send a message to continue]' }],
1639
+ });
1640
+ setRunStatus(s.sessionId, 'idle');
1641
+ continue;
1642
+ }
1643
+
1644
+ const conv = loadConversation(s.sessionId);
1645
+ const lastUser = [...conv].reverse().find(m =>
1646
+ m.role === 'user' && m.blocks.some(b => b.type === 'text' && !b.text.startsWith('[Resumed'))
1647
+ );
1648
+ const originalPrompt = lastUser?.blocks.find(b => b.type === 'text')?.text;
1649
+ if (!originalPrompt) {
1650
+ setRunStatus(s.sessionId, 'idle');
1651
+ continue;
1652
+ }
1653
+
1654
+ incrementRetryCount(s.sessionId);
1655
+
1656
+ // Recover partial assistant response saved before crash
1657
+ const partialBlocks = readPartial(s.sessionId);
1658
+ clearPartial(s.sessionId);
1659
+
1660
+ let prompt: string;
1661
+ if (partialBlocks?.length) {
1662
+ const partialTexts = partialBlocks.filter(b => b.type === 'text').map(b => (b as any).text);
1663
+ const cutoffText = partialTexts.length
1664
+ ? partialTexts.join('\n') + '\n\n[...response interrupted by server restart]'
1665
+ : '[...response interrupted by server restart]';
1666
+ appendMessage(s.sessionId, {
1667
+ id: crypto.randomUUID(),
1668
+ role: 'assistant',
1669
+ blocks: [{ type: 'text', text: cutoffText }],
1670
+ });
1671
+ prompt = 'Your previous response was cut off by a server restart. The partial response has been preserved above. Continue from where you left off.';
1672
+ console.log(`[recovery] Restored partial response (${partialBlocks.length} blocks) for ${s.sessionId.slice(0, 8)}`);
1673
+ } else {
1674
+ appendMessage(s.sessionId, {
1675
+ id: crypto.randomUUID(),
1676
+ role: 'user',
1677
+ blocks: [{ type: 'text', text: '[Resumed after server restart]' }],
1678
+ });
1679
+ prompt = originalPrompt;
1680
+ }
1681
+
1682
+ if (s.runOrigin === 'slack') {
1683
+ if (!resumeFeatureSession('slack', s, prompt)) {
1684
+ console.warn(`[recovery] slack feature not available to resume ${s.sessionId.slice(0, 8)}`);
1685
+ setRunStatus(s.sessionId, 'idle');
1686
+ }
1687
+ } else {
1688
+ retryWebSession(s, prompt).catch(err => console.error(`[recovery] web retry failed:`, err.message));
1689
+ }
1690
+ }
1691
+
1692
+ // Periodic sweep: clean up scheduler sessions not tracked by the engine
1693
+ const orphanSweep = setInterval(() => {
1694
+ const stillRunning = getRunningSessions().filter(s => s.runOrigin === 'scheduler');
1695
+ if (!stillRunning.length) { clearInterval(orphanSweep); return; }
1696
+ const engineRunning = new Set(scheduler.getRunningIds());
1697
+ for (const s of stillRunning) {
1698
+ if (s.scheduleId && engineRunning.has(s.scheduleId)) continue;
1699
+ console.log(`[recovery] orphaned scheduler session ${s.sessionId.slice(0, 30)}… — not in engine, marking idle/error`);
1700
+ setRunStatus(s.sessionId, 'idle');
1701
+ updateScheduledSessionStatus(s.sessionId, 'error');
1702
+ }
1703
+ }, 30_000);
1704
+ }
1705
+
1706
+ let _draining = false;
1707
+
1708
+ async function gracefulShutdown(signal: string, opts: { exit?: boolean } = {}) {
1709
+ const exit = opts.exit ?? true;
1710
+ if (_draining) return;
1711
+ _draining = true;
1712
+ console.log(`[server] ${signal} — draining (up to 90s)…`);
1713
+ setShuttingDown();
1714
+ stopSidecars();
1715
+
1716
+ for (const ws of activeConnections.keys()) send(ws, { type: 'server_restarting' });
1717
+
1718
+ // Agent turns routinely run for minutes; give in-flight streams time to finish before we force-kill
1719
+ // their Claude Code child processes (which would otherwise surface as `exited with code 143`).
1720
+ // Keep this under the service manager's stop timeout (e.g. systemd TimeoutStopSec) so the drain wins, not SIGKILL.
1721
+ const deadline = Date.now() + 90_000;
1722
+ while (Date.now() < deadline) {
1723
+ const running = getRunningSessions();
1724
+ if (running.length === 0) break;
1725
+ console.log(`[server] waiting for ${running.length} active stream(s)…`);
1726
+ await new Promise((r) => setTimeout(r, 2000));
1727
+ }
1728
+
1729
+ const remaining = getRunningSessions();
1730
+ console.log(`[server] drain complete — ${remaining.length} stream(s) still active, closing`);
1731
+
1732
+ for (const [ws, session] of activeConnections) {
1733
+ for (const ac of session.abortControllers.values()) ac.abort();
1734
+ ws.close();
1735
+ }
1736
+
1737
+ // Optional engines that hold OS resources (warm subprocesses, h2 connections) register their own
1738
+ // teardown on SIGTERM/SIGINT from the overlay — the core names none of them here.
1739
+
1740
+ // Exit 0 on the fallback too: by this point we've drained and aborted cleanly, so a slow
1741
+ // server.close() is not a failure. Exiting 1 here made systemd log `status=1/FAILURE` on every
1742
+ // normal restart — a false alarm. (Restart=always brings us back regardless of code.)
1743
+ // Library embedders call stop() (exit:false): close the sockets but leave the host process alive.
1744
+ if (!exit) {
1745
+ wss.close();
1746
+ sidecarWss.close();
1747
+ await new Promise<void>((resolve) => server.close(() => resolve()));
1748
+ return;
1749
+ }
1750
+ server.close(() => process.exit(0));
1751
+ setTimeout(() => process.exit(0), 5000);
1752
+ }
1753
+
1754
+ // Install process-level signal handlers for the standalone server/CLI path (default). A pure
1755
+ // library embedder that owns its own lifecycle sets SHRAGA_INSTALL_SIGNALS=0 and uses handle.stop().
1756
+ if (process.env.SHRAGA_INSTALL_SIGNALS !== '0') {
1757
+ process.on('SIGTERM', () => gracefulShutdown('SIGTERM').catch((e) => { console.error('[server] shutdown error:', e); process.exit(1); }));
1758
+ process.on('SIGINT', () => gracefulShutdown('SIGINT').catch((e) => { console.error('[server] shutdown error:', e); process.exit(1); }));
1759
+ }
1760
+
1761
+ // ── Start ─────────────────────────────────────────────────────────────────────
1762
+
1763
+ // Kill orphaned vendor MCP processes from a previous server crash.
1764
+ // Only targets processes reparented to init (ppid=1) — safe for multi-tenant.
1765
+ try {
1766
+ const out = execSync(
1767
+ "pgrep -f 'vendor/mcp-.*--stdio' | xargs -I{} sh -c 'ppid=$(ps -o ppid= -p {} 2>/dev/null | tr -d \" \"); [ \"$ppid\" = \"1\" ] && echo {}' 2>/dev/null || true",
1768
+ { encoding: 'utf-8' },
1769
+ ).trim();
1770
+ if (out) {
1771
+ const pids = out.split('\n').filter(Boolean).map(Number);
1772
+ console.log(`[server] killing ${pids.length} orphaned vendor MCP process(es): ${pids.join(',')}`);
1773
+ for (const pid of pids) { try { process.kill(pid, 'SIGTERM'); } catch {} }
1774
+ }
1775
+ } catch {}
1776
+
1777
+ const PORT = Number(process.env.PORT) || 3032;
1778
+ await new Promise<void>((resolve) => {
1779
+ server.listen(PORT, () => {
1780
+ console.log(`[server] Running on http://0.0.0.0:${PORT}`);
1781
+ resolve();
1782
+ if (PASSIVE) return; // no sidecars, recovery, or MCP warmers in passive mode
1783
+ startSidecars().catch(err => console.error('[sidecar] startup error:', err));
1784
+ recoverInterruptedSessions().catch(err => console.error('[recovery] failed:', err));
1785
+ // The disk MCP catalog is warmed off the turn path by whichever engine consumes it — the CE default
1786
+ // (Claude Code) hands MCP servers straight to its SDK and needs no catalog. An add-on engine that
1787
+ // uses the shared catalog registers its own boot/interval warm-up through the overlay.
1788
+ });
1789
+ });
1790
+
1791
+ // OPT-IN post-start plug-and-play. Only extensions/webhooks/events are runtime-safe (they mount on
1792
+ // the persistent extRouter / in-process bus); features & engines mount at boot and are NOT re-entrant.
1793
+ const RT_FLAG = process.env.SHRAGA_RUNTIME_REGISTRATION;
1794
+ const RUNTIME_REG = RT_FLAG === '1' || RT_FLAG === 'true';
1795
+ const runtimeGuard = (what: string) => {
1796
+ if (!RUNTIME_REG) throw new Error(
1797
+ `[shraga] ${what}() at runtime is disabled. Enable it with createShraga({ runtimeRegistration: true }) ` +
1798
+ `(or SHRAGA_RUNTIME_REGISTRATION=1) before start().`,
1799
+ );
1800
+ };
1801
+
1802
+ return {
1803
+ app,
1804
+ server,
1805
+ port: PORT,
1806
+ url: `http://localhost:${PORT}`,
1807
+ emitEvent,
1808
+ registerExtension: (fn) => { runtimeGuard('registerExtension'); return registerExtension(fn); },
1809
+ // A webhook is just an extension that mounts a verified route on the extension Router — reuse the seam.
1810
+ registerWebhook: (opts) => { runtimeGuard('registerWebhook'); return registerExtension((_r, ctx) => { ctx.registerWebhook(opts); }); },
1811
+ on: (source, handler) => { runtimeGuard('on'); return subscribeEvent(source, handler); },
1812
+ stop: () => gracefulShutdown('stop', { exit: false }),
1813
+ };
1814
+
1815
+ }