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
@@ -2,7 +2,11 @@ import path from 'node:path';
2
2
  import { readdirSync } from 'node:fs';
3
3
 
4
4
  function resolveDataDir(): string {
5
- if (process.env.DATA_DIR) return process.env.DATA_DIR;
5
+ // Absolutize against cwd: run.sh sets a RELATIVE `DATA_DIR=data-<env>`, and a relative path breaks
6
+ // any consumer that isn't cwd-relative — notably the dynamic `import(configPath)` in
7
+ // shraga-config.ts, which resolves a relative specifier against the IMPORTING MODULE
8
+ // (`node_modules/shraga/src/server/`), not the process cwd.
9
+ if (process.env.DATA_DIR) return path.resolve(process.env.DATA_DIR);
6
10
  const root = process.cwd();
7
11
  const hasNamed = readdirSync(root).some(f => f.startsWith('data-'));
8
12
  if (hasNamed) {
@@ -17,8 +21,55 @@ function resolveDataDir(): string {
17
21
  export const DATA_DIR = resolveDataDir();
18
22
  export const dataPath = (...segments: string[]) => path.join(DATA_DIR, ...segments);
19
23
 
20
- // The Shraga app root (where `defaults/` lives and the agent's project filesystem is rooted).
21
- // Derived from THIS module's stable location — `src/server/paths.ts` → repo root is two dirs up — so
22
- // it's correct even for code loaded from an overlay checkout in a different directory (where
23
- // `import.meta.dirname`-relative math would resolve to the overlay, not the app).
24
- export const PROJECT_ROOT = path.resolve(import.meta.dirname, '..', '..');
24
+ // ── Two distinct roots. Conflating them is what broke npm-consumer deployments. ──────────────────
25
+ //
26
+ // PACKAGE_ROOT where the SHRAGA PACKAGE's own shipped assets live (`defaults/`, `dist/client/`,
27
+ // `package.json` — everything in package.json `files`). Package-relative is CORRECT here: in an npm
28
+ // consumer these really do live under `node_modules/shraga/`. Do not "fix" this to APP_ROOT.
29
+ export const PACKAGE_ROOT = path.resolve(import.meta.dirname, '..', '..');
30
+
31
+ /**
32
+ * APP_ROOT — the DEPLOYMENT/consumer root: where `vendor/`, `secrets/` and `data/` live, and where
33
+ * the agent's project filesystem is rooted. NOT shipped in the package.
34
+ *
35
+ * In a source checkout this equals PACKAGE_ROOT. In an npm consumer (`shraga-circles`, `shraga-ee`)
36
+ * it is the CONSUMER root, while PACKAGE_ROOT is `<consumer>/node_modules/shraga` — resolving vendor
37
+ * from PACKAGE_ROOT is what silently killed ~21/25 MCPs in prod.
38
+ *
39
+ * Signal, in precedence order:
40
+ * 1. SHRAGA_APP_ROOT env — explicit escape hatch for any layout the heuristics get wrong.
41
+ * 2. node_modules ancestor — if this file sits under `.../node_modules/shraga/...`, the app root is
42
+ * the directory CONTAINING that `node_modules`. Independent of cwd, so it survives a server
43
+ * started from anywhere (systemd, a cron shell, `bun --cwd`).
44
+ * 3. process.cwd() — the source-checkout case, and already this module's established app-root signal
45
+ * (see resolveDataDir above; run.sh `cd`s to the app root before launching).
46
+ *
47
+ * Failure modes, explicit:
48
+ * - Hoisted/pnpm layouts where `shraga` resolves to a store dir outside the consumer's own
49
+ * node_modules: rule 2 picks the hoisting root, which may not be the dir holding `vendor/`.
50
+ * - A nested `node_modules/x/node_modules/shraga`: rule 2 stops at the INNERMOST node_modules.
51
+ * Both are exactly why rule 1 exists — set SHRAGA_APP_ROOT and the heuristics are bypassed.
52
+ */
53
+ function resolveAppRoot(): string {
54
+ const explicit = process.env.SHRAGA_APP_ROOT?.trim();
55
+ if (explicit) return path.resolve(explicit);
56
+
57
+ const marker = `${path.sep}node_modules${path.sep}`;
58
+ const idx = PACKAGE_ROOT.lastIndexOf(marker);
59
+ if (idx !== -1) return PACKAGE_ROOT.slice(0, idx);
60
+
61
+ return process.cwd();
62
+ }
63
+
64
+ export const APP_ROOT = resolveAppRoot();
65
+
66
+ /**
67
+ * @deprecated Ambiguous name — use APP_ROOT (vendor/secrets/data, agent cwd) or PACKAGE_ROOT
68
+ * (shipped assets) explicitly.
69
+ *
70
+ * Aliased to APP_ROOT, not PACKAGE_ROOT, deliberately: every remaining external consumer of this
71
+ * export (shraga-ee `engine/cursor.ts`, `engine/agentx.ts`) uses it as the agent's project root —
72
+ * i.e. they meant APP_ROOT and were hitting the same npm-layout bug. Pointing the alias here fixes
73
+ * them without an EE change. Nothing in this package reads shipped assets through it.
74
+ */
75
+ export const PROJECT_ROOT = APP_ROOT;
@@ -15,10 +15,12 @@ export const HOURLY_SUMMARIZER_SCHEDULE_ID = 'builtin-conversation-summarizer';
15
15
  export const FAILURE_NOTIFIER_SCHEDULE_ID = 'builtin-failure-notifier';
16
16
 
17
17
  /** Generic triage prompt for the failure notifier. Deployments override the prompt
18
- * (recipients, runbooks, base URL, severity rules) — their edits survive reconcile. */
18
+ * (recipients, runbooks, severity rules) — their edits survive reconcile. The session link
19
+ * is NOT part of that: it comes from the event payload (see getSessionUrl), precisely so it
20
+ * reaches deployments whose stored prompt reconcile will never touch. */
19
21
  const FAILURE_NOTIFIER_PROMPT = [
20
22
  'A scheduled job just FAILED. The failure event payload is included in this message',
21
- '(fields: name, scheduleId, status, error, sessionId). Duplicate alerts for the same',
23
+ '(fields: name, scheduleId, status, error, sessionId, sessionUrl). Duplicate alerts for the same',
22
24
  'job+error are already suppressed by this trigger\'s throttle, so just handle this one.',
23
25
  '',
24
26
  'TRIAGE — classify the error:',
@@ -35,10 +37,25 @@ const FAILURE_NOTIFIER_PROMPT = [
35
37
  ' *What:* <one plain-language line>',
36
38
  ' *Fix:* <actionable next step from triage>',
37
39
  ' *Error:* <error, truncated to ~400 chars, in backticks>',
38
- ' *Session:* <deployment URL>/?session=<sessionId>',
40
+ ' *Session:* <the payload\'s sessionUrl, verbatim>',
41
+ 'The payload carries a ready-made absolute sessionUrl. Use it EXACTLY as given — never build a',
42
+ 'link yourself. If sessionUrl is absent the deployment has no public origin configured: OMIT the',
43
+ 'Session line entirely. Do NOT substitute localhost, $PORT or any host you infer from the box —',
44
+ 'the alert is read off-box and such a link is always dead.',
39
45
  'Do NOT try to fix the job yourself.',
40
46
  ].join('\n');
41
47
 
48
+ /** The pre-sessionUrl Session line: it asked the model to improvise "<deployment URL>", which
49
+ * nothing ever supplied, so it fell back to the only host it could see ($PORT → localhost).
50
+ * Reconcile deliberately preserves a builtin's stored `task.prompt` so deployment edits survive
51
+ * upgrades — which also means a stored prompt keeps this broken line forever. Heal just the line
52
+ * (not the whole prompt), so a deployment's other customisations are untouched.
53
+ * Only the placeholder itself and the label before it are replaced: anything the deployment
54
+ * appended after the placeholder is a hand-written annotation, so it is carried over verbatim,
55
+ * and every occurrence is healed (a stored prompt may mention the link more than once). */
56
+ const LEGACY_SESSION_LINE = /^.*<deployment URL>\/\?session=<sessionId>(.*)$/gm;
57
+ const SESSION_LINE_FIX = " *Session:* <the payload's sessionUrl, verbatim — omit this line if absent>";
58
+
42
59
  export function isSystemSchedule(schedule: Schedule): boolean {
43
60
  return schedule.scope === 'system';
44
61
  }
@@ -69,6 +86,13 @@ export function backfillScope(schedules: Schedule[]): void {
69
86
  if (task.kind === 'job' && task.command === 'bun run summarize:conversations') {
70
87
  task.command = SUMMARIZER_CMD;
71
88
  }
89
+ // Heal a persisted failure-notifier prompt still carrying the un-supplied "<deployment URL>"
90
+ // placeholder — reconcile won't touch task.prompt, so this is the only path that reaches it.
91
+ if (s.id === FAILURE_NOTIFIER_SCHEDULE_ID && typeof task.prompt === 'string') {
92
+ // `$1` keeps whatever the deployment wrote after the placeholder. No .test() guard:
93
+ // LEGACY_SESSION_LINE is global, and a global regex's .test() carries lastIndex between calls.
94
+ task.prompt = task.prompt.replace(LEGACY_SESSION_LINE, `${SESSION_LINE_FIX}$1`);
95
+ }
72
96
  }
73
97
  }
74
98
 
@@ -3,6 +3,7 @@ import { computeNextRun, computePrevRun, validateTrigger } from './timing.ts';
3
3
  import { runSchedule, type ResumeOptions, type EventContext } from './runner.ts';
4
4
  import { backfillScope, ensureBuiltinSchedules } from './builtins.ts';
5
5
  import { emitEvent } from '../events/bus.ts';
6
+ import { getSessionUrl } from '../shraga-config.ts';
6
7
  import type { Schedule } from './types.ts';
7
8
 
8
9
  type Broadcast = (data: object) => void;
@@ -410,6 +411,11 @@ function startRun(s: Schedule, _firedAt: number, override?: string, resume?: Res
410
411
  name: s.name,
411
412
  status: summary.status,
412
413
  sessionId: summary.sessionId,
414
+ // Ready-made absolute link. Supplied here rather than left to the consuming prompt:
415
+ // reconcile never syncs a builtin's stored `task.prompt`, so a prompt-only fix would
416
+ // miss every deployment that already persisted the schedule. Omitted when no public
417
+ // origin is configured — better no link than a localhost one.
418
+ sessionUrl: getSessionUrl(summary.sessionId),
413
419
  error: summary.error,
414
420
  }, { id: summary.sessionId });
415
421
  }
@@ -66,6 +66,27 @@ export function resolvePromptFile(p: string): string {
66
66
  return existsSync(dataAnchored) ? dataAnchored : rootAnchored;
67
67
  }
68
68
 
69
+ /**
70
+ * Bounded retry for transient engine failures on a prompt run.
71
+ *
72
+ * A cold-open race in the engine (observed: `database is locked` from @cursor/sdk, and an
73
+ * unreachable ANTHROPIC_BASE_URL) can kill a run in ~600ms having produced NOTHING — no first
74
+ * token, no tool call, no side effect. One blip then costs the whole day's run. The race window is
75
+ * milliseconds, so these delays are deliberately short: this is a blip retry, not an outage retry.
76
+ * Each delay is jittered (×0.5–1.5) so concurrent schedules don't retry in lockstep.
77
+ */
78
+ const RETRY_BACKOFF_MS = [500, 2_000];
79
+ const MAX_ATTEMPTS = RETRY_BACKOFF_MS.length + 1;
80
+
81
+ /** Resolves after `ms`, or immediately on abort — a cancelled run must not sit out its backoff
82
+ * holding the session lock and the running marker. */
83
+ const sleep = (ms: number, signal?: AbortSignal) => new Promise<void>((resolve) => {
84
+ if (signal?.aborted) return resolve();
85
+ const done = () => { clearTimeout(timer); signal?.removeEventListener('abort', done); resolve(); };
86
+ const timer = setTimeout(done, ms);
87
+ signal?.addEventListener('abort', done, { once: true });
88
+ });
89
+
69
90
  function formatEventBlock(e: EventContext): string {
70
91
  let body: string;
71
92
  try { body = JSON.stringify(e.payload, null, 2); } catch { body = String(e.payload); }
@@ -144,6 +165,9 @@ export async function runSchedule(
144
165
  const mcpServers = getMcpConfig(schedule.createdBy.uid);
145
166
 
146
167
  let assistantText = '';
168
+ // Thinking is real, billed model output but lands in no block (we don't persist it), so it needs
169
+ // its own flag to suppress retry — see the side-effect boundary below.
170
+ let producedThinking = false;
147
171
  const assistantBlocks: ConvBlock[] = [];
148
172
  const collectPartialBlocks = () => [
149
173
  ...assistantBlocks,
@@ -169,39 +193,81 @@ export async function runSchedule(
169
193
  onEvent({ type: 'schedule:run_started', scheduleId: schedule.id, sessionId, at: now });
170
194
 
171
195
  try {
172
- for await (const ev of streamChat({
173
- prompt,
174
- sessionId,
175
- uid: schedule.createdBy.uid,
176
- userEmail: schedule.createdBy.email,
177
- userName: schedule.createdBy.email.split('@')[0],
178
- mcpServers,
179
- abortController,
180
- onPermissionRequest,
181
- })) {
182
- onEvent({ type: 'session_stream', sessionId, event: ev });
183
- if (ev.type === 'text_delta') {
184
- assistantText += ev.text;
185
- } else if (ev.type === 'tool_use') {
196
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
197
+ status = 'ok';
198
+ error = undefined;
199
+ try {
200
+ for await (const ev of streamChat({
201
+ prompt,
202
+ sessionId,
203
+ uid: schedule.createdBy.uid,
204
+ userEmail: schedule.createdBy.email,
205
+ userName: schedule.createdBy.email.split('@')[0],
206
+ mcpServers,
207
+ abortController,
208
+ onPermissionRequest,
209
+ })) {
210
+ onEvent({ type: 'session_stream', sessionId, event: ev });
211
+ if (ev.type === 'text_delta') {
212
+ assistantText += ev.text;
213
+ } else if (ev.type === 'tool_use') {
214
+ if (assistantText) { assistantBlocks.push({ type: 'text', text: assistantText }); assistantText = ''; }
215
+ assistantBlocks.push({ type: 'tool_use', tool: ev.tool, toolUseId: ev.toolUseId, input: ev.input });
216
+ } else if (ev.type === 'thinking_delta') {
217
+ producedThinking = true;
218
+ } else if (ev.type === 'tool_result') {
219
+ assistantBlocks.push({ type: 'tool_result', toolUseId: ev.toolUseId, output: ev.output });
220
+ } else if (ev.type === 'done') {
221
+ break;
222
+ } else if (ev.type === 'error') {
223
+ status = abortController.signal.aborted ? 'aborted' : 'error';
224
+ error = ev.message;
225
+ break;
226
+ }
227
+ }
228
+ } catch (err: any) {
229
+ if (abortController.signal.aborted) {
230
+ status = 'aborted';
231
+ } else {
232
+ status = 'error';
233
+ error = err?.message ?? String(err);
234
+ }
235
+ }
236
+
237
+ if (status !== 'error') break;
238
+
239
+ // The side-effect boundary. This is NOT an exact `ttft=-1` test — the engine emits events we
240
+ // don't track (model_resolved, stats) — it is the weaker but sufficient guarantee we actually
241
+ // need: if all three are empty, no side effect can have occurred, so re-running can't
242
+ // double-apply one (a posted DM, a written file). That holds because `tool_use` is yielded at
243
+ // content_block_start, BEFORE the tool executes — any tool that ran is always preceded by a
244
+ // `tool_use` already in `assistantBlocks`. `assistantText`/`producedThinking` additionally
245
+ // stop us re-billing a long model call that genuinely started producing before dying.
246
+ // Deliberately NOT counted: `model_resolved` and `stats` fire at spawn/on a timer before any
247
+ // generation — counting them would disable retry for the exact incident this exists for.
248
+ // `tool_use_input`, `tool_result_image`, `permission_request` and `question_request` need no
249
+ // separate flag: each is necessarily preceded by the `tool_use` that already set the boundary.
250
+ const producedOutput = assistantBlocks.length > 0 || assistantText.length > 0 || producedThinking;
251
+ if (producedOutput || abortController.signal.aborted || attempt >= MAX_ATTEMPTS) {
252
+ // Nobody watches stderr on a scheduled run — record the failure in the transcript.
186
253
  if (assistantText) { assistantBlocks.push({ type: 'text', text: assistantText }); assistantText = ''; }
187
- assistantBlocks.push({ type: 'tool_use', tool: ev.tool, toolUseId: ev.toolUseId, input: ev.input });
188
- } else if (ev.type === 'tool_result') {
189
- assistantBlocks.push({ type: 'tool_result', toolUseId: ev.toolUseId, output: ev.output });
190
- } else if (ev.type === 'done') {
191
- break;
192
- } else if (ev.type === 'error') {
193
- status = abortController.signal.aborted ? 'aborted' : 'error';
194
- error = ev.message;
254
+ assistantBlocks.push({ type: 'error', text: error ?? 'Unknown error' });
255
+ console.error(`[scheduler] run error for ${schedule.id} (attempt ${attempt}/${MAX_ATTEMPTS}):`, error);
195
256
  break;
196
257
  }
197
- }
198
- } catch (err: any) {
199
- if (abortController.signal.aborted) {
200
- status = 'aborted';
201
- } else {
202
- status = 'error';
203
- error = err?.message ?? String(err);
204
- console.error(`[scheduler] run error for ${schedule.id}:`, error);
258
+
259
+ // Retries stay visible: a silent one would hide that the upstream engine is flaky.
260
+ const delay = Math.round(RETRY_BACKOFF_MS[attempt - 1]! * (0.5 + Math.random()));
261
+ console.warn(`[scheduler] attempt ${attempt}/${MAX_ATTEMPTS} for ${schedule.id} failed before any output, retrying in ${delay}ms:`, error);
262
+ appendMessage(sessionId, {
263
+ id: crypto.randomUUID(),
264
+ role: 'assistant',
265
+ blocks: [{ type: 'text', text: `⚠️ Attempt ${attempt}/${MAX_ATTEMPTS} failed before producing any output — retrying in ${delay}ms.\n\n\`${error}\`` }],
266
+ });
267
+ onEvent({ type: 'session_messages_changed', sessionId });
268
+ await sleep(delay, abortController.signal);
269
+ // Cancelling during the backoff is a user cancel, not a failure — don't spawn attempt N+1.
270
+ if (abortController.signal.aborted) { status = 'aborted'; error = undefined; break; }
205
271
  }
206
272
  } finally {
207
273
  flush();
@@ -59,4 +59,7 @@ export interface Schedule {
59
59
  nextRun?: number;
60
60
  lastRun?: ScheduleRunSummary;
61
61
  runCount: number;
62
+ /** Set when a data-plane module owns this schedule (module name). Module reconcile
63
+ * updates trigger/task; enable/disable snapshots live in the module's state entry. */
64
+ managedBy?: string;
62
65
  }
@@ -356,6 +356,10 @@ export type ConvBlock =
356
356
  | { type: 'tool_use'; tool: string; toolUseId: string; input: unknown }
357
357
  | { type: 'tool_result'; toolUseId: string; output: string }
358
358
  | { type: 'thinking'; text: string }
359
+ // A run that failed at the engine/adapter level. Persisted so the transcript records the failure
360
+ // durably — otherwise a dead session (esp. an unattended scheduled one) is indistinguishable from
361
+ // one still thinking, with the cause only in stderr.
362
+ | { type: 'error'; text: string }
359
363
  // Persisted block written by an add-on engine's background worker (e.g. EE's duplex voice brain).
360
364
  // The core stores/renders it but owns none of its semantics; name kept for stored-history back-compat.
361
365
  | { type: 'duplex_result'; label?: string; tier?: string; text?: string }
@@ -1,6 +1,6 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import path from 'node:path';
3
- import { DATA_DIR } from './paths.ts';
3
+ import { DATA_DIR, APP_ROOT } from './paths.ts';
4
4
  import type { McpServerConfig, McpConfig, McpHttpServerConfig } from './mcp.ts';
5
5
 
6
6
  /** Shorthand for vendor-dir MCPs (auto-resolves command/args from vendor/{name}) */
@@ -57,6 +57,10 @@ export interface ShragaConfig {
57
57
  /** @deprecated Use `mcps` instead */
58
58
  vendorMcps?: Record<string, McpShorthandEntry>;
59
59
  mcps?: Record<string, McpEntry>;
60
+ /** Public origin this deployment is reachable at, e.g. `https://agent.example.com`.
61
+ * Used to build absolute session links for out-of-band notifications (push, alerts)
62
+ * that have no incoming request to derive it from. Falls back to `$PUBLIC_ORIGIN`. */
63
+ publicOrigin?: string;
60
64
  }
61
65
 
62
66
  export interface HttpSidecarSpec {
@@ -70,8 +74,6 @@ export function defineConfig(config: ShragaConfig): ShragaConfig {
70
74
  return config;
71
75
  }
72
76
 
73
- const PROJECT_ROOT = path.resolve(import.meta.dirname, '..', '..');
74
-
75
77
  /**
76
78
  * Config filenames, in precedence order. `shraga.config.ts` is canonical; `unclaw.config.ts` is
77
79
  * the legacy name kept for back-compat — existing deployments have that file in their data dir,
@@ -116,6 +118,21 @@ export function getShragaConfigSync(): ShragaConfig {
116
118
  return _cached ?? {};
117
119
  }
118
120
 
121
+ /** This deployment's public origin, without a trailing slash — data-dir config first, then
122
+ * `$PUBLIC_ORIGIN`. Empty when unconfigured: callers MUST omit the link rather than fall back
123
+ * to a locally-derived host, which is unreachable from wherever the notification is read. */
124
+ export function getPublicOrigin(): string {
125
+ const origin = getShragaConfigSync().publicOrigin ?? process.env.PUBLIC_ORIGIN ?? '';
126
+ return origin.trim().replace(/\/+$/, '');
127
+ }
128
+
129
+ /** Absolute link to a session in the web UI, or `undefined` when no public origin is configured. */
130
+ export function getSessionUrl(sessionId: string | undefined): string | undefined {
131
+ const origin = getPublicOrigin();
132
+ if (!origin || !sessionId) return undefined;
133
+ return `${origin}/?session=${encodeURIComponent(sessionId)}`;
134
+ }
135
+
119
136
  /** Resolve global MCPs from the data-dir config (both shorthand vendor entries and full entries) */
120
137
  export function getGlobalMcpsFromConfig(): McpConfig {
121
138
  const ucConfig = getShragaConfigSync();
@@ -131,7 +148,7 @@ export function getGlobalMcpsFromConfig(): McpConfig {
131
148
  result[name] = { type: 'stdio', ...full } satisfies McpServerConfig;
132
149
  } else {
133
150
  const shorthand = entry as McpShorthandEntry;
134
- const vendorDir = path.join(PROJECT_ROOT, 'vendor', shorthand.dir ?? name);
151
+ const vendorDir = path.join(APP_ROOT, 'vendor', shorthand.dir ?? name);
135
152
  const command = shorthand.command ?? 'bun';
136
153
  const args = shorthand.args ?? ['run', path.join(vendorDir, 'src/mcp/cli.ts'), '--stdio'];
137
154
  const env: Record<string, string> = {};
@@ -1,13 +1,11 @@
1
1
  import { mkdirSync, readdirSync, readFileSync, writeFileSync, existsSync, unlinkSync, renameSync } from 'node:fs';
2
2
  import path from 'node:path';
3
- import { DATA_DIR, dataPath } from './paths.ts';
3
+ import { DATA_DIR, dataPath, APP_ROOT } from './paths.ts';
4
4
  import { getBuiltinSkillNames } from './seed.ts';
5
5
  import { dataSync } from './data-sync.ts';
6
6
  import { injectFile } from './file-inject.ts';
7
7
  import { getGlobalMcpConfig } from './mcp.ts';
8
8
 
9
- const PROJECT_ROOT = path.resolve(import.meta.dirname, '..', '..');
10
-
11
9
  const SKILLS_DIR = dataPath('skills');
12
10
  const DEFAULTS_PATH = dataPath('skills-defaults.json');
13
11
 
@@ -24,6 +22,8 @@ export interface SkillMeta {
24
22
  expires?: string;
25
23
  origin?: string;
26
24
  reviewed?: boolean;
25
+ /** `managed-by: <module>@<version>` — set on skills rendered by a data-plane module. */
26
+ managedBy?: string;
27
27
  }
28
28
 
29
29
  export function isExpired(meta: SkillMeta): boolean {
@@ -68,6 +68,7 @@ export function parseSkillFrontmatter(content: string): { meta: SkillMeta; body:
68
68
  }
69
69
  if (key === 'expires') meta.expires = val;
70
70
  if (key === 'origin') meta.origin = val;
71
+ if (key === 'managed-by') meta.managedBy = val;
71
72
  if (key === 'reviewed') meta.reviewed = val === 'true' ? true : val === 'false' ? false : undefined;
72
73
  }
73
74
  return { meta, body };
@@ -108,7 +109,7 @@ function formatMcpCommandBlock(mcpName: string, skillBody: string, args: string)
108
109
  * Markdown from `vendor/<serverName>/.claude/skills/<serverName>/SKILL.md` — same file the MCP exposes as skill://serverName/workflow.
109
110
  */
110
111
  export function resolveMcpBundledSkillContent(serverName: string): string | null {
111
- const file = path.join(PROJECT_ROOT, 'vendor', serverName, '.claude/skills', serverName, 'SKILL.md');
112
+ const file = path.join(APP_ROOT, 'vendor', serverName, '.claude/skills', serverName, 'SKILL.md');
112
113
  if (!existsSync(file)) return null;
113
114
  return readFileSync(file, 'utf-8');
114
115
  }
@@ -128,7 +129,7 @@ export function resolvedSkillInjectionBlock(name: string): string | null {
128
129
  }
129
130
 
130
131
  function mcpSkillFilePath(serverName: string): string {
131
- return path.join(PROJECT_ROOT, 'vendor', serverName, '.claude/skills', serverName, 'SKILL.md');
132
+ return path.join(APP_ROOT, 'vendor', serverName, '.claude/skills', serverName, 'SKILL.md');
132
133
  }
133
134
 
134
135
  /**
@@ -98,8 +98,10 @@ async function* pumpStream(
98
98
  stopReason = ev.stopReason ?? 'end_turn';
99
99
  break;
100
100
  } else if (ev.type === 'error') {
101
+ // Slack gets it as text (it has no block renderer); the transcript gets a real error block.
101
102
  const t = `\n⚠️ ${ev.message}`;
102
- assistantText += t;
103
+ if (assistantText) { assistantBlocks.push({ type: 'text', text: assistantText }); assistantText = ''; }
104
+ assistantBlocks.push({ type: 'error', text: ev.message });
103
105
  yield { type: 'text_delta', text: t };
104
106
  break;
105
107
  }