shraga 0.1.14 → 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 (52) 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 +8 -0
  38. package/src/server/events/types.ts +3 -0
  39. package/src/server/modules/index.ts +3 -0
  40. package/src/server/modules/routes.ts +78 -0
  41. package/src/server/modules/service.ts +575 -0
  42. package/src/server/modules/types.ts +62 -0
  43. package/src/server/scheduler/builtins.ts +27 -3
  44. package/src/server/scheduler/engine.ts +6 -0
  45. package/src/server/scheduler/runner.ts +96 -30
  46. package/src/server/scheduler/types.ts +3 -0
  47. package/src/server/sessions.ts +4 -0
  48. package/src/server/shraga-config.ts +19 -0
  49. package/src/server/skills.ts +3 -0
  50. package/src/server/slack/bot.ts +3 -1
  51. package/dist/client/assets/index-ChElotX8.js +0 -1936
  52. package/dist/client/assets/index-DdibEb2O.css +0 -10
@@ -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 }
@@ -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 {
@@ -114,6 +118,21 @@ export function getShragaConfigSync(): ShragaConfig {
114
118
  return _cached ?? {};
115
119
  }
116
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
+
117
136
  /** Resolve global MCPs from the data-dir config (both shorthand vendor entries and full entries) */
118
137
  export function getGlobalMcpsFromConfig(): McpConfig {
119
138
  const ucConfig = getShragaConfigSync();
@@ -22,6 +22,8 @@ export interface SkillMeta {
22
22
  expires?: string;
23
23
  origin?: string;
24
24
  reviewed?: boolean;
25
+ /** `managed-by: <module>@<version>` — set on skills rendered by a data-plane module. */
26
+ managedBy?: string;
25
27
  }
26
28
 
27
29
  export function isExpired(meta: SkillMeta): boolean {
@@ -66,6 +68,7 @@ export function parseSkillFrontmatter(content: string): { meta: SkillMeta; body:
66
68
  }
67
69
  if (key === 'expires') meta.expires = val;
68
70
  if (key === 'origin') meta.origin = val;
71
+ if (key === 'managed-by') meta.managedBy = val;
69
72
  if (key === 'reviewed') meta.reviewed = val === 'true' ? true : val === 'false' ? false : undefined;
70
73
  }
71
74
  return { meta, body };
@@ -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
  }