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