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.
Files changed (55) hide show
  1. package/defaults/modules/routine/README.md +38 -0
  2. package/defaults/modules/routine/module.json +36 -0
  3. package/defaults/modules/routine/routine.md.tmpl +75 -0
  4. package/defaults/modules/routine/seeds/workspace/agenda.md +15 -0
  5. package/defaults/skills/add-skill.md +4 -0
  6. package/defaults/skills/artifacts.md +4 -0
  7. package/defaults/skills/communications.md +4 -0
  8. package/defaults/skills/create-module.md +75 -0
  9. package/defaults/skills/debug.md +4 -0
  10. package/defaults/skills/garden.md +4 -0
  11. package/defaults/skills/github-contributor.md +4 -0
  12. package/defaults/skills/identity.md +4 -0
  13. package/defaults/skills/mcp-server.md +4 -0
  14. package/defaults/skills/modules.md +41 -0
  15. package/defaults/skills/plan.md +4 -0
  16. package/defaults/skills/reconcile.md +4 -0
  17. package/defaults/skills/scheduler.md +5 -2
  18. package/defaults/skills/self-aware.md +5 -1
  19. package/defaults/skills/write-tests.md +4 -0
  20. package/dist/client/assets/index-D5KxKl57.js +1946 -0
  21. package/dist/client/assets/index-nyZagTjP.css +10 -0
  22. package/dist/client/index.html +2 -2
  23. package/package.json +1 -1
  24. package/src/client/App.tsx +11 -0
  25. package/src/client/components/ChatView.tsx +14 -0
  26. package/src/client/components/ConversationHeader.tsx +29 -0
  27. package/src/client/components/ModulesManager.tsx +259 -0
  28. package/src/client/hooks/useConversation.ts +5 -3
  29. package/src/client/hooks/useModules.ts +81 -0
  30. package/src/client/lib/api.ts +13 -0
  31. package/src/client/lib/sessionApi.ts +16 -2
  32. package/src/client/lib/workspaceContext.tsx +2 -0
  33. package/src/mcp-stdio-bridge.ts +5 -5
  34. package/src/server/boot.ts +95 -16
  35. package/src/server/claude.ts +12 -1
  36. package/src/server/data-sync.ts +28 -0
  37. package/src/server/engine/claude-code.ts +10 -3
  38. package/src/server/events/types.ts +3 -0
  39. package/src/server/mcp-sidecar.ts +2 -2
  40. package/src/server/mcp.ts +3 -5
  41. package/src/server/modules/index.ts +3 -0
  42. package/src/server/modules/routes.ts +78 -0
  43. package/src/server/modules/service.ts +575 -0
  44. package/src/server/modules/types.ts +62 -0
  45. package/src/server/paths.ts +57 -6
  46. package/src/server/scheduler/builtins.ts +27 -3
  47. package/src/server/scheduler/engine.ts +6 -0
  48. package/src/server/scheduler/runner.ts +96 -30
  49. package/src/server/scheduler/types.ts +3 -0
  50. package/src/server/sessions.ts +4 -0
  51. package/src/server/shraga-config.ts +21 -4
  52. package/src/server/skills.ts +6 -5
  53. package/src/server/slack/bot.ts +3 -1
  54. package/dist/client/assets/index-ChElotX8.js +0 -1936
  55. 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
- /** Authenticated fetch with bearer token + timeout. Throws on !ok. */
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 Error(`${res.status} ${res.statusText}`);
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
  /**
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env bun
2
2
  // stdio↔HTTP bridge for MCP (Streamable HTTP transport, maintains session ID)
3
- // Generic: MCP_URL + MCP_API_KEY, or legacy: UNCLAW_URL + UNCLAW_API_KEY
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] MCP_API_KEY (or UNCLAW_API_KEY) is required'); process.exit(1); }
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
 
@@ -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
- res.json(getAgentConfig());
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
- saveAgentConfig(req.body as AgentConfig);
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) { scheduler.start(broadcast); startEventDispatcher(); }
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: process.env.PUBLIC_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
- app.post('/api/data-sync/webhook', async (req, res) => {
1006
- if (!activated || !dataSync.isEnabled()) return res.sendStatus(404);
1007
- const secret = process.env.DATA_SYNC_WEBHOOK_SECRET;
1008
- if (secret && req.headers['x-webhook-secret'] !== secret) return res.sendStatus(403);
1009
- await dataSync.pull();
1010
- res.sendStatus(200);
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
- console.error(`[ws] Stream error for ${session.email}:`, err.message || err);
1222
- send(ws, { type: 'error', message: err.message || String(err), sessionId: sid });
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 += `\n⚠️ ${ev.message}`;
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 preview = assistantText.slice(0, 120) || '(completed)';
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) {
@@ -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 += `\n⚠️ ${ev.message}`;
395
+ if (text) { blocks.push({ type: 'text', text }); text = ''; }
396
+ blocks.push({ type: 'error', text: ev.message });
386
397
  break;
387
398
  }
388
399
  }
@@ -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 = PROJECT_ROOT;
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(PROJECT_ROOT, 'vendor', spec.dir);
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(PROJECT_ROOT, 'secrets/google-service-account.json');
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(PROJECT_ROOT, raw.replace(/^\.\//, '')));
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
+ }