shraga 0.1.55 → 0.1.57

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,41 @@
1
+ # data/mcps/ — what is actually read, and when
2
+
3
+ This directory holds **per-user MCP overlays only**. One file per user:
4
+
5
+ data/mcps/<uid>.json → read by getUserMcpConfig(uid) (src/server/mcp.ts)
6
+
7
+ Nothing else in this directory is loaded. There is **no `_global.json`** — a file by that name
8
+ looks live and is not. Grep the loader before trusting any file here:
9
+
10
+ grep -rn "mcps/" src/server/mcp.ts
11
+
12
+ ## Where globals come from
13
+
14
+ Global MCPs live in **`data/shraga.config.ts`** (legacy name `data/unclaw.config.ts`), read by
15
+ `getGlobalMcpsFromConfig()`. The effective config for a user is:
16
+
17
+ { ...global (shraga.config.ts), ...user (mcps/<uid>.json) } — the user overlay wins
18
+
19
+ Only names present in the **global** config register as `/<name>` MCP commands
20
+ (`listMcpCommands()`), and `PUT /api/mcps` strips any global name from a user overlay before
21
+ saving — so a global MCP cannot be "added" by hand-writing a user file.
22
+
23
+ ## Cached vs live
24
+
25
+ - `data/mcps/<uid>.json` — re-read on every call. Edit it and the next turn sees it.
26
+ - `data/shraga.config.ts` — re-read when its mtime/size changes (`refreshConfig()` in
27
+ `src/server/shraga-config.ts`). Edit it and the next turn sees it too; **no restart needed**.
28
+ If the edited file fails to load, the process keeps the **last-good** config and logs
29
+ `[config] failed to load …` — check the server log before assuming your edit took.
30
+
31
+ ## Verify at runtime, don't assume
32
+
33
+ curl -sH "Authorization: Bearer $KEY" localhost:$PORT/api/mcps | jq 'keys'
34
+
35
+ An entry with `"readonly": true` came from the global config; anything else is this user's overlay.
36
+
37
+ ## Why this file exists
38
+
39
+ An intent-bearing commit ("lock agf-prod RTDB to read-only for the agent") once edited a
40
+ `data/mcps/_global.json` that no loader has ever read. It was a silent no-op, and prod stayed
41
+ writable until the gate was re-implemented in `shraga.config.ts`. Edit only what is read.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shraga",
3
- "version": "0.1.55",
3
+ "version": "0.1.57",
4
4
  "description": "The teammate you delegate coding to — a self-hostable, multi-user AI coding agent web UI (Claude Code, with a pluggable engine seam).",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -1,11 +1,27 @@
1
1
  #!/usr/bin/env bun
2
2
  // stdio↔HTTP bridge for MCP (Streamable HTTP transport, maintains session ID)
3
3
  // Env: SHRAGA_URL + SHRAGA_API_KEY (preferred), or generic MCP_URL + MCP_API_KEY, or legacy UNCLAW_*
4
+ // MCP_EXTRA_HEADERS — optional JSON object of extra headers sent with every request,
5
+ // for servers that need a second credential beyond the bearer token (e.g. an
6
+ // upstream app behind a proxy that gates its own admin ops on its own header).
4
7
  const baseUrl = (process.env.SHRAGA_URL || process.env.MCP_URL || process.env.UNCLAW_URL || 'http://localhost:3033').replace(/\/$/, '');
5
8
  const mcpPath = process.env.SHRAGA_MCP_PATH || process.env.MCP_PATH || '/mcp';
6
9
  const apiKey = process.env.SHRAGA_API_KEY || process.env.MCP_API_KEY || process.env.UNCLAW_API_KEY;
7
10
  if (!apiKey) { console.error('[mcp-bridge] SHRAGA_API_KEY (or MCP_API_KEY/UNCLAW_API_KEY) is required'); process.exit(1); }
8
11
 
12
+ let extraHeaders: Record<string, string> = {};
13
+ if (process.env.MCP_EXTRA_HEADERS) {
14
+ try {
15
+ const parsed = JSON.parse(process.env.MCP_EXTRA_HEADERS);
16
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('must be a JSON object');
17
+ extraHeaders = Object.fromEntries(Object.entries(parsed).map(([k, v]) => [k, String(v)]));
18
+ } catch (e: any) {
19
+ // Fail loudly: silently dropping a credential header would surface far away as a 403.
20
+ console.error(`[mcp-bridge] MCP_EXTRA_HEADERS is not a valid JSON object: ${e.message}`);
21
+ process.exit(1);
22
+ }
23
+ }
24
+
9
25
  let sessionId: string | null = null;
10
26
 
11
27
  async function sendMessage(message: any): Promise<void> {
@@ -15,6 +31,7 @@ async function sendMessage(message: any): Promise<void> {
15
31
  'Accept': 'application/json, text/event-stream',
16
32
  };
17
33
  if (sessionId) headers['mcp-session-id'] = sessionId;
34
+ Object.assign(headers, extraHeaders);
18
35
 
19
36
  const res = await fetch(`${baseUrl}${mcpPath}`, {
20
37
  method: 'POST',
@@ -8,6 +8,64 @@ import { runTextQuery } from './sdk-utils.ts';
8
8
  const TAG = '[data-sync]';
9
9
  const DEPLOYMENT_ID_FILE = '.deployment-id';
10
10
 
11
+ /** How long the LLM commit-message call may take before we fall back (ms). */
12
+ const COMMIT_MSG_TIMEOUT_MS = 60_000;
13
+ /** Warn if the push latch has been held longer than this — the 2026-08 outage's signature was silence. */
14
+ const PUSHING_STUCK_MS = 5 * 60_000;
15
+
16
+ /**
17
+ * Reject after `ms` so a hung subprocess can't hold the push latch forever.
18
+ * `onTimeout` runs on the timeout path only — use it to actually CANCEL the work
19
+ * (Promise.race alone abandons it, which orphaned one `claude` subprocess per timeout).
20
+ */
21
+ export function withTimeout<T>(p: Promise<T>, ms: number, label: string, onTimeout?: () => void): Promise<T> {
22
+ let t: ReturnType<typeof setTimeout>;
23
+ return Promise.race([
24
+ p,
25
+ new Promise<T>((_, rej) => { t = setTimeout(() => { try { onTimeout?.(); } catch { /* cancel is best-effort */ } rej(new Error(`${label} timed out after ${ms}ms`)); }, ms); }),
26
+ ]).finally(() => clearTimeout(t!));
27
+ }
28
+
29
+ /** Deterministic, always-valid commit subject derived from the changed paths. */
30
+ export function fallbackCommitMessage(files: string[]): string {
31
+ const names = files.filter(Boolean);
32
+ if (!names.length) return 'sync: update agent data';
33
+ const joined = `sync: ${names.join(', ')}`;
34
+ if (joined.length <= 72) return joined;
35
+ return `sync: ${names[0]} (+${names.length - 1} more)`.slice(0, 72);
36
+ }
37
+
38
+ // Only unambiguous "I'm about to explain" openers. Deliberately does NOT list common subject
39
+ // starters like "the diff"/"this change" — those swallowed legitimate subjects
40
+ // ("the diff view now renders inline"). Real prose is caught by the length guard below.
41
+ const PROSE_PREAMBLE = /^(looking at|here'?s|here is|sure|certainly|based on|i'?ll|okay)\b/i;
42
+
43
+ /**
44
+ * Pull a real commit subject out of a model reply. Bug 2026-08-26: the raw reply went straight to
45
+ * `git commit -m`, so a fence-only answer produced a commit literally titled "```".
46
+ * Returns '' when nothing usable is there — callers must use fallbackCommitMessage().
47
+ */
48
+ export function extractCommitSubject(raw: string): string {
49
+ if (!raw) return '';
50
+ // Prefer the body of a fenced block if the model wrapped its answer in one.
51
+ const fenced = raw.match(/```[a-zA-Z]*\n([\s\S]*?)```/);
52
+ const body = fenced ? fenced[1] : raw.replace(/```[a-zA-Z]*/g, '');
53
+ for (const rawLine of body.split('\n')) {
54
+ let line = rawLine.trim();
55
+ if (!line) continue;
56
+ line = line.replace(/^(?:[-*>#]+|\d+[.)])\s*/, '').trim(); // bullets / numbered list / headings / quotes
57
+ line = line.replace(/^`+|`+$/g, '').trim(); // inline code ticks
58
+ line = line.replace(/^["'](.*)["']$/, '$1').trim(); // wrapping quotes
59
+ if (!line || /^`+$/.test(line) || line.includes('```')) continue;
60
+ if (line.length < 3) continue;
61
+ if (PROSE_PREAMBLE.test(line)) return ''; // model explained instead of answering
62
+ if (line.endsWith(':')) return ''; // "Commit message:" style preamble
63
+ if (line.length > 72) return ''; // longer than a subject line -> prose, never truncate
64
+ return line.slice(0, 72).trim();
65
+ }
66
+ return '';
67
+ }
68
+
11
69
  export class DataSyncOptions {
12
70
  repoUrl = process.env.DATA_SYNC_REPO || '';
13
71
  branch = process.env.DATA_SYNC_BRANCH || 'main';
@@ -35,6 +93,7 @@ export class DataSync {
35
93
  private pending = new Set<string>();
36
94
  private timer: ReturnType<typeof setTimeout> | null = null;
37
95
  private pushing = false;
96
+ private pushingSince = 0;
38
97
  private pulling = false;
39
98
  private pullPending = false;
40
99
  private ready = false;
@@ -303,8 +362,14 @@ export class DataSync {
303
362
  }
304
363
 
305
364
  private async flush(): Promise<void> {
306
- if (!this.pending.size || this.pushing) return;
365
+ if (!this.pending.size) return;
366
+ if (this.pushing) {
367
+ const held = Date.now() - this.pushingSince;
368
+ if (held > PUSHING_STUCK_MS) console.warn(`${TAG} Push latch held for ${Math.round(held / 1000)}s — sync is not pushing (pending: ${this.pending.size})`);
369
+ return;
370
+ }
307
371
  this.pushing = true;
372
+ this.pushingSince = Date.now();
308
373
  const files = [...this.pending];
309
374
  this.pending.clear();
310
375
  this.timer = null;
@@ -320,13 +385,13 @@ export class DataSync {
320
385
  }
321
386
 
322
387
  const status = await this.git('status', '--porcelain');
323
- if (!status.trim()) { this.pushing = false; return; }
388
+ if (!status.trim()) return;
324
389
 
325
- if (await this.guardMassDeletions('flush')) { this.pushing = false; return; }
390
+ if (await this.guardMassDeletions('flush')) return;
326
391
  const msg = await this.generateCommitMessage(files);
327
392
  await this.git('commit', '-m', msg).catch(() => {});
328
393
  const ahead = await this.git('rev-list', '--count', `origin/${this.options.branch}..HEAD`).catch(() => '0');
329
- if (parseInt(ahead.trim()) === 0) { this.pushing = false; return; }
394
+ if (parseInt(ahead.trim()) === 0) return;
330
395
  await this.git('push', 'origin', this.options.branch).catch(async (err) => {
331
396
  console.warn(`${TAG} Push failed, pulling first:`, (err as Error).message);
332
397
  await this.pull();
@@ -336,26 +401,38 @@ export class DataSync {
336
401
  this.rebuildLog().catch(() => {});
337
402
  } catch (err) {
338
403
  console.error(`${TAG} Commit/push failed:`, (err as Error).message);
339
- }
340
- this.pushing = false;
341
- if (this.pending.size && !this.timer) {
342
- this.timer = setTimeout(() => this.flush(), 2000);
404
+ } finally {
405
+ // ALWAYS release the latch: any unsettled/throwing await used to wedge sync permanently,
406
+ // since every later flush() early-returns on `if (this.pushing) return`.
407
+ this.pushing = false;
408
+ if (this.pending.size && !this.timer) {
409
+ this.timer = setTimeout(() => this.flush(), 2000);
410
+ }
343
411
  }
344
412
  }
345
413
 
346
414
  private async generateCommitMessage(files: string[]): Promise<string> {
347
- const fallback = `sync: ${files.join(', ')}`;
415
+ const fallback = fallbackCommitMessage(files);
348
416
  try {
349
417
  const diff = await this.git('diff', '--cached', '--stat').catch(() => '');
350
418
  const diffContent = await this.git('diff', '--cached', '--no-color', '-U2').catch(() => '');
351
419
  if (!diffContent.trim()) return fallback;
352
420
  const truncated = diffContent.slice(0, 3000);
353
- const msg = await this.askClaude(
354
- 'Write a concise git commit message (max 72 chars, no quotes, no prefix like "feat:" or "sync:") for this change to an AI agent\'s behavioral config.\n' +
355
- `Files: ${files.join(', ')}\nStats: ${diff}\n\nDiff:\n${truncated}`,
356
- 'haiku',
421
+ // Bounded: a hung `claude` subprocess used to wedge flush() forever (it holds the push latch).
422
+ const ac = new AbortController();
423
+ const msg = await withTimeout(
424
+ this.askClaude(
425
+ 'Write a concise git commit message (max 72 chars, no quotes, no prefix like "feat:" or "sync:") for this change to an AI agent\'s behavioral config.\n' +
426
+ `Files: ${files.join(', ')}\nStats: ${diff}\n\nDiff:\n${truncated}`,
427
+ 'haiku',
428
+ ac,
429
+ ),
430
+ Number(process.env.DATA_SYNC_COMMIT_MSG_TIMEOUT_MS) || COMMIT_MSG_TIMEOUT_MS,
431
+ 'commit-message query',
432
+ () => ac.abort(),
357
433
  );
358
- const line = msg.split('\n')[0].trim().slice(0, 72);
434
+ const line = extractCommitSubject(msg);
435
+ if (!line) console.warn(`${TAG} Unusable LLM commit msg, using fallback:`, JSON.stringify((msg || '').slice(0, 120)));
359
436
  return line || fallback;
360
437
  } catch (err) {
361
438
  console.warn(`${TAG} LLM commit msg failed:`, (err as Error).message);
@@ -586,8 +663,8 @@ export class DataSync {
586
663
  * which is unset on subscription-auth deployments — every merge-conflict resolution failed and
587
664
  * spammed owners via notifyOwners().
588
665
  */
589
- private async askClaude(prompt: string, model: 'haiku' | 'sonnet' | 'opus' = 'sonnet'): Promise<string> {
590
- return runTextQuery({ prompt, model, maxTurns: 1 });
666
+ private async askClaude(prompt: string, model: 'haiku' | 'sonnet' | 'opus' = 'sonnet', abortController?: AbortController): Promise<string> {
667
+ return runTextQuery({ prompt, model, maxTurns: 1, abortController });
591
668
  }
592
669
 
593
670
  private async getConflictedFiles(): Promise<string[]> {
@@ -1,4 +1,5 @@
1
1
  import { query } from '@anthropic-ai/claude-agent-sdk';
2
+ import { spawn } from 'node:child_process';
2
3
  import { readFileSync } from 'node:fs';
3
4
  import path from 'node:path';
4
5
  import { signInternalToken } from '../auth.ts';
@@ -118,18 +119,44 @@ function logCacheUsage(usage: any, model: string): void {
118
119
  console.log(`[claude] Cache: hit=${hitRate}% read=${read} write=${created} uncached=${fresh} out=${usage.output_tokens ?? 0} model=${model}`);
119
120
  }
120
121
 
122
+ /** SDK spawn hook: same call the SDK would make, plus `detached` (own process group). See the
123
+ * call site for why. Shape mirrors the SDK's own spawnLocalProcess return. */
124
+ function spawnDetached(cfg: { command: string; args: string[]; cwd?: string; env: Record<string, string | undefined>; signal?: AbortSignal }) {
125
+ const child = spawn(cfg.command, cfg.args, {
126
+ cwd: cfg.cwd,
127
+ env: cfg.env,
128
+ signal: cfg.signal,
129
+ // Keep SDK debug output visible when it is asked for; otherwise the SDK's own default.
130
+ stdio: ['pipe', 'pipe', cfg.env.DEBUG_CLAUDE_AGENT_SDK ? 'inherit' : 'ignore'],
131
+ detached: true,
132
+ windowsHide: true,
133
+ });
134
+ return {
135
+ stdin: child.stdin,
136
+ stdout: child.stdout,
137
+ get killed() { return child.killed; },
138
+ get exitCode() { return child.exitCode; },
139
+ kill: child.kill.bind(child),
140
+ on: child.on.bind(child),
141
+ once: child.once.bind(child),
142
+ off: child.off.bind(child),
143
+ };
144
+ }
145
+
121
146
  const INLINE_MIMES = new Set(['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf']);
122
147
 
123
148
  async function* buildAttachmentPrompt(text: string, attachments: { path: string; name: string; mimeType: string }[], sessionId: string): AsyncIterable<any> {
124
149
  const content: any[] = [];
125
150
  const fileRefs: string[] = [];
126
151
  const audioRefs: string[] = [];
152
+ const inlined: string[] = [];
127
153
  for (const att of attachments) {
128
154
  if (INLINE_MIMES.has(att.mimeType)) {
129
155
  try {
130
156
  const buf = readFileSync(att.path);
131
157
  const blockType = att.mimeType === 'application/pdf' ? 'document' : 'image';
132
158
  content.push({ type: blockType, source: { type: 'base64', media_type: att.mimeType, data: buf.toString('base64') } });
159
+ inlined.push(`${att.name} (${att.path})`);
133
160
  } catch (err) {
134
161
  console.error(`[claude] Failed to read attachment ${att.path}:`, err);
135
162
  fileRefs.push(`${att.name} (at ${att.path} — failed to read)`);
@@ -143,6 +170,12 @@ async function* buildAttachmentPrompt(text: string, attachments: { path: string;
143
170
  }
144
171
  if (audioRefs.length > 0) text += `\n\n[Audio attached — transcribe with the mcp-audio tool (post_audio_transcribe { file }) before answering]: ${audioRefs.join(', ')}`;
145
172
  if (fileRefs.length > 0) text += `\n\n[Attached files — use Read tool to access]: ${fileRefs.join(', ')}`;
173
+ // Say IN THE TEXT that the inlined blocks exist. `text` carries the whole conversation history, so
174
+ // the image blocks sit tens of thousands of tokens above the actual question and the model has
175
+ // answered "I don't see any images attached" to a message that had four — the transcript proves the
176
+ // blocks were sent. This line makes the prompt agree with its own content, and names the on-disk
177
+ // paths so the model can Read them if it still can't see a block.
178
+ if (inlined.length > 0) text += `\n\n[${inlined.length} file(s) are attached to THIS message and included above as image/document blocks — look at them: ${inlined.join(', ')}]`;
146
179
  content.push({ type: 'text', text });
147
180
  yield { type: 'user', message: { role: 'user', content }, parent_tool_use_id: null, session_id: sessionId };
148
181
  }
@@ -273,6 +306,14 @@ export class ClaudeCodeEngine implements AgentEngine {
273
306
  const addonSuffix = getPromptSuffix(opts.turnHints);
274
307
  options['systemPrompt'] = `${IMMUTABLE_SYSTEM_PROMPT}\n\n${userPrompt}${addonSuffix ? `\n\n${addonSuffix}` : ''}`;
275
308
  if (opts.abortController) options['abortController'] = opts.abortController;
309
+ // Spawn the CLI in its OWN process group. The service manager signals the whole JOB on restart
310
+ // (`launchctl kickstart -k`, `systemctl restart`), so an inherited process group means the child
311
+ // dies instantly with SIGTERM — surfacing mid-reply as `exited with code 143` and making the
312
+ // server's 90s drain (gracefulShutdown) protect nothing: it only ever waited for a turn that was
313
+ // already dead. Detached, the signal reaches the server alone and the drain can finish the turn.
314
+ // Teardown is unaffected: the SDK still kills the child on abort/close and on process exit.
315
+ options['spawnClaudeCodeProcess'] = spawnDetached;
316
+
276
317
  // Passed as a FILE, never as `options.mcpServers` — the SDK would put the whole config (every MCP
277
318
  // server's credentials) on the CLI's argv, where `ps` / `/proc` / journald expose it. See
278
319
  // writeMcpConfigFile. Setting both would re-add the argv copy, so it's one or the other.
@@ -9,6 +9,8 @@ export async function runTextQuery(opts: {
9
9
  model?: string;
10
10
  maxTurns?: number;
11
11
  systemPrompt?: string;
12
+ /** Passed to the SDK so a timed-out/cancelled call actually kills the `claude` subprocess. */
13
+ abortController?: AbortController;
12
14
  }): Promise<string> {
13
15
  const saved = process.env.CLAUDECODE;
14
16
  delete process.env.CLAUDECODE;
@@ -22,6 +24,7 @@ export async function runTextQuery(opts: {
22
24
  model,
23
25
  maxTurns: opts.maxTurns || 1,
24
26
  systemPrompt: opts.systemPrompt,
27
+ abortController: opts.abortController,
25
28
  },
26
29
  })) {
27
30
  const m = ev as any;
@@ -66,8 +66,16 @@ export function seedDefaults() {
66
66
  console.log(`[seed] Synced ${count} built-in subagent defs from defaults`);
67
67
  }
68
68
 
69
- // MCPs dir: ensure it exists for per-user configs
69
+ // MCPs dir: ensure it exists for per-user configs, and ship the README that says what is
70
+ // actually read here. Load-bearing docs, not decoration: an operator once encoded a real
71
+ // security intent into a `data/mcps/_global.json` that no loader has ever opened, and the
72
+ // silent no-op left prod RTDB writable. Overwritten from defaults (like skills/extensions) —
73
+ // it's shipped documentation, not user data.
70
74
  mkdirSync(dataPath('mcps'), { recursive: true });
75
+ const mcpsReadme = path.join(DEFAULTS_DIR, 'mcps', 'README.md');
76
+ if (existsSync(mcpsReadme) && copyIfChanged(mcpsReadme, dataPath('mcps', 'README.md'))) {
77
+ console.log('[seed] Synced data/mcps/README.md from defaults');
78
+ }
71
79
 
72
80
  // Scripts: seed agent-facing scripts from defaults → data/scripts/
73
81
  const srcScripts = path.join(DEFAULTS_DIR, 'scripts');
@@ -72,6 +72,13 @@ export class SelfUpgrade {
72
72
  if (running.length) {
73
73
  reasons.push(`${running.length} scheduled job(s) still running (${running.join(', ')}) — restarting now would kill them mid-flight; retry when idle or pass {"force":true}`);
74
74
  }
75
+ // Same failure, other origin: an interactive Slack/web turn is not a scheduled job, so the
76
+ // check above never saw it and upgrades cut live replies off mid-sentence (143). Any held
77
+ // session lock means someone is mid-turn.
78
+ const turns = this.o.runningTurns();
79
+ if (turns) {
80
+ reasons.push(`${turns} agent turn(s) in flight — restarting now would cut the reply off mid-stream; retry when idle or pass {"force":true}`);
81
+ }
75
82
  }
76
83
  const pkgPath = path.join(this.o.appRoot, 'package.json');
77
84
 
@@ -254,6 +261,13 @@ export class SelfUpgradeOptions {
254
261
  catch (err) { console.warn(`${TAG} could not read running jobs:`, (err as Error).message); return []; }
255
262
  };
256
263
 
264
+ /** Interactive agent turns in flight (Slack/web/API), counted off the live session locks. Same
265
+ * lazy+guarded shape as runningJobs, and injectable for the same reason. */
266
+ public runningTurns: () => number = () => {
267
+ try { return require('../sessions.ts').getActiveLockCount() as number; }
268
+ catch (err) { console.warn(`${TAG} could not read running turns:`, (err as Error).message); return 0; }
269
+ };
270
+
257
271
  /** Injectable so the preflight is testable without depending on the test host's PATH. */
258
272
  public hasPython3: () => boolean = () => {
259
273
  try { return spawnSync('python3', ['--version'], { stdio: 'ignore' }).status === 0; }
@@ -1,4 +1,5 @@
1
- import { existsSync } from 'node:fs';
1
+ import { existsSync, statSync } from 'node:fs';
2
+ import { createRequire } from 'node:module';
2
3
  import path from 'node:path';
3
4
  import { DATA_DIR, APP_ROOT } from './paths.ts';
4
5
  import type { McpServerConfig, McpConfig, McpHttpServerConfig } from './mcp.ts';
@@ -93,28 +94,151 @@ export function resolveConfigPath(): string | null {
93
94
  }
94
95
 
95
96
  let _cached: ShragaConfig | null = null;
97
+ /** Config file the cache was built from, and its `mtimeMs:size` stamp. */
98
+ let _cachedPath: string | null = null;
99
+ let _cachedStamp = '';
100
+ /** Stamp of the last version we failed to load — so a broken config logs once, not per call. */
101
+ let _failedStamp = '';
102
+ /** True once we've reported that a previously-present config file went missing (log once, not per call). */
103
+ let _missingLogged = false;
96
104
 
97
- export async function loadShragaConfig(): Promise<ShragaConfig> {
98
- if (_cached) return _cached;
105
+ const _require = createRequire(import.meta.url);
106
+
107
+ function stampOf(file: string): string {
108
+ try {
109
+ const st = statSync(file);
110
+ return `${st.mtimeMs}:${st.size}`;
111
+ } catch {
112
+ return '';
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Load the config module SYNCHRONOUSLY, bypassing the runtime module cache.
118
+ *
119
+ * Busting `_cached` alone is not enough: `await import(p)` returns the SAME module object for a
120
+ * path already loaded, so an edited file is never re-evaluated (measured on Bun 1.3.10).
121
+ * `require` + a `require.cache` delete does re-evaluate — and being sync, it lets the sync
122
+ * consumers (`getGlobalMcpsFromConfig`, `getPublicOrigin`, …) pick up an edit with no call-site
123
+ * changes anywhere.
124
+ *
125
+ * Throws on a broken config; callers keep the last-good value.
126
+ */
127
+ function readConfigSync(configPath: string): ShragaConfig {
128
+ // A 0-byte file is a TRUNCATED/FAILED WRITE, not an authored "no MCPs": `require` hands back `{}`
129
+ // for it without throwing, which would silently drop every global MCP. Fail loudly instead so the
130
+ // caller keeps the last-good value — and, because the stamp is not advanced, recovers by itself
131
+ // the moment the real content lands.
132
+ if (statSync(configPath).size === 0) {
133
+ throw new Error('config file is empty (0 bytes) — refusing to load it over the last-good config');
134
+ }
135
+ try {
136
+ // Delete by the RESOLVED key: the cache is keyed by the realpath, and DATA_DIR is often a
137
+ // symlinked path (macOS /var -> /private/var, or a symlinked data dir), so deleting the
138
+ // literal path silently misses and the stale module is returned.
139
+ const cache = _require.cache as Record<string, unknown> | undefined;
140
+ if (cache) {
141
+ delete cache[_require.resolve(configPath)];
142
+ delete cache[configPath];
143
+ }
144
+ } catch {
145
+ // A runtime without a mutable require.cache: fall through — the load below may still be fresh,
146
+ // and loadShragaConfig()'s async fallback covers the stale case.
147
+ }
148
+ const mod = _require(configPath);
149
+ return (mod?.default ?? {}) as ShragaConfig;
150
+ }
151
+
152
+ /**
153
+ * Re-read the config when the file changed on disk (mtime+size), so editing `shraga.config.ts`
154
+ * takes effect without restarting the process — the way editing `data/mcps/<uid>.json` already
155
+ * does. Returns the load error, or null.
156
+ *
157
+ * Fully synchronous, so two concurrent turns can never interleave into a torn cache.
158
+ */
159
+ function refreshConfig(): unknown {
99
160
  const configPath = resolveConfigPath();
100
161
  if (!configPath) {
101
- _cached = {};
102
- return _cached;
162
+ // Cold start with no config at all: an empty config is the honest answer.
163
+ if (_cached === null) { _cached = {}; _cachedPath = null; _cachedStamp = ''; return null; }
164
+ // We HAD a config and the file is now gone — deleted, renamed, or a checkout/mount blip.
165
+ // Resetting the cache here would silently drop EVERY global MCP from a running process, with
166
+ // no log to explain it. Keep the last-good value (same policy as a config that fails to parse),
167
+ // say so once, and leave _cachedPath/_cachedStamp intact so a re-appearing file recovers.
168
+ if (_cachedPath !== null && !_missingLogged) {
169
+ _missingLogged = true;
170
+ console.error(
171
+ `[config] ${path.basename(_cachedPath)} is GONE from ${DATA_DIR} —`,
172
+ 'KEEPING the last-good config in memory (global MCPs preserved)',
173
+ );
174
+ }
175
+ return null;
103
176
  }
177
+ _missingLogged = false;
178
+ const stamp = stampOf(configPath);
179
+ if (_cached !== null && configPath === _cachedPath && stamp === _cachedStamp) return null;
104
180
  try {
105
- const mod = await import(configPath);
106
- _cached = mod.default ?? {};
181
+ _cached = readConfigSync(configPath);
182
+ _cachedPath = configPath;
183
+ _cachedStamp = stamp;
184
+ _failedStamp = '';
185
+ return null;
107
186
  } catch (e) {
108
- console.error(`[config] failed to load ${path.basename(configPath)}:`, e instanceof Error ? e.message : String(e));
109
- _cached = {};
187
+ if (_failedStamp !== stamp) {
188
+ _failedStamp = stamp;
189
+ console.error(
190
+ `[config] failed to load ${path.basename(configPath)}:`,
191
+ e instanceof Error ? e.message : String(e),
192
+ _cached === null ? '— using an EMPTY config' : '— KEEPING the last-good config in memory',
193
+ );
194
+ }
195
+ // Never leave the process with no config, and never poison the cache with a broken file:
196
+ // keep the last-good value, and deliberately do NOT advance _cachedStamp so the next call
197
+ // retries (a fixed file recovers on its own).
198
+ if (_cached === null) { _cached = {}; _cachedPath = configPath; _cachedStamp = ''; }
199
+ return e;
200
+ }
201
+ }
202
+
203
+ /** Drop the cache so the next read re-evaluates the config file unconditionally. */
204
+ export function invalidateShragaConfig(): void {
205
+ _cached = null;
206
+ _cachedPath = null;
207
+ _cachedStamp = '';
208
+ _failedStamp = '';
209
+ }
210
+
211
+ export async function loadShragaConfig(): Promise<ShragaConfig> {
212
+ const err = refreshConfig();
213
+ if (err) {
214
+ // `require` cannot load every valid ESM config (top-level await). Retry through the async
215
+ // loader — cache-busted, since a plain re-import of a loaded path returns the stale module.
216
+ const configPath = resolveConfigPath();
217
+ if (configPath) {
218
+ try {
219
+ const stamp = stampOf(configPath);
220
+ const mod = await import(`${configPath}?stamp=${encodeURIComponent(stamp)}`);
221
+ // We just awaited, so the world may have moved: another caller can have loaded a NEWER
222
+ // config synchronously while this import was in flight. Committing unconditionally would
223
+ // overwrite it with the older value AND stamp it as current — a lost update that hands the
224
+ // stale config to both callers. Commit only when the file on disk is still the version we
225
+ // imported and the cache has not already reached it.
226
+ if (stampOf(configPath) === stamp && _cachedStamp !== stamp) {
227
+ _cached = mod.default ?? {};
228
+ _cachedPath = configPath;
229
+ _cachedStamp = stamp;
230
+ _failedStamp = '';
231
+ }
232
+ } catch {
233
+ // Already logged by refreshConfig; last-good (or {}) stands.
234
+ }
235
+ }
110
236
  }
111
- // Every path above assigns _cached a non-null value; `?? {}` satisfies the type
112
- // without changing behavior (mirrors getShragaConfigSync below).
113
237
  return _cached ?? {};
114
238
  }
115
239
 
116
240
  export function getShragaConfigSync(): ShragaConfig {
117
- if (!_cached) console.warn('[config] getShragaConfigSync called before loadShragaConfig — returning empty config');
241
+ refreshConfig();
118
242
  return _cached ?? {};
119
243
  }
120
244