shraga 0.1.13 → 0.1.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/defaults/modules/routine/README.md +38 -0
- package/defaults/modules/routine/module.json +36 -0
- package/defaults/modules/routine/routine.md.tmpl +75 -0
- package/defaults/modules/routine/seeds/workspace/agenda.md +15 -0
- package/defaults/skills/add-skill.md +4 -0
- package/defaults/skills/artifacts.md +4 -0
- package/defaults/skills/communications.md +4 -0
- package/defaults/skills/create-module.md +75 -0
- package/defaults/skills/debug.md +4 -0
- package/defaults/skills/garden.md +4 -0
- package/defaults/skills/github-contributor.md +4 -0
- package/defaults/skills/identity.md +4 -0
- package/defaults/skills/mcp-server.md +4 -0
- package/defaults/skills/modules.md +41 -0
- package/defaults/skills/plan.md +4 -0
- package/defaults/skills/reconcile.md +4 -0
- package/defaults/skills/scheduler.md +5 -2
- package/defaults/skills/self-aware.md +5 -1
- package/defaults/skills/write-tests.md +4 -0
- package/dist/client/assets/index-D5KxKl57.js +1946 -0
- package/dist/client/assets/index-nyZagTjP.css +10 -0
- package/dist/client/index.html +2 -2
- package/package.json +1 -1
- package/src/client/App.tsx +11 -0
- package/src/client/components/ChatView.tsx +14 -0
- package/src/client/components/ConversationHeader.tsx +29 -0
- package/src/client/components/ModulesManager.tsx +259 -0
- package/src/client/hooks/useConversation.ts +5 -3
- package/src/client/hooks/useModules.ts +81 -0
- package/src/client/lib/api.ts +13 -0
- package/src/client/lib/sessionApi.ts +16 -2
- package/src/client/lib/workspaceContext.tsx +2 -0
- package/src/mcp-stdio-bridge.ts +5 -5
- package/src/server/boot.ts +95 -16
- package/src/server/claude.ts +12 -1
- package/src/server/data-sync.ts +28 -0
- package/src/server/engine/claude-code.ts +10 -3
- package/src/server/events/types.ts +3 -0
- package/src/server/mcp-sidecar.ts +2 -2
- package/src/server/mcp.ts +3 -5
- package/src/server/modules/index.ts +3 -0
- package/src/server/modules/routes.ts +78 -0
- package/src/server/modules/service.ts +575 -0
- package/src/server/modules/types.ts +62 -0
- package/src/server/paths.ts +57 -6
- package/src/server/scheduler/builtins.ts +27 -3
- package/src/server/scheduler/engine.ts +6 -0
- package/src/server/scheduler/runner.ts +96 -30
- package/src/server/scheduler/types.ts +3 -0
- package/src/server/sessions.ts +4 -0
- package/src/server/shraga-config.ts +21 -4
- package/src/server/skills.ts +6 -5
- package/src/server/slack/bot.ts +3 -1
- package/dist/client/assets/index-ChElotX8.js +0 -1936
- package/dist/client/assets/index-DdibEb2O.css +0 -10
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
2
|
+
import { api } from '@/lib/api';
|
|
3
|
+
|
|
4
|
+
export interface ModuleConfigField {
|
|
5
|
+
type: 'string' | 'number' | 'boolean';
|
|
6
|
+
default: unknown;
|
|
7
|
+
description?: string;
|
|
8
|
+
}
|
|
9
|
+
export type ModuleConfigSchema = Record<string, ModuleConfigField>;
|
|
10
|
+
|
|
11
|
+
export interface InstalledModule {
|
|
12
|
+
name: string;
|
|
13
|
+
version: string;
|
|
14
|
+
description?: string;
|
|
15
|
+
enabled: boolean;
|
|
16
|
+
config: Record<string, unknown>;
|
|
17
|
+
installedAt: string;
|
|
18
|
+
source: string;
|
|
19
|
+
configSchema?: ModuleConfigSchema;
|
|
20
|
+
skillCount?: number;
|
|
21
|
+
scheduleCount?: number;
|
|
22
|
+
/** Server contract addition: GET /api/modules entries may include the module's README (markdown). */
|
|
23
|
+
readme?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface AvailableModule {
|
|
27
|
+
name: string;
|
|
28
|
+
version: string;
|
|
29
|
+
description?: string;
|
|
30
|
+
installed: boolean;
|
|
31
|
+
configSchema?: ModuleConfigSchema;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function useModules(getToken: () => Promise<string | null>, enabled: boolean, refreshKey = 0) {
|
|
35
|
+
const [installed, setInstalled] = useState<InstalledModule[]>([]);
|
|
36
|
+
const [available, setAvailable] = useState<AvailableModule[]>([]);
|
|
37
|
+
const [unsupported, setUnsupported] = useState(false); // server predates /api/modules (404)
|
|
38
|
+
const [loading, setLoading] = useState(false);
|
|
39
|
+
const [error, setError] = useState<string | null>(null);
|
|
40
|
+
|
|
41
|
+
const refresh = useCallback(async () => {
|
|
42
|
+
setLoading(true);
|
|
43
|
+
setError(null);
|
|
44
|
+
try {
|
|
45
|
+
const data = await api<{ installed: InstalledModule[]; available: AvailableModule[] }>('/api/modules', getToken);
|
|
46
|
+
setInstalled(data.installed ?? []);
|
|
47
|
+
setAvailable(data.available ?? []);
|
|
48
|
+
setUnsupported(false);
|
|
49
|
+
} catch (e: any) {
|
|
50
|
+
// Note: this detection assumes a pre-modules server 404 carries no JSON `error` field (message stays "404 Not Found").
|
|
51
|
+
if (/^404\b/.test(e.message)) setUnsupported(true);
|
|
52
|
+
else setError(e.message);
|
|
53
|
+
} finally {
|
|
54
|
+
setLoading(false);
|
|
55
|
+
}
|
|
56
|
+
}, [getToken]);
|
|
57
|
+
|
|
58
|
+
useEffect(() => { if (enabled) refresh(); }, [enabled, refresh, refreshKey]);
|
|
59
|
+
|
|
60
|
+
const install = useCallback(async (ref: { name?: string; path?: string }) => {
|
|
61
|
+
await api('/api/modules/install', getToken, { method: 'POST', body: JSON.stringify(ref) });
|
|
62
|
+
await refresh();
|
|
63
|
+
}, [getToken, refresh]);
|
|
64
|
+
|
|
65
|
+
const toggle = useCallback(async (name: string, en: boolean) => {
|
|
66
|
+
await api(`/api/modules/${name}/${en ? 'enable' : 'disable'}`, getToken, { method: 'POST' });
|
|
67
|
+
await refresh();
|
|
68
|
+
}, [getToken, refresh]);
|
|
69
|
+
|
|
70
|
+
const updateConfig = useCallback(async (name: string, config: Record<string, unknown>) => {
|
|
71
|
+
await api(`/api/modules/${name}/config`, getToken, { method: 'PUT', body: JSON.stringify({ config }) });
|
|
72
|
+
await refresh();
|
|
73
|
+
}, [getToken, refresh]);
|
|
74
|
+
|
|
75
|
+
const uninstall = useCallback(async (name: string) => {
|
|
76
|
+
await api(`/api/modules/${name}`, getToken, { method: 'DELETE' });
|
|
77
|
+
await refresh();
|
|
78
|
+
}, [getToken, refresh]);
|
|
79
|
+
|
|
80
|
+
return { installed, available, unsupported, loading, error, refresh, install, toggle, updateConfig, uninstall };
|
|
81
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Minimal authenticated JSON fetch helper. Throws `Error("<status> <statusText>")` or the server's `error` field. */
|
|
2
|
+
export async function api<T>(path: string, getToken: () => Promise<string | null>, init?: RequestInit): Promise<T> {
|
|
3
|
+
const token = await getToken();
|
|
4
|
+
const res = await fetch(path, {
|
|
5
|
+
...init,
|
|
6
|
+
headers: { Authorization: `Bearer ${token ?? ''}`, 'Content-Type': 'application/json', ...init?.headers },
|
|
7
|
+
});
|
|
8
|
+
if (!res.ok) {
|
|
9
|
+
const body = await res.json().catch(() => ({}));
|
|
10
|
+
throw new Error(body.error || `${res.status} ${res.statusText}`);
|
|
11
|
+
}
|
|
12
|
+
return res.json();
|
|
13
|
+
}
|
|
@@ -1,7 +1,21 @@
|
|
|
1
1
|
import { randomUUID } from '@/lib/utils';
|
|
2
2
|
import type { ChatMessage, MessageBlock } from '@/hooks/useConversation';
|
|
3
3
|
|
|
4
|
-
/**
|
|
4
|
+
/** An !ok response from `apiFetch`, carrying the HTTP `status` so callers can branch on it.
|
|
5
|
+
*
|
|
6
|
+
* WHY a class and not a bare Error: apiFetch THROWS on !ok — it never RETURNS a non-ok response — so
|
|
7
|
+
* the natural-looking `const res = await apiFetch(…); if (res.status === 404) …` is DEAD CODE that
|
|
8
|
+
* never runs. Callers must branch in the `catch`, and a stringified `Error('404 Not Found')` leaves
|
|
9
|
+
* them parsing the message to do it. This shipped as a real bug: a dead-PTY cwd poller kept 404ing
|
|
10
|
+
* every 3s forever because its "stop on 404" test sat on the return value. */
|
|
11
|
+
export class ApiError extends Error {
|
|
12
|
+
constructor(public readonly status: number, statusText: string) {
|
|
13
|
+
super(`${status} ${statusText}`);
|
|
14
|
+
this.name = 'ApiError';
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Authenticated fetch with bearer token + timeout. Throws `ApiError` (with `.status`) on !ok. */
|
|
5
19
|
export async function apiFetch(
|
|
6
20
|
path: string,
|
|
7
21
|
getToken: () => Promise<string | null>,
|
|
@@ -19,7 +33,7 @@ export async function apiFetch(
|
|
|
19
33
|
signal: controller.signal,
|
|
20
34
|
headers: { Authorization: `Bearer ${token}`, ...init?.headers },
|
|
21
35
|
});
|
|
22
|
-
if (!res.ok) throw new
|
|
36
|
+
if (!res.ok) throw new ApiError(res.status, res.statusText);
|
|
23
37
|
return res;
|
|
24
38
|
} finally {
|
|
25
39
|
clearTimeout(timer);
|
|
@@ -11,6 +11,8 @@ export interface AgentConfig {
|
|
|
11
11
|
systemPrompt?: string;
|
|
12
12
|
thinking?: 'adaptive' | 'enabled' | 'disabled';
|
|
13
13
|
effort?: 'low' | 'medium' | 'high' | 'max';
|
|
14
|
+
/** Derived server state (read-only, not persisted): claude-code credentials in use — see /api/config. */
|
|
15
|
+
claudeAuthSource?: 'subscription' | 'api-key';
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
/**
|
package/src/mcp-stdio-bridge.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
// stdio↔HTTP bridge for MCP (Streamable HTTP transport, maintains session ID)
|
|
3
|
-
//
|
|
4
|
-
const baseUrl = (process.env.MCP_URL || process.env.UNCLAW_URL || 'http://localhost:3033').replace(/\/$/, '');
|
|
5
|
-
const mcpPath = process.env.MCP_PATH || '/mcp';
|
|
6
|
-
const apiKey = process.env.MCP_API_KEY || process.env.UNCLAW_API_KEY;
|
|
7
|
-
if (!apiKey) { console.error('[mcp-bridge]
|
|
3
|
+
// Env: SHRAGA_URL + SHRAGA_API_KEY (preferred), or generic MCP_URL + MCP_API_KEY, or legacy UNCLAW_*
|
|
4
|
+
const baseUrl = (process.env.SHRAGA_URL || process.env.MCP_URL || process.env.UNCLAW_URL || 'http://localhost:3033').replace(/\/$/, '');
|
|
5
|
+
const mcpPath = process.env.SHRAGA_MCP_PATH || process.env.MCP_PATH || '/mcp';
|
|
6
|
+
const apiKey = process.env.SHRAGA_API_KEY || process.env.MCP_API_KEY || process.env.UNCLAW_API_KEY;
|
|
7
|
+
if (!apiKey) { console.error('[mcp-bridge] SHRAGA_API_KEY (or MCP_API_KEY/UNCLAW_API_KEY) is required'); process.exit(1); }
|
|
8
8
|
|
|
9
9
|
let sessionId: string | null = null;
|
|
10
10
|
|
package/src/server/boot.ts
CHANGED
|
@@ -12,6 +12,7 @@ process.on('unhandledRejection', (reason) => {
|
|
|
12
12
|
console.error('[server] Unhandled rejection (kept alive):', msg);
|
|
13
13
|
});
|
|
14
14
|
import { createServer } from 'node:http';
|
|
15
|
+
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
15
16
|
import { execSync } from 'node:child_process';
|
|
16
17
|
import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync } from 'node:fs';
|
|
17
18
|
import path from 'node:path';
|
|
@@ -20,7 +21,7 @@ import express from 'express';
|
|
|
20
21
|
import { WebSocketServer, WebSocket } from 'ws';
|
|
21
22
|
import { requireAuth, verifyBearer, AUTH_PROVIDER, localLogin, addLocalUser, localUserCount } from './auth.ts';
|
|
22
23
|
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 { streamChat, consumeStream, getAgentConfig, saveAgentConfig, getClaudeAuthSource, type AgentConfig, type PermissionHandler, type QuestionHandler, type QuestionAnswers, type AttachmentMeta, type WsEvent } from './claude.ts';
|
|
24
25
|
import { mountFeatures, registerFeature, resumeFeatureSession, collectFeatureFlags, collectSidecarRoutes } from './features.ts';
|
|
25
26
|
import { registerSpaCatchAll } from './spa-catchall.ts';
|
|
26
27
|
import { slackFeature } from './slack/feature.ts';
|
|
@@ -36,9 +37,11 @@ import type { Schedule } from './scheduler/index.ts';
|
|
|
36
37
|
import { listSkills, listMcpCommands, getSkill, saveSkill, deleteSkill, duplicateSkill, renameSkill, getDefaultSkills, setDefaultSkills, resolveDefaultSkillsContent, purgeExpiredSkills, lintSkills } from './skills.ts';
|
|
37
38
|
import { listWorkspaceTree, listWorkspaceDir, readWorkspaceFile, safeResolve as resolveWorkspacePath, watchWorkspace, ensureDir as ensureWorkspaceDir } from './workspace.ts';
|
|
38
39
|
import { seedDefaults, getBuiltinSkillNames } from './seed.ts';
|
|
40
|
+
import { registerModuleRoutes, reconcileInstalledModules } from './modules/index.ts';
|
|
39
41
|
import { hydrateSlackUserToken } from './slack/oauth.ts';
|
|
40
42
|
import { registerMcpOAuthRoutes } from './mcp-oauth.ts';
|
|
41
43
|
import { registerEventRoutes } from './events/routes.ts';
|
|
44
|
+
import { registerWebhook } from './events/webhook.ts';
|
|
42
45
|
import { startEventDispatcher } from './events/dispatcher.ts';
|
|
43
46
|
import { seedOperators } from './contacts.ts';
|
|
44
47
|
import { dataSync } from './data-sync.ts';
|
|
@@ -47,7 +50,7 @@ import { lookupIdempotent, rememberIdempotent } from './idempotency.ts';
|
|
|
47
50
|
import { createApiKey, deleteApiKey, listApiKeys } from './api-keys.ts';
|
|
48
51
|
import { addUnread, markRead as markUnread, getUnreads } from './unread.ts';
|
|
49
52
|
|
|
50
|
-
import { loadShragaConfig } from './shraga-config.ts';
|
|
53
|
+
import { loadShragaConfig, getPublicOrigin } from './shraga-config.ts';
|
|
51
54
|
import { startSidecars, stopSidecars } from './mcp-sidecar.ts';
|
|
52
55
|
import { syncVendorRepos } from './vendor-sync.ts';
|
|
53
56
|
import { initEngines, getAvailableEngines, getEngine } from './engine/index.ts';
|
|
@@ -297,11 +300,14 @@ app.put('/api/mcps', requireAuth, (req, res) => {
|
|
|
297
300
|
});
|
|
298
301
|
|
|
299
302
|
app.get('/api/config', requireAuth, (_req, res) => {
|
|
300
|
-
|
|
303
|
+
// `claudeAuthSource` is derived server state (not persisted config) — the spread always overrides
|
|
304
|
+
// any stale value, so it can never round-trip into agent-config.json even if a client echoes it back.
|
|
305
|
+
res.json({ ...getAgentConfig(), claudeAuthSource: getClaudeAuthSource() });
|
|
301
306
|
});
|
|
302
307
|
|
|
303
308
|
app.put('/api/config', requireAuth, (req, res) => {
|
|
304
|
-
|
|
309
|
+
const { claudeAuthSource: _drop, ...config } = (req.body ?? {}) as AgentConfig & { claudeAuthSource?: string };
|
|
310
|
+
saveAgentConfig(config);
|
|
305
311
|
res.json({ ok: true });
|
|
306
312
|
});
|
|
307
313
|
|
|
@@ -365,6 +371,9 @@ app.put('/api/skills-defaults', requireAuth, (req, res) => {
|
|
|
365
371
|
res.json({ ok: true });
|
|
366
372
|
});
|
|
367
373
|
|
|
374
|
+
// ── Data-plane modules ───────────────────────────────────────────────────────
|
|
375
|
+
registerModuleRoutes(app, requireAuth);
|
|
376
|
+
|
|
368
377
|
// ── Schedules ────────────────────────────────────────────────────────────────
|
|
369
378
|
|
|
370
379
|
function scheduleIfVisible(id: string, uid: string, isOwner = false): Schedule | undefined {
|
|
@@ -932,7 +941,14 @@ function broadcast(data: object, exclude?: WebSocket) {
|
|
|
932
941
|
setBroadcaster(broadcast); // let session-bus push async events (e.g. an add-on's background worker output) to clients
|
|
933
942
|
ensureWorkspaceDir();
|
|
934
943
|
watchWorkspace((event) => broadcast({ type: 'workspace_change', ...event }));
|
|
935
|
-
if (!PASSIVE) {
|
|
944
|
+
if (!PASSIVE) {
|
|
945
|
+
scheduler.start(broadcast);
|
|
946
|
+
startEventDispatcher();
|
|
947
|
+
// Modules reconcile MUST follow scheduler.start(): upsertSchedule mutates the engine's
|
|
948
|
+
// in-memory list, which is empty (and would be saved over schedules.json) before start.
|
|
949
|
+
// Skipped when passive (single-active-writer: no data/ mutations from a standby twin).
|
|
950
|
+
try { reconcileInstalledModules(); } catch (err) { console.error('[modules] boot reconcile failed:', (err as Error).message); }
|
|
951
|
+
}
|
|
936
952
|
// Host telemetry is read-only (no persistence, unref'd timer) — not a writer or consumer, so it
|
|
937
953
|
// runs in passive too. Otherwise a standby instance reports empty stats and /api/stats is a lie.
|
|
938
954
|
statsSampler.start(broadcast);
|
|
@@ -945,7 +961,7 @@ initPolls({
|
|
|
945
961
|
// Remote-push triggers: subscribe to schedule.finished and expose turn-done/question
|
|
946
962
|
// hooks. isForeground reuses the existing presence tracking (see isUserViewingSession).
|
|
947
963
|
initPushTriggers({
|
|
948
|
-
origin:
|
|
964
|
+
origin: getPublicOrigin(),
|
|
949
965
|
isForeground: (uid, sessionId) => isUserViewingSession(uid, sessionId),
|
|
950
966
|
});
|
|
951
967
|
// Optional add-ons (voice, github, gmail, fleet, …) mount here through the feature seam.
|
|
@@ -1002,12 +1018,62 @@ app.post('/internal/activate', async (req, res) => {
|
|
|
1002
1018
|
res.json({ ok: true });
|
|
1003
1019
|
});
|
|
1004
1020
|
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
const
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1021
|
+
// Constant-time equality for two strings (avoids leaking length/content via timing).
|
|
1022
|
+
function safeStrEqual(a: string, b: string): boolean {
|
|
1023
|
+
const ab = Buffer.from(a), bb = Buffer.from(b);
|
|
1024
|
+
return ab.length === bb.length && timingSafeEqual(ab, bb);
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
// Verify GitHub's HMAC signature (x-hub-signature-256 = "sha256=" + HMAC-SHA256(secret, RAW BODY)).
|
|
1028
|
+
// Must run over the EXACT bytes GitHub sent, captured as req.rawBody by express.json's verify hook —
|
|
1029
|
+
// re-serializing req.body would change the bytes and never match.
|
|
1030
|
+
function validGithubSignature(req: express.Request, secret: string): boolean {
|
|
1031
|
+
const header = req.headers['x-hub-signature-256'];
|
|
1032
|
+
if (typeof header !== 'string') return false;
|
|
1033
|
+
const raw = (req as any).rawBody as Buffer | undefined;
|
|
1034
|
+
if (!raw) return false;
|
|
1035
|
+
const expected = 'sha256=' + createHmac('sha256', secret).update(raw).digest('hex');
|
|
1036
|
+
return safeStrEqual(header, expected);
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
// Data-sync webhook, ported to shraga's first-class registerWebhook convention: the framework mounts
|
|
1040
|
+
// the PUBLIC route, captures rawBody, runs `verify` (the ONLY per-vendor piece — the HMAC below),
|
|
1041
|
+
// rejects on a falsy result, and on a pass emits the 'data-sync.pull' event. The subscribeEvent below
|
|
1042
|
+
// (gated on active/non-passive) consumes it and calls dataSync.pull(). Mounted on `app` at the
|
|
1043
|
+
// SAME position the bespoke route occupied — before the SPA catch-all — via the `path` option, so the
|
|
1044
|
+
// GitHub webhook needs no reconfig.
|
|
1045
|
+
//
|
|
1046
|
+
// Semantic deltas vs the old inline route (documented, deliberate):
|
|
1047
|
+
// • bad signature → 400 (was 403): registerWebhook's convention is 400 for any falsy verify. GitHub
|
|
1048
|
+
// treats any non-2xx as a failed delivery, so redelivery/behavior is unchanged.
|
|
1049
|
+
// • not-activated / data-sync disabled → 200 (was 404): the route now always accepts + emits; the
|
|
1050
|
+
// PULL is what's gated. In passive/standby no pull handler is subscribed (single-active-writer),
|
|
1051
|
+
// and pull() itself no-ops when disabled — so no data is mutated. The webhook only ever targets
|
|
1052
|
+
// the active instance, so the visible-status change is inert in practice.
|
|
1053
|
+
// • response no longer awaits the pull: 200 is returned on emit and pull() runs async. This is the
|
|
1054
|
+
// convention (and safer — a git pull must not block GitHub's ~10s webhook timeout).
|
|
1055
|
+
registerWebhook(app, {
|
|
1056
|
+
source: 'data-sync.pull',
|
|
1057
|
+
path: '/api/data-sync/webhook',
|
|
1058
|
+
// Reuse the verified-correct crypto below verbatim — signature-only; activation/enablement gating
|
|
1059
|
+
// lives on the pull handler, not here.
|
|
1060
|
+
verify: (req) => {
|
|
1061
|
+
const secret = process.env.DATA_SYNC_WEBHOOK_SECRET;
|
|
1062
|
+
if (!secret) return true; // secret unset → open (current live behavior)
|
|
1063
|
+
// Accept EITHER a valid GitHub HMAC signature OR the legacy plain header (backward-compat).
|
|
1064
|
+
const plain = req.headers['x-webhook-secret'];
|
|
1065
|
+
return validGithubSignature(req, secret) || (typeof plain === 'string' && safeStrEqual(plain, secret));
|
|
1066
|
+
},
|
|
1067
|
+
});
|
|
1068
|
+
|
|
1069
|
+
// Consume the verified webhook's event → pull. The PULL is gated on active (non-passive) via the live
|
|
1070
|
+
// `activated`/`PASSIVE` closure: a passive/standby twin shares DATA_DIR and must NOT mutate data/
|
|
1071
|
+
// (single-active-writer), and before promotion there's nothing to pull into serving. `activated` flips
|
|
1072
|
+
// true in activateConsumers(), so a promoted instance starts pulling with no extra wiring. pull() also
|
|
1073
|
+
// self-no-ops when data-sync is disabled.
|
|
1074
|
+
subscribeEvent('data-sync.pull', () => {
|
|
1075
|
+
if (PASSIVE || !activated) return;
|
|
1076
|
+
dataSync.pull().catch((err) => console.error('[data-sync] webhook pull failed:', (err as Error).message));
|
|
1011
1077
|
});
|
|
1012
1078
|
|
|
1013
1079
|
async function runStream(ws: WebSocket, session: WsSession, sid: string, promptText: string, attachments: AttachmentMeta[] | undefined, mcpServers: McpConfig, isSteerRestart = false, conversationReset = false, turnHints?: Record<string, unknown>) {
|
|
@@ -1204,6 +1270,10 @@ async function runStream(ws: WebSocket, session: WsSession, sid: string, promptT
|
|
|
1204
1270
|
console.log(`[ws] Suppressing error during steer for ${sid.slice(0, 8)}`);
|
|
1205
1271
|
} else {
|
|
1206
1272
|
console.error(`[ws] Error event for ${session.email}: ${event.message}`);
|
|
1273
|
+
// Persist alongside whatever partial output the turn produced — flushAssistant() saves it.
|
|
1274
|
+
if (thinkingText) { assistantBlocks.push({ type: 'thinking', text: thinkingText }); thinkingText = ''; }
|
|
1275
|
+
if (assistantText) { assistantBlocks.push({ type: 'text', text: assistantText }); assistantText = ''; }
|
|
1276
|
+
assistantBlocks.push({ type: 'error', text: event.message });
|
|
1207
1277
|
send(ws, { type: 'error', message: event.message, sessionId: sid });
|
|
1208
1278
|
}
|
|
1209
1279
|
break;
|
|
@@ -1218,8 +1288,14 @@ async function runStream(ws: WebSocket, session: WsSession, sid: string, promptT
|
|
|
1218
1288
|
console.log(`[ws] Stream aborted for steer in ${sid.slice(0, 8)}`);
|
|
1219
1289
|
} else {
|
|
1220
1290
|
stopReason = 'error';
|
|
1221
|
-
|
|
1222
|
-
|
|
1291
|
+
const msg = err.message || String(err);
|
|
1292
|
+
console.error(`[ws] Stream error for ${session.email}:`, msg);
|
|
1293
|
+
// Flush partial output first — flushAssistant() appends it in the finally, which would
|
|
1294
|
+
// otherwise order the failure ahead of the text it followed.
|
|
1295
|
+
if (thinkingText) { assistantBlocks.push({ type: 'thinking', text: thinkingText }); thinkingText = ''; }
|
|
1296
|
+
if (assistantText) { assistantBlocks.push({ type: 'text', text: assistantText }); assistantText = ''; }
|
|
1297
|
+
assistantBlocks.push({ type: 'error', text: msg });
|
|
1298
|
+
send(ws, { type: 'error', message: msg, sessionId: sid });
|
|
1223
1299
|
}
|
|
1224
1300
|
} finally {
|
|
1225
1301
|
clearInterval(partialInterval);
|
|
@@ -1561,7 +1637,8 @@ async function retryWebSession(session: SessionMeta, prompt: string) {
|
|
|
1561
1637
|
} else if (ev.type === 'done') {
|
|
1562
1638
|
break;
|
|
1563
1639
|
} else if (ev.type === 'error') {
|
|
1564
|
-
assistantText
|
|
1640
|
+
if (assistantText) { assistantBlocks.push({ type: 'text', text: assistantText }); assistantText = ''; }
|
|
1641
|
+
assistantBlocks.push({ type: 'error', text: ev.message });
|
|
1565
1642
|
break;
|
|
1566
1643
|
}
|
|
1567
1644
|
}
|
|
@@ -1569,7 +1646,9 @@ async function retryWebSession(session: SessionMeta, prompt: string) {
|
|
|
1569
1646
|
if (assistantBlocks.length) {
|
|
1570
1647
|
appendMessage(sid, { id: crypto.randomUUID(), role: 'assistant', blocks: assistantBlocks });
|
|
1571
1648
|
broadcast({ type: 'session_messages_changed', sessionId: sid });
|
|
1572
|
-
const
|
|
1649
|
+
const errBlock = assistantBlocks.find((b) => b.type === 'error');
|
|
1650
|
+
const preview = assistantText.slice(0, 120)
|
|
1651
|
+
|| (errBlock?.type === 'error' ? `⚠️ ${errBlock.text}`.slice(0, 120) : '(completed)');
|
|
1573
1652
|
notifyUnread(session.uid, sid, preview, 'response', session.title);
|
|
1574
1653
|
}
|
|
1575
1654
|
} catch (err: any) {
|
package/src/server/claude.ts
CHANGED
|
@@ -52,6 +52,16 @@ export function getAgentConfig(): AgentConfig {
|
|
|
52
52
|
return config;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Which credentials the claude-code SDK will resolve — process-global, derived from env at call time
|
|
57
|
+
* (an API-key env var wins over a stored claude.ai login, matching the SDK's own precedence). Surfaced
|
|
58
|
+
* to the UI so the header shows subscription-vs-API-key immediately, without waiting for a turn. The
|
|
59
|
+
* per-turn ground truth (SDK `apiKeySource`) is logged by the engine.
|
|
60
|
+
*/
|
|
61
|
+
export function getClaudeAuthSource(): 'subscription' | 'api-key' {
|
|
62
|
+
return process.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_AUTH_TOKEN ? 'api-key' : 'subscription';
|
|
63
|
+
}
|
|
64
|
+
|
|
55
65
|
export function saveAgentConfig(config: AgentConfig): void {
|
|
56
66
|
mkdirSync(DATA_DIR, { recursive: true });
|
|
57
67
|
writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
|
|
@@ -382,7 +392,8 @@ export async function consumeStream(stream: AsyncGenerator<WsEvent>, onEvent?: (
|
|
|
382
392
|
} else if (ev.type === 'done') {
|
|
383
393
|
break;
|
|
384
394
|
} else if (ev.type === 'error') {
|
|
385
|
-
text
|
|
395
|
+
if (text) { blocks.push({ type: 'text', text }); text = ''; }
|
|
396
|
+
blocks.push({ type: 'error', text: ev.message });
|
|
386
397
|
break;
|
|
387
398
|
}
|
|
388
399
|
}
|
package/src/server/data-sync.ts
CHANGED
|
@@ -24,6 +24,8 @@ export class DataSync {
|
|
|
24
24
|
private pending = new Set<string>();
|
|
25
25
|
private timer: ReturnType<typeof setTimeout> | null = null;
|
|
26
26
|
private pushing = false;
|
|
27
|
+
private pulling = false;
|
|
28
|
+
private pullPending = false;
|
|
27
29
|
private ready = false;
|
|
28
30
|
private warnedDisabled = false;
|
|
29
31
|
|
|
@@ -181,6 +183,32 @@ export class DataSync {
|
|
|
181
183
|
}
|
|
182
184
|
|
|
183
185
|
async pull(): Promise<void> {
|
|
186
|
+
// Coalesce overlapping triggers WITHOUT dropping any. Serializing pulls is required — concurrent
|
|
187
|
+
// stash/merge/pop on shared uncommitted state would corrupt the worktree. But a trigger that
|
|
188
|
+
// arrives mid-pull (webhook B fires while pull A is already past its `git fetch`) references a
|
|
189
|
+
// commit A will NOT see, so silently skipping B leaves a pure-consumer permanently stale (no
|
|
190
|
+
// periodic poller catches up). Instead, mark work pending and guarantee exactly one follow-up
|
|
191
|
+
// pass after the current one — mirrors the push-side pending/re-run pattern (flush()).
|
|
192
|
+
if (this.pulling) {
|
|
193
|
+
this.pullPending = true;
|
|
194
|
+
console.log(`${TAG} Pull in progress — queued a follow-up (coalesced)`);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
this.pulling = true;
|
|
198
|
+
try {
|
|
199
|
+
do {
|
|
200
|
+
// Clear BEFORE the pass: a trigger during this _pull() re-sets it → one more loop.
|
|
201
|
+
this.pullPending = false;
|
|
202
|
+
await this._pull();
|
|
203
|
+
} while (this.pullPending);
|
|
204
|
+
} finally {
|
|
205
|
+
// Reset both so a throw mid-pass can never wedge the lock or a stale pending flag.
|
|
206
|
+
this.pulling = false;
|
|
207
|
+
this.pullPending = false;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private async _pull(): Promise<void> {
|
|
184
212
|
if (!this.isEnabled()) return;
|
|
185
213
|
try {
|
|
186
214
|
await this.git('fetch', 'origin', this.options.branch);
|
|
@@ -13,8 +13,7 @@ import { resolveModelSwitch } from '../model-aliases.ts';
|
|
|
13
13
|
import type { WsEvent, AskQuestion, QuestionAnswers, QuestionHandler } from '../claude.ts';
|
|
14
14
|
import type { AgentEngine, EngineStreamOpts, EngineModel } from './types.ts';
|
|
15
15
|
import { getPromptSuffix } from '../prompt-suffix.ts';
|
|
16
|
-
|
|
17
|
-
const PROJECT_ROOT = path.resolve(import.meta.dirname, '..', '..', '..');
|
|
16
|
+
import { APP_ROOT } from '../paths.ts';
|
|
18
17
|
const IMMUTABLE_SYSTEM_PROMPT = readFileSync(path.resolve(import.meta.dirname, '../../../defaults/system-prompt.md'), 'utf-8');
|
|
19
18
|
const DEFAULT_USER_PROMPT = `You are a helpful assistant with access to MCP tools.`;
|
|
20
19
|
const DEFAULT_ALLOWED_TOOLS = ['Read', 'Edit', 'Bash', 'WebSearch', 'Glob', 'LS', 'ToolSearch'];
|
|
@@ -175,7 +174,7 @@ export class ClaudeCodeEngine implements AgentEngine {
|
|
|
175
174
|
|
|
176
175
|
async *stream(opts: EngineStreamOpts): AsyncGenerator<WsEvent> {
|
|
177
176
|
const { config, directives } = opts;
|
|
178
|
-
const cwd =
|
|
177
|
+
const cwd = APP_ROOT;
|
|
179
178
|
|
|
180
179
|
const fullPrompt = buildHistoryPrompt(opts.conversation, opts.contextBlock, opts.prompt);
|
|
181
180
|
const permMode = opts.onPermissionRequest ? 'default' : (config.permissionMode ?? 'acceptEdits');
|
|
@@ -334,6 +333,14 @@ export class ClaudeCodeEngine implements AgentEngine {
|
|
|
334
333
|
}
|
|
335
334
|
|
|
336
335
|
if (m.type === 'system' && m.subtype === 'init') {
|
|
336
|
+
// Which credentials the SDK actually resolved (env key vs claude.ai login) — a process-global
|
|
337
|
+
// fact surfaced per-run so logs (incl. headless/scheduler) answer "subscription or API key?".
|
|
338
|
+
// Runtime `apiKeySource` is the RESOLVED source string (verified against the SDK, not its .d.ts
|
|
339
|
+
// enum): a named env var ("ANTHROPIC_API_KEY") ⇒ API key; "none"/"oauth" ⇒ stored login ⇒ sub.
|
|
340
|
+
const src = m.apiKeySource as string | undefined;
|
|
341
|
+
const authSource: 'subscription' | 'api-key' | undefined =
|
|
342
|
+
src == null ? undefined : src === 'none' || src === 'oauth' ? 'subscription' : 'api-key';
|
|
343
|
+
if (authSource) console.log(`[claude] Auth: ${authSource} (apiKeySource=${src})`);
|
|
337
344
|
if (m.model) {
|
|
338
345
|
console.log(`[claude] Init model=${m.model}${m.model !== options['model'] ? ` (requested ${options['model']})` : ''}`);
|
|
339
346
|
// If the user explicitly asked to switch models via a [directive], announce the change
|
|
@@ -24,6 +24,9 @@ export interface ShragaEventMap {
|
|
|
24
24
|
name?: string;
|
|
25
25
|
status?: string;
|
|
26
26
|
sessionId?: string;
|
|
27
|
+
/** Absolute link to the run's session. Present only when a public origin is configured
|
|
28
|
+
* (see getSessionUrl) — a scheduled run has no request to derive one from. */
|
|
29
|
+
sessionUrl?: string;
|
|
27
30
|
error?: string;
|
|
28
31
|
};
|
|
29
32
|
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { spawn, type ChildProcess } from 'node:child_process';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { getHttpSidecarSpecs, type HttpSidecarSpec } from './shraga-config.ts';
|
|
4
|
+
import { APP_ROOT } from './paths.ts';
|
|
4
5
|
|
|
5
|
-
const PROJECT_ROOT = path.resolve(import.meta.dirname, '..', '..');
|
|
6
6
|
const sidecars = new Map<string, { proc: ChildProcess; spec: HttpSidecarSpec }>();
|
|
7
7
|
|
|
8
8
|
async function isPortAlive(url: string): Promise<boolean> {
|
|
@@ -17,7 +17,7 @@ async function isPortAlive(url: string): Promise<boolean> {
|
|
|
17
17
|
let shuttingDown = false;
|
|
18
18
|
|
|
19
19
|
function startOne(spec: HttpSidecarSpec, restarts = 0) {
|
|
20
|
-
const vendorDir = path.join(
|
|
20
|
+
const vendorDir = path.join(APP_ROOT, 'vendor', spec.dir);
|
|
21
21
|
const entrypoint = path.join(vendorDir, 'src/mcp/cli.ts');
|
|
22
22
|
const args = ['run', entrypoint, '--port', String(spec.port)];
|
|
23
23
|
const startedAt = Date.now();
|
package/src/server/mcp.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { dataPath } from './paths.ts';
|
|
3
|
+
import { dataPath, APP_ROOT } from './paths.ts';
|
|
4
4
|
import { dataSync } from './data-sync.ts';
|
|
5
5
|
import { getGlobalMcpsFromConfig } from './shraga-config.ts';
|
|
6
6
|
|
|
@@ -151,8 +151,6 @@ function withStdioType(config: McpConfig): McpConfig {
|
|
|
151
151
|
return out;
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
-
const PROJECT_ROOT = path.resolve(import.meta.dirname, '..', '..');
|
|
155
|
-
|
|
156
154
|
/** Canonical path baked into MCP env when prod has the file deployed (cwd = app dir). */
|
|
157
155
|
const GOOGLE_SA_DEPLOY_REL = './secrets/google-service-account.json';
|
|
158
156
|
|
|
@@ -163,7 +161,7 @@ const GOOGLE_SA_DEPLOY_REL = './secrets/google-service-account.json';
|
|
|
163
161
|
*/
|
|
164
162
|
function finalizeGoogleServiceAccountCredentials(config: McpConfig): McpConfig {
|
|
165
163
|
const jsonFromEnv = process.env.GOOGLE_SERVICE_ACCOUNT_JSON?.trim();
|
|
166
|
-
const defaultAbs = path.join(
|
|
164
|
+
const defaultAbs = path.join(APP_ROOT, 'secrets/google-service-account.json');
|
|
167
165
|
const defaultExists = existsSync(defaultAbs);
|
|
168
166
|
|
|
169
167
|
let result = { ...config };
|
|
@@ -184,7 +182,7 @@ function finalizeGoogleServiceAccountCredentials(config: McpConfig): McpConfig {
|
|
|
184
182
|
const pathOk =
|
|
185
183
|
raw &&
|
|
186
184
|
!raw.includes('${') &&
|
|
187
|
-
existsSync(path.isAbsolute(raw) ? raw : path.resolve(
|
|
185
|
+
existsSync(path.isAbsolute(raw) ? raw : path.resolve(APP_ROOT, raw.replace(/^\.\//, '')));
|
|
188
186
|
|
|
189
187
|
if (pathOk) continue;
|
|
190
188
|
if (!defaultExists) continue;
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export * from './types.ts';
|
|
2
|
+
export { loadState, getInstalled, readManifest, listAvailableModules, renderTemplate, renderDeep, effectiveConfig, reconcileInstalledModules, installModule, enableModule, disableModule, setModuleConfig, uninstallModule } from './service.ts';
|
|
3
|
+
export { registerModuleRoutes } from './routes.ts';
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/** REST surface for data-plane modules. Reads: any authed user. Mutations: owner-gated
|
|
2
|
+
* (modules are system-scope, like schedule edits on records you don't own). */
|
|
3
|
+
import type { Express, RequestHandler, Request, Response } from 'express';
|
|
4
|
+
import { loadState, listAvailableModules, installModule, enableModule, disableModule, setModuleConfig, uninstallModule, readManifest, readModuleReadme } from './service.ts';
|
|
5
|
+
import { dataPath } from '../paths.ts';
|
|
6
|
+
|
|
7
|
+
function ownerOnly(req: Request, res: Response): boolean {
|
|
8
|
+
const user = (req as any).user;
|
|
9
|
+
if (user?.isOwner) return true;
|
|
10
|
+
res.status(403).json({ error: 'Only an owner can manage modules' });
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function registerModuleRoutes(app: Express, requireAuth: RequestHandler): void {
|
|
15
|
+
app.get('/api/modules', requireAuth, (_req, res) => {
|
|
16
|
+
const installed = loadState().installed.map((rec) => {
|
|
17
|
+
let manifest = null;
|
|
18
|
+
try { manifest = readManifest(dataPath('modules', rec.name)); } catch { /* folder missing */ }
|
|
19
|
+
return {
|
|
20
|
+
...rec,
|
|
21
|
+
manifest,
|
|
22
|
+
description: manifest?.description,
|
|
23
|
+
configSchema: manifest?.configSchema,
|
|
24
|
+
readme: readModuleReadme(rec.name, 'installed'),
|
|
25
|
+
skillCount: manifest?.skills?.length ?? 0,
|
|
26
|
+
scheduleCount: manifest?.schedules?.length ?? 0,
|
|
27
|
+
};
|
|
28
|
+
});
|
|
29
|
+
const installedNames = new Set(installed.map((m) => m.name));
|
|
30
|
+
const available = listAvailableModules()
|
|
31
|
+
.filter((m) => !installedNames.has(m.name))
|
|
32
|
+
.map((m) => ({ ...m, readme: readModuleReadme(m.name, 'builtin') }));
|
|
33
|
+
res.json({ installed, available });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
app.post('/api/modules/install', requireAuth, (req, res) => {
|
|
37
|
+
if (!ownerOnly(req, res)) return;
|
|
38
|
+
const { name, path: folder } = req.body ?? {};
|
|
39
|
+
try {
|
|
40
|
+
const rec = installModule({ name: typeof name === 'string' ? name : undefined, path: typeof folder === 'string' ? folder : undefined });
|
|
41
|
+
res.json(rec);
|
|
42
|
+
} catch (err) {
|
|
43
|
+
res.status(400).json({ error: (err as Error).message });
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
app.post('/api/modules/:name/enable', requireAuth, (req, res) => {
|
|
48
|
+
if (!ownerOnly(req, res)) return;
|
|
49
|
+
try { res.json(enableModule(String(req.params.name))); }
|
|
50
|
+
catch (err) { res.status(404).json({ error: (err as Error).message }); }
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
app.post('/api/modules/:name/disable', requireAuth, (req, res) => {
|
|
54
|
+
if (!ownerOnly(req, res)) return;
|
|
55
|
+
try { res.json(disableModule(String(req.params.name))); }
|
|
56
|
+
catch (err) { res.status(404).json({ error: (err as Error).message }); }
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
app.put('/api/modules/:name/config', requireAuth, (req, res) => {
|
|
60
|
+
if (!ownerOnly(req, res)) return;
|
|
61
|
+
const { config } = req.body ?? {};
|
|
62
|
+
if (!config || typeof config !== 'object' || Array.isArray(config)) {
|
|
63
|
+
res.status(400).json({ error: 'Body must be {"config": {...}}' });
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
try { res.json(setModuleConfig(String(req.params.name), config)); }
|
|
67
|
+
catch (err) {
|
|
68
|
+
const msg = (err as Error).message;
|
|
69
|
+
res.status(msg.includes('not installed') ? 404 : 400).json({ error: msg });
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
app.delete('/api/modules/:name', requireAuth, (req, res) => {
|
|
74
|
+
if (!ownerOnly(req, res)) return;
|
|
75
|
+
try { uninstallModule(String(req.params.name)); res.json({ ok: true }); }
|
|
76
|
+
catch (err) { res.status(404).json({ error: (err as Error).message }); }
|
|
77
|
+
});
|
|
78
|
+
}
|