shraga 0.1.13 → 0.1.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/defaults/modules/routine/README.md +38 -0
- package/defaults/modules/routine/module.json +36 -0
- package/defaults/modules/routine/routine.md.tmpl +75 -0
- package/defaults/modules/routine/seeds/workspace/agenda.md +15 -0
- package/defaults/skills/add-skill.md +4 -0
- package/defaults/skills/artifacts.md +4 -0
- package/defaults/skills/communications.md +4 -0
- package/defaults/skills/create-module.md +75 -0
- package/defaults/skills/debug.md +4 -0
- package/defaults/skills/garden.md +4 -0
- package/defaults/skills/github-contributor.md +4 -0
- package/defaults/skills/identity.md +4 -0
- package/defaults/skills/mcp-server.md +4 -0
- package/defaults/skills/modules.md +41 -0
- package/defaults/skills/plan.md +4 -0
- package/defaults/skills/reconcile.md +4 -0
- package/defaults/skills/scheduler.md +5 -2
- package/defaults/skills/self-aware.md +5 -1
- package/defaults/skills/write-tests.md +4 -0
- package/dist/client/assets/index-D5KxKl57.js +1946 -0
- package/dist/client/assets/index-nyZagTjP.css +10 -0
- package/dist/client/index.html +2 -2
- package/package.json +1 -1
- package/src/client/App.tsx +11 -0
- package/src/client/components/ChatView.tsx +14 -0
- package/src/client/components/ConversationHeader.tsx +29 -0
- package/src/client/components/ModulesManager.tsx +259 -0
- package/src/client/hooks/useConversation.ts +5 -3
- package/src/client/hooks/useModules.ts +81 -0
- package/src/client/lib/api.ts +13 -0
- package/src/client/lib/sessionApi.ts +16 -2
- package/src/client/lib/workspaceContext.tsx +2 -0
- package/src/mcp-stdio-bridge.ts +5 -5
- package/src/server/boot.ts +95 -16
- package/src/server/claude.ts +12 -1
- package/src/server/data-sync.ts +28 -0
- package/src/server/engine/claude-code.ts +10 -3
- package/src/server/events/types.ts +3 -0
- package/src/server/mcp-sidecar.ts +2 -2
- package/src/server/mcp.ts +3 -5
- package/src/server/modules/index.ts +3 -0
- package/src/server/modules/routes.ts +78 -0
- package/src/server/modules/service.ts +575 -0
- package/src/server/modules/types.ts +62 -0
- package/src/server/paths.ts +57 -6
- package/src/server/scheduler/builtins.ts +27 -3
- package/src/server/scheduler/engine.ts +6 -0
- package/src/server/scheduler/runner.ts +96 -30
- package/src/server/scheduler/types.ts +3 -0
- package/src/server/sessions.ts +4 -0
- package/src/server/shraga-config.ts +21 -4
- package/src/server/skills.ts +6 -5
- package/src/server/slack/bot.ts +3 -1
- package/dist/client/assets/index-ChElotX8.js +0 -1936
- package/dist/client/assets/index-DdibEb2O.css +0 -10
package/src/server/paths.ts
CHANGED
|
@@ -2,7 +2,11 @@ import path from 'node:path';
|
|
|
2
2
|
import { readdirSync } from 'node:fs';
|
|
3
3
|
|
|
4
4
|
function resolveDataDir(): string {
|
|
5
|
-
|
|
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
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
// `
|
|
24
|
-
|
|
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,
|
|
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:* <
|
|
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
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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: '
|
|
188
|
-
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
}
|
package/src/server/sessions.ts
CHANGED
|
@@ -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(
|
|
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> = {};
|
package/src/server/skills.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
132
|
+
return path.join(APP_ROOT, 'vendor', serverName, '.claude/skills', serverName, 'SKILL.md');
|
|
132
133
|
}
|
|
133
134
|
|
|
134
135
|
/**
|
package/src/server/slack/bot.ts
CHANGED
|
@@ -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
|
|
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
|
}
|