shraga 0.1.2 → 0.1.4
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/README.md +63 -1
- package/defaults/agents/trace-extractor.md +1 -1
- package/defaults/extensions/stripe-webhook.ext.ts +29 -21
- package/defaults/scripts/agent-once.ts +1 -1
- package/defaults/skills/communications.md +1 -1
- package/defaults/skills/mcps-sync.md +2 -2
- package/defaults/skills/self-aware.md +1 -1
- package/dist/client/assets/{index-BoHttkMt.js → index-BnArwb7g.js} +43 -43
- package/dist/client/index.html +1 -1
- package/package.json +8 -5
- package/src/cli.ts +3 -1
- package/src/client/components/ArtifactPanel.tsx +16 -3
- package/src/index.ts +172 -0
- package/src/server/artifacts/artifacts.routes.ts +2 -19
- package/src/server/boot.ts +1779 -0
- package/src/server/engine/claude-code.ts +2 -2
- package/src/server/events/bus.ts +21 -5
- package/src/server/events/types.ts +26 -3
- package/src/server/events/webhook.ts +64 -0
- package/src/server/extensions.ts +34 -0
- package/src/server/hooks.ts +2 -2
- package/src/server/index.ts +8 -1712
- package/src/server/slack/bot.ts +1 -1
- package/src/server/slack/feature.ts +1 -1
- package/src/server/turn-context.ts +7 -0
- package/src/server/artifacts/artifacts.export.ts +0 -85
|
@@ -457,7 +457,7 @@ export class ClaudeCodeEngine implements AgentEngine {
|
|
|
457
457
|
}
|
|
458
458
|
|
|
459
459
|
const pending = pendingToolUses.get(String(block.tool_use_id));
|
|
460
|
-
if (pending?.tool === 'mcp__mcp-
|
|
460
|
+
if (pending?.tool === 'mcp__mcp-slack-use__post_slack_message') {
|
|
461
461
|
try {
|
|
462
462
|
const parsed = typeof output === 'string' ? JSON.parse(output) : output;
|
|
463
463
|
const ts = parsed?.ts || parsed?.preview?.ts;
|
|
@@ -470,7 +470,7 @@ export class ClaudeCodeEngine implements AgentEngine {
|
|
|
470
470
|
}
|
|
471
471
|
} catch (err) { console.warn('[claude] Failed to track proactive message:', (err as Error).message); }
|
|
472
472
|
}
|
|
473
|
-
if (pending?.tool === 'mcp__mcp-
|
|
473
|
+
if (pending?.tool === 'mcp__mcp-slack-use__post_slack_poll') {
|
|
474
474
|
try {
|
|
475
475
|
const parsed = typeof output === 'string' ? JSON.parse(output) : output;
|
|
476
476
|
const body = ((pending.input as any)?.body ?? {}) as Record<string, any>;
|
package/src/server/events/bus.ts
CHANGED
|
@@ -4,24 +4,40 @@
|
|
|
4
4
|
// call emitEvent(); the dispatcher subscribes and routes matching events to the
|
|
5
5
|
// scheduler's execution path. Deliberately minimal: no plugin registry, no
|
|
6
6
|
// lifecycle — just emit + subscribe. Adding a source = one emitEvent() call.
|
|
7
|
-
|
|
7
|
+
//
|
|
8
|
+
// TYPING: `ShragaEventMap` (types.ts) maps a source → its payload type. A source
|
|
9
|
+
// listed there gets a checked payload; anything else falls back to `unknown`, so
|
|
10
|
+
// ad-hoc emitters keep compiling. Add-ons register their source via declaration
|
|
11
|
+
// merging (see types.ts).
|
|
12
|
+
import type { ShragaEvent, PayloadOf } from './types.ts';
|
|
8
13
|
|
|
9
14
|
type Listener = (evt: ShragaEvent) => void;
|
|
10
15
|
|
|
11
16
|
const listeners = new Set<Listener>();
|
|
12
17
|
|
|
13
|
-
/** Subscribe to
|
|
18
|
+
/** Subscribe to ALL events. Returns an unsubscribe fn. The dispatcher uses this. */
|
|
14
19
|
export function subscribeEvents(fn: Listener): () => void {
|
|
15
20
|
listeners.add(fn);
|
|
16
21
|
return () => listeners.delete(fn);
|
|
17
22
|
}
|
|
18
23
|
|
|
24
|
+
/** Subscribe to a SINGLE source with a typed payload. Thin filter over
|
|
25
|
+
* subscribeEvents — convenience + typing for the common "I only care about X" case. */
|
|
26
|
+
export function subscribeEvent<K extends string>(
|
|
27
|
+
source: K,
|
|
28
|
+
handler: (payload: PayloadOf<K>, evt: ShragaEvent<K>) => void,
|
|
29
|
+
): () => void {
|
|
30
|
+
return subscribeEvents((evt) => {
|
|
31
|
+
if (evt.source === source) handler(evt.payload as PayloadOf<K>, evt as ShragaEvent<K>);
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
19
35
|
/** Publish an event. Listeners are invoked synchronously; a throwing listener is
|
|
20
36
|
* logged and skipped so one bad subscriber can't sink the emit. */
|
|
21
|
-
export function emitEvent(source:
|
|
22
|
-
const evt: ShragaEvent = { source, payload, id: opts?.id, at: Date.now() };
|
|
37
|
+
export function emitEvent<K extends string>(source: K, payload: PayloadOf<K>, opts?: { id?: string }): ShragaEvent<K> {
|
|
38
|
+
const evt: ShragaEvent<K> = { source, payload, id: opts?.id, at: Date.now() };
|
|
23
39
|
for (const fn of listeners) {
|
|
24
|
-
try { fn(evt); } catch (err) {
|
|
40
|
+
try { fn(evt as ShragaEvent); } catch (err) {
|
|
25
41
|
console.error(`[events] listener threw for source "${source}":`, err);
|
|
26
42
|
}
|
|
27
43
|
}
|
|
@@ -1,9 +1,32 @@
|
|
|
1
1
|
/** A single event flowing through the bus. `source` routes it to matching
|
|
2
2
|
* `event`-trigger schedules; `payload` is matched against their `match` filter and
|
|
3
3
|
* injected into the run. `id` (optional) dedupes retried webhook deliveries. */
|
|
4
|
-
export interface ShragaEvent {
|
|
5
|
-
source:
|
|
6
|
-
payload:
|
|
4
|
+
export interface ShragaEvent<K extends string = string> {
|
|
5
|
+
source: K;
|
|
6
|
+
payload: PayloadOf<K>;
|
|
7
7
|
id?: string;
|
|
8
8
|
at: number;
|
|
9
9
|
}
|
|
10
|
+
|
|
11
|
+
/** Extensible registry of source → payload types. Add-ons augment it via
|
|
12
|
+
* declaration merging so a known source gets a typed payload, e.g.:
|
|
13
|
+
*
|
|
14
|
+
* declare module '../events/types.ts' {
|
|
15
|
+
* interface ShragaEventMap { stripe: Stripe.Event }
|
|
16
|
+
* }
|
|
17
|
+
*
|
|
18
|
+
* Sources NOT in the map still work — they fall back to `unknown` (see PayloadOf),
|
|
19
|
+
* so untyped/ad-hoc emitters keep compiling. */
|
|
20
|
+
export interface ShragaEventMap {
|
|
21
|
+
// Core sources shipped by shraga itself. Add-ons merge in their own.
|
|
22
|
+
'schedule.finished': {
|
|
23
|
+
scheduleId: string;
|
|
24
|
+
name?: string;
|
|
25
|
+
status?: string;
|
|
26
|
+
sessionId?: string;
|
|
27
|
+
error?: string;
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Payload type for a source: the mapped type if registered, else `unknown`. */
|
|
32
|
+
export type PayloadOf<K extends string> = K extends keyof ShragaEventMap ? ShragaEventMap[K] : unknown;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Generic verified-webhook → event bridge.
|
|
2
|
+
//
|
|
3
|
+
// A vendor webhook can't present shraga auth (it carries the vendor's OWN
|
|
4
|
+
// signature), so it can't hit the auth-gated POST /api/events/:source. Instead
|
|
5
|
+
// declare it as data: registerWebhook(app, { source, verify }) mounts a PUBLIC
|
|
6
|
+
// POST /api/webhooks/<source>, and on a passing `verify` calls emitEvent(source).
|
|
7
|
+
//
|
|
8
|
+
// The ONLY per-vendor piece is `verify` — dispatch, dedup, and typing are generic.
|
|
9
|
+
// The raw request bytes (needed for HMAC) are read from req.rawBody, which the
|
|
10
|
+
// global express.json({ verify }) in index.ts stashes for every request.
|
|
11
|
+
import type { IRouter, Request, Response } from 'express';
|
|
12
|
+
import { emitEvent } from './bus.ts';
|
|
13
|
+
import type { PayloadOf } from './types.ts';
|
|
14
|
+
|
|
15
|
+
export interface WebhookOptions<K extends string> {
|
|
16
|
+
/** Event source name → the bus source + `/api/webhooks/<source>` path. */
|
|
17
|
+
source: K;
|
|
18
|
+
/** Verify the vendor's signature over the RAW body. Return any FALSY value
|
|
19
|
+
* (false/undefined/null/'') to reject (400). Return `true` to accept
|
|
20
|
+
* (payload = normalize?.(req) ?? parsed body), or return the payload object
|
|
21
|
+
* directly. Receives the raw bytes for HMAC. */
|
|
22
|
+
verify: (req: Request, raw: string) => boolean | PayloadOf<K>;
|
|
23
|
+
/** Optional payload shaper when `verify` returns `true` (defaults to req.body). */
|
|
24
|
+
normalize?: (req: Request) => PayloadOf<K>;
|
|
25
|
+
/** Optional dedup id extractor (retried deliveries). Defaults to body.id. */
|
|
26
|
+
eventId?: (payload: PayloadOf<K>, req: Request) => string | undefined;
|
|
27
|
+
/** Override the mount path (default `/api/webhooks/<source>`). */
|
|
28
|
+
path?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function rawBodyOf(req: Request): string {
|
|
32
|
+
const buf = (req as unknown as { rawBody?: unknown }).rawBody;
|
|
33
|
+
return buf instanceof Buffer ? buf.toString('utf8') : '';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Mount a public webhook route that verifies the vendor's signature, then emits a
|
|
37
|
+
* typed event. Returns the mounted path. */
|
|
38
|
+
export function registerWebhook<K extends string>(router: IRouter, opts: WebhookOptions<K>): string {
|
|
39
|
+
const path = opts.path ?? `/api/webhooks/${opts.source}`;
|
|
40
|
+
router.post(path, (req: Request, res: Response) => {
|
|
41
|
+
const raw = rawBodyOf(req);
|
|
42
|
+
let result: boolean | PayloadOf<K>;
|
|
43
|
+
try {
|
|
44
|
+
result = opts.verify(req, raw);
|
|
45
|
+
} catch (err) {
|
|
46
|
+
console.error(`[webhook] verify threw for "${opts.source}":`, err);
|
|
47
|
+
return res.status(400).json({ error: 'verification failed' });
|
|
48
|
+
}
|
|
49
|
+
// Reject on ANY falsy result — a `verify` written as "return the payload, or
|
|
50
|
+
// nothing on failure" yields undefined/null/''/0 on a bad signature; those must
|
|
51
|
+
// NOT fall through and emit an unauthenticated event.
|
|
52
|
+
if (!result) return res.status(400).json({ error: 'bad signature' });
|
|
53
|
+
|
|
54
|
+
const payload: PayloadOf<K> = result === true ? (opts.normalize?.(req) ?? (req.body as PayloadOf<K>)) : result;
|
|
55
|
+
const id = opts.eventId
|
|
56
|
+
? opts.eventId(payload, req)
|
|
57
|
+
: ((payload as { id?: unknown } | null)?.id != null ? String((payload as { id?: unknown }).id) : undefined);
|
|
58
|
+
|
|
59
|
+
emitEvent(opts.source, payload, { id });
|
|
60
|
+
res.json({ received: true });
|
|
61
|
+
});
|
|
62
|
+
console.log(`[webhook] mounted POST ${path} → emitEvent("${opts.source}")`);
|
|
63
|
+
return path;
|
|
64
|
+
}
|
package/src/server/extensions.ts
CHANGED
|
@@ -22,6 +22,7 @@ import { pathToFileURL } from 'node:url';
|
|
|
22
22
|
import { dataPath } from './paths.ts';
|
|
23
23
|
import { requireAuth } from './auth.ts';
|
|
24
24
|
import { emitEvent } from './events/bus.ts';
|
|
25
|
+
import { registerWebhook, type WebhookOptions } from './events/webhook.ts';
|
|
25
26
|
|
|
26
27
|
export interface ExtensionContext {
|
|
27
28
|
/** Resolve a path inside the active data dir (e.g. ctx.dataPath('github-app.json')). */
|
|
@@ -35,11 +36,38 @@ export interface ExtensionContext {
|
|
|
35
36
|
/** Publish an event onto the bus → fires matching `event`-trigger schedules.
|
|
36
37
|
* Use after verifying a vendor webhook's own signature. */
|
|
37
38
|
emitEvent: (source: string, payload: unknown, opts?: { id?: string }) => void;
|
|
39
|
+
/** Declare a vendor webhook as data: mounts a PUBLIC POST /api/webhooks/<source>
|
|
40
|
+
* that runs `verify` (the only per-vendor piece) → emitEvent. Returns the path. */
|
|
41
|
+
registerWebhook: <K extends string>(opts: WebhookOptions<K>) => string;
|
|
38
42
|
}
|
|
39
43
|
|
|
44
|
+
/** A programmatic extension: the same shape as a `*.ext.ts` file's default export. */
|
|
45
|
+
export type ExtRegisterFn = (router: ExpressRouter, ctx: ExtensionContext) => void | Promise<void>;
|
|
46
|
+
|
|
40
47
|
const loaded = new Set<string>();
|
|
41
48
|
let extRouter: ExpressRouter | null = null;
|
|
42
49
|
let ctx: ExtensionContext | null = null;
|
|
50
|
+
const pendingProgrammatic: ExtRegisterFn[] = [];
|
|
51
|
+
|
|
52
|
+
async function runProgrammatic(fn: ExtRegisterFn): Promise<void> {
|
|
53
|
+
try {
|
|
54
|
+
await fn(extRouter!, ctx!);
|
|
55
|
+
console.log('[extensions] mounted programmatic extension');
|
|
56
|
+
} catch (err) {
|
|
57
|
+
console.error('[extensions] programmatic register failed:', (err as Error)?.stack || err);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Programmatic equivalent of dropping a `data/extensions/*.ext.ts` file. Funnels through the SAME
|
|
63
|
+
* persistent Router + ExtensionContext (mounted before the SPA catch-all), so a webhook/callback
|
|
64
|
+
* registered this way is reachable exactly like a file-based one. If called before loadExtensions()
|
|
65
|
+
* (the usual case — createShraga queues these pre-boot), it runs when the router is ready.
|
|
66
|
+
*/
|
|
67
|
+
export async function registerExtension(fn: ExtRegisterFn): Promise<void> {
|
|
68
|
+
if (extRouter && ctx) return runProgrammatic(fn);
|
|
69
|
+
pendingProgrammatic.push(fn);
|
|
70
|
+
}
|
|
43
71
|
|
|
44
72
|
async function mountFile(dir: string, f: string): Promise<void> {
|
|
45
73
|
if (loaded.has(f) || !extRouter || !ctx) return;
|
|
@@ -77,8 +105,14 @@ export async function loadExtensions(app: Express): Promise<void> {
|
|
|
77
105
|
log: (...a) => console.log('[extensions]', ...a),
|
|
78
106
|
app,
|
|
79
107
|
emitEvent,
|
|
108
|
+
// Mount on the extension Router (before the SPA catch-all) so hot-loaded
|
|
109
|
+
// webhooks are reachable too — never on `app` directly.
|
|
110
|
+
registerWebhook: (opts) => registerWebhook(extRouter!, opts),
|
|
80
111
|
};
|
|
81
112
|
|
|
113
|
+
// Flush programmatic extensions first (they mount ahead of file-based drop-ins, before the catch-all).
|
|
114
|
+
for (const fn of pendingProgrammatic.splice(0)) await runProgrammatic(fn);
|
|
115
|
+
|
|
82
116
|
const dir = path.resolve(dataPath('extensions'));
|
|
83
117
|
await scan(dir);
|
|
84
118
|
|
package/src/server/hooks.ts
CHANGED
|
@@ -62,7 +62,7 @@ const SLACK_MENTION_FIELDS: Record<string, string[]> = {
|
|
|
62
62
|
const resolveSlackMentions: HookCallback = async (input) => {
|
|
63
63
|
if (input.hook_event_name !== 'PreToolUse') return {};
|
|
64
64
|
const { tool_name, tool_input } = input as PreToolUseHookInput;
|
|
65
|
-
const m = /^mcp__mcp-
|
|
65
|
+
const m = /^mcp__mcp-slack-use__(post_slack_\w+)$/.exec(tool_name);
|
|
66
66
|
if (!m) return {};
|
|
67
67
|
const fields = SLACK_MENTION_FIELDS[m[1]];
|
|
68
68
|
if (!fields) return {};
|
|
@@ -135,7 +135,7 @@ export function buildHooks(): Partial<Record<HookEvent, HookCallbackMatcher[]>>
|
|
|
135
135
|
return {
|
|
136
136
|
PreToolUse: [
|
|
137
137
|
{ matcher: 'Bash', hooks: [forceBackgroundForScripts] },
|
|
138
|
-
{ matcher: 'mcp__mcp-
|
|
138
|
+
{ matcher: 'mcp__mcp-slack-use__post_slack_.*', hooks: [resolveSlackMentions] },
|
|
139
139
|
{ matcher: 'mcp__mcp-firebase-(?:prod|lab)__get_db.*', hooks: [guardFirebaseReads] },
|
|
140
140
|
],
|
|
141
141
|
};
|