shraga 0.1.87 → 0.1.89
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 +2 -0
- package/dist/client/assets/{index-BSvY0squ.js → index-B-8NBGtJ.js} +38 -38
- package/dist/client/index.html +1 -1
- package/package.json +1 -1
- package/src/cli.ts +28 -51
- package/src/client/components/ConversationHeader.tsx +54 -11
- package/src/client/components/ConversationPane.tsx +8 -1
- package/src/client/lib/ws.ts +1 -1
- package/src/server/boot.ts +13 -7
- package/src/server/claude.ts +14 -3
- package/src/server/data-sync.ts +26 -2
- package/src/server/engine/claude-code.ts +27 -4
- package/src/server/engine/index.ts +20 -8
- package/src/server/notify-owners.ts +7 -6
- package/src/server/scheduler/builtins.ts +15 -1
- package/src/server/scheduler/runner.ts +5 -4
- package/src/server/sessions.ts +11 -3
- package/src/server/webhook-lane/feature.ts +227 -0
- package/src/server/{para → webhook-lane}/streamer.ts +90 -60
- package/src/server/para/feature.ts +0 -229
|
@@ -1,229 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* paraFeature — the sender half of the para-li external-agent lane.
|
|
3
|
-
*
|
|
4
|
-
* Mirrors `slackFeature` exactly in shape: one `ServerFeature` that (a) mounts an ingress route and
|
|
5
|
-
* (b) subscribes the owner-notice event bus so deploy / self-upgrade / downtime notices reach the
|
|
6
|
-
* medium. Slack is untouched and the two coexist — both subscribe the same bus, neither knows about
|
|
7
|
-
* the other, and a notice is delivered to each independently.
|
|
8
|
-
*
|
|
9
|
-
* TRANSPORT. para-li POSTs one turn here and we stream the answer BACK to it over signed webhook
|
|
10
|
-
* calls (see `streamer.ts`), rather than holding this response open. para-li's caller is a Bodify
|
|
11
|
-
* trigger whose lifetime is the turn, so a multi-minute agent run held on one response body dies to
|
|
12
|
-
* a proxy idle timeout with a half-written row. Two independent requests also give the proactive
|
|
13
|
-
* lane the same transport for free.
|
|
14
|
-
*
|
|
15
|
-
* TRUST. The turn request is authenticated with an ordinary shraga API key (`POST /api/api-keys`),
|
|
16
|
-
* so reaching this route requires a credential the owner minted. The callback URL + secret arrive
|
|
17
|
-
* IN that authenticated request — para-li tells us where to answer and with what, per turn, which
|
|
18
|
-
* is what makes a rotated webhook secret take effect on the very next message with no config here.
|
|
19
|
-
*/
|
|
20
|
-
import type { ServerFeature, FeatureContext } from '../features.ts';
|
|
21
|
-
import crypto from 'node:crypto';
|
|
22
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
23
|
-
import { subscribeEvents } from '../events/bus.ts';
|
|
24
|
-
import { streamChat } from '../claude.ts';
|
|
25
|
-
import { getMcpConfig } from '../mcp.ts';
|
|
26
|
-
import { dataPath } from '../paths.ts';
|
|
27
|
-
import {
|
|
28
|
-
appendMessage, upsertSession, setRunStatus, acquireSessionLock, releaseSessionLock,
|
|
29
|
-
type ConvBlock,
|
|
30
|
-
} from '../sessions.ts';
|
|
31
|
-
import { validateApiKey } from '../api-keys.ts';
|
|
32
|
-
import { isOwnerEmail } from '../notify-owners.ts';
|
|
33
|
-
import { ParaStreamer, postProactive, type ParaCallback } from './streamer.ts';
|
|
34
|
-
|
|
35
|
-
interface DeployNotice { kind: 'deploy'; owners: { name?: string; slackId: string }[]; text: string }
|
|
36
|
-
|
|
37
|
-
/** Last known para conversation per connection — the proactive lane's destination.
|
|
38
|
-
*
|
|
39
|
-
* Learned from the first turn rather than configured: para-li already tells us the conv and the
|
|
40
|
-
* callback on every turn, so a second source of truth would only be a thing to drift. Persisted
|
|
41
|
-
* because a deploy notice fires right after a RESTART, which is exactly when an in-memory map is
|
|
42
|
-
* empty — the one moment the feature has to work. Lives beside `api-keys.json` in the data dir and
|
|
43
|
-
* holds the webhook secret, so it inherits that file's protection, no more and no less. */
|
|
44
|
-
const LINKS_PATH = dataPath('para-links.json');
|
|
45
|
-
/** `uid` is the shraga user whose API key opened this link. It is the OWNER of the entry — see
|
|
46
|
-
* `rememberLink`.
|
|
47
|
-
*
|
|
48
|
-
* `email` is that user's address, recorded so the PROACTIVE lane can answer "is this link's user
|
|
49
|
-
* an owner of this deployment?" — `OWNERS` is an email list, and a uid does not join to it. It is
|
|
50
|
-
* taken from `validateApiKey`, never from the request body. A link written before this field
|
|
51
|
-
* existed has no email and is therefore not an owner: it receives no notices until its next turn
|
|
52
|
-
* refreshes the entry. */
|
|
53
|
-
export type Link = ParaCallback & { convId: string; at: number; uid: string; email?: string };
|
|
54
|
-
|
|
55
|
-
export function loadLinks(): Record<string, Link> {
|
|
56
|
-
if (!existsSync(LINKS_PATH)) return {};
|
|
57
|
-
try { return JSON.parse(readFileSync(LINKS_PATH, 'utf-8')); } catch (err) {
|
|
58
|
-
console.warn('[para] links file unreadable, starting empty:', (err as Error).message);
|
|
59
|
-
return {};
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
/** Record (or refresh) a connection's callback.
|
|
63
|
-
*
|
|
64
|
-
* ONE USER OWNS A connId. `connId` is chosen by the caller, so without this an API key for user A
|
|
65
|
-
* could claim a connId already linked by user B and re-point every future PROACTIVE notice
|
|
66
|
-
* (deploy reports, self-upgrade outcomes) at A's URL + secret. An API key is already full agent
|
|
67
|
-
* access to its own user, so this is not a privilege boundary being invented — it is the one
|
|
68
|
-
* cross-user step that access does not otherwise imply, so it is refused rather than logged. */
|
|
69
|
-
function rememberLink(link: Link): void {
|
|
70
|
-
try {
|
|
71
|
-
const all = loadLinks();
|
|
72
|
-
const prev = all[link.connId];
|
|
73
|
-
if (prev?.uid && prev.uid !== link.uid) {
|
|
74
|
-
console.warn(`[para] refusing to re-link ${link.connId}: owned by another user`);
|
|
75
|
-
return;
|
|
76
|
-
}
|
|
77
|
-
all[link.connId] = link;
|
|
78
|
-
mkdirSync(dataPath(''), { recursive: true });
|
|
79
|
-
writeFileSync(LINKS_PATH, JSON.stringify(all, null, 2));
|
|
80
|
-
} catch (err) {
|
|
81
|
-
console.warn('[para] could not persist link:', (err as Error).message);
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/** Run one turn, streaming into the para row. Errors settle the row visibly — the owner must never
|
|
86
|
-
* be left watching a "typing…" placeholder that will never resolve. */
|
|
87
|
-
async function runParaTurn(args: {
|
|
88
|
-
callback: ParaCallback; convId: string; msgId: string; sessionId: string; prompt: string;
|
|
89
|
-
uid: string; userEmail: string; sendSegments: boolean;
|
|
90
|
-
}): Promise<void> {
|
|
91
|
-
const { callback, convId, msgId, sessionId, prompt, uid, userEmail, sendSegments } = args;
|
|
92
|
-
const streamer = new ParaStreamer({ callback, convId, msgId, sendSegments });
|
|
93
|
-
const abortController = new AbortController();
|
|
94
|
-
|
|
95
|
-
// Lock origin is 'api': the union in sessions.ts is a closed set ('web'|'slack'|'scheduler'|
|
|
96
|
-
// 'api') and this is an authenticated API caller. Widening it just to label the medium would
|
|
97
|
-
// touch recovery and status code paths for no behavioural gain.
|
|
98
|
-
if (!acquireSessionLock(sessionId, 'api', abortController)) {
|
|
99
|
-
// sessionId === convId, so this is genuinely "you sent two messages into the same thread while
|
|
100
|
-
// the first was still running". Say so rather than dropping it silently.
|
|
101
|
-
await streamer.fail('That conversation is already processing a message — wait for it to finish.');
|
|
102
|
-
return;
|
|
103
|
-
}
|
|
104
|
-
upsertSession(sessionId, prompt, { uid, email: userEmail });
|
|
105
|
-
appendMessage(sessionId, { id: crypto.randomUUID(), role: 'user', blocks: [{ type: 'text', text: prompt }], channel: 'para' });
|
|
106
|
-
setRunStatus(sessionId, 'running', 'web');
|
|
107
|
-
|
|
108
|
-
const blocks: ConvBlock[] = [];
|
|
109
|
-
let text = '';
|
|
110
|
-
try {
|
|
111
|
-
for await (const ev of streamChat({
|
|
112
|
-
prompt, sessionId, uid, userEmail,
|
|
113
|
-
mcpServers: getMcpConfig(uid),
|
|
114
|
-
abortController,
|
|
115
|
-
context: { source: 'para', user: userEmail },
|
|
116
|
-
onPermissionRequest: async () => ({ allow: true }),
|
|
117
|
-
})) {
|
|
118
|
-
if (ev.type === 'text_delta') { text += ev.text; streamer.feed({ type: 'text_delta', text: ev.text }); }
|
|
119
|
-
else if (ev.type === 'tool_use') {
|
|
120
|
-
if (text) { blocks.push({ type: 'text', text }); text = ''; }
|
|
121
|
-
blocks.push({ type: 'tool_use', tool: ev.tool, toolUseId: ev.toolUseId, input: ev.input });
|
|
122
|
-
streamer.feed({ type: 'tool_use', tool: ev.tool, toolUseId: ev.toolUseId, input: ev.input });
|
|
123
|
-
}
|
|
124
|
-
else if (ev.type === 'tool_result') {
|
|
125
|
-
blocks.push({ type: 'tool_result', toolUseId: ev.toolUseId, output: ev.output });
|
|
126
|
-
// The `running` → `completed`/`error` transition. Fed unconditionally; the streamer ignores
|
|
127
|
-
// it unless the receiver negotiated segments.
|
|
128
|
-
streamer.feed({ type: 'tool_result', toolUseId: ev.toolUseId, output: ev.output, isError: ev.isError });
|
|
129
|
-
}
|
|
130
|
-
else if (ev.type === 'done') break;
|
|
131
|
-
else if (ev.type === 'error') {
|
|
132
|
-
if (text) { blocks.push({ type: 'text', text }); text = ''; }
|
|
133
|
-
blocks.push({ type: 'error', text: ev.message });
|
|
134
|
-
await streamer.fail(ev.message);
|
|
135
|
-
return;
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
if (text) blocks.push({ type: 'text', text });
|
|
139
|
-
await streamer.finish();
|
|
140
|
-
} catch (err) {
|
|
141
|
-
console.error('[para] turn failed:', (err as Error).message);
|
|
142
|
-
await streamer.fail((err as Error).message || 'agent error');
|
|
143
|
-
} finally {
|
|
144
|
-
// The transcript is persisted whatever happened, so the shraga UI and the next turn's context
|
|
145
|
-
// see the same history para saw.
|
|
146
|
-
if (blocks.length) appendMessage(sessionId, { id: crypto.randomUUID(), role: 'assistant', blocks });
|
|
147
|
-
if (releaseSessionLock(sessionId, abortController)) setRunStatus(sessionId, 'idle');
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
let mounted = false;
|
|
152
|
-
let busSubscribed = false;
|
|
153
|
-
|
|
154
|
-
export const paraFeature: ServerFeature = {
|
|
155
|
-
name: 'para',
|
|
156
|
-
|
|
157
|
-
// No capability flag. `flags` is the seam's way to tell the CLIENT a surface exists, and nothing
|
|
158
|
-
// in the client gates on para — the lane is driven entirely by para.li calling in. Declaring one
|
|
159
|
-
// would be dead public surface on /api/features (slackFeature declares none for the same reason).
|
|
160
|
-
|
|
161
|
-
register(ctx: FeatureContext): void {
|
|
162
|
-
// Owner notices → the linked para conversations OF THIS DEPLOYMENT'S OWNERS. Keyed on the
|
|
163
|
-
// notice KIND, not the source, for the reason spelled out in slackFeature: self-upgrade emits
|
|
164
|
-
// under its own source and a source-gated subscriber silently dropped every one of them.
|
|
165
|
-
//
|
|
166
|
-
// WHY NOT `payload.owners`. That field is `{name?, slackId}[]` — the SLACK join, computed by
|
|
167
|
-
// `resolveOwners` as OWNERS ∩ contacts-that-have-a-Slack-id. A para link carries no Slack id,
|
|
168
|
-
// so the field is unmatchable here. Unfiltered, this loop posted every deploy / self-upgrade /
|
|
169
|
-
// data-sync report to EVERY entry in para-links.json — and any shraga user with an API key
|
|
170
|
-
// gets an entry on their first turn (`rememberLink`). The `uid` guard does not help: it stops
|
|
171
|
-
// STEALING another user's connId, not adding your own.
|
|
172
|
-
// The join that works is the one OWNERS is actually expressed in — the email of the API key
|
|
173
|
-
// that opened the link — checked with the same `isOwnerEmail` that backs `resolveOwners`.
|
|
174
|
-
if (!ctx.passive && !busSubscribed) {
|
|
175
|
-
busSubscribed = true;
|
|
176
|
-
subscribeEvents((evt) => {
|
|
177
|
-
const payload = evt.payload as DeployNotice;
|
|
178
|
-
if (payload?.kind !== 'deploy' || !payload.text) return;
|
|
179
|
-
for (const link of Object.values(loadLinks())) {
|
|
180
|
-
if (!isOwnerEmail(link.email)) continue;
|
|
181
|
-
postProactive({ url: link.url, secret: link.secret, connId: link.connId }, link.convId, payload.text)
|
|
182
|
-
.then((ok) => console.log(`[para] owner notice ${ok ? 'delivered' : 'FAILED'} → ${link.convId}`))
|
|
183
|
-
.catch((err) => console.warn('[para] owner notice failed:', (err as Error).message));
|
|
184
|
-
}
|
|
185
|
-
});
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
if (ctx.passive || mounted) return;
|
|
189
|
-
mounted = true;
|
|
190
|
-
|
|
191
|
-
ctx.app.post('/api/para/turn', (req, res) => {
|
|
192
|
-
const bearer = /^Bearer\s+(.+)$/i.exec(req.get('authorization') ?? '')?.[1];
|
|
193
|
-
const caller = bearer ? validateApiKey(bearer) : null;
|
|
194
|
-
if (!caller) return void res.status(401).json({ error: 'unauthorized' });
|
|
195
|
-
|
|
196
|
-
const { connId, convId, sessionId, msgId, prompt, callback, accepts } = req.body as {
|
|
197
|
-
connId?: string; convId?: string; sessionId?: string; msgId?: string; prompt?: string;
|
|
198
|
-
callback?: { url?: string; secret?: string };
|
|
199
|
-
/** Receiver capability negotiation. `'segments'` means "I can store and render structured
|
|
200
|
-
* tool segments" — see `ParaStreamerOptions.sendSegments`. ABSENT means no: a para-li that
|
|
201
|
-
* predates this field, and a lane (groups) that deliberately declines, both fall back to
|
|
202
|
-
* the flattened `_🔧 Tool_` markers in the text. */
|
|
203
|
-
accepts?: unknown;
|
|
204
|
-
};
|
|
205
|
-
if (!connId || !convId || !msgId || !prompt) return void res.status(400).json({ error: 'connId, convId, msgId and prompt are required' });
|
|
206
|
-
if (!callback?.url || !callback?.secret) return void res.status(400).json({ error: 'callback.url and callback.secret are required' });
|
|
207
|
-
try {
|
|
208
|
-
const u = new URL(callback.url);
|
|
209
|
-
// We hold the owner's credential and will POST to whatever this says, so it is validated
|
|
210
|
-
// here too rather than trusted because the request authenticated.
|
|
211
|
-
if (u.protocol !== 'https:' && u.hostname !== 'localhost' && u.hostname !== '127.0.0.1') throw new Error('https required');
|
|
212
|
-
} catch { return void res.status(400).json({ error: 'callback.url must be a valid HTTPS URL' }); }
|
|
213
|
-
|
|
214
|
-
const cb: ParaCallback = { url: callback.url, secret: callback.secret, connId };
|
|
215
|
-
rememberLink({ ...cb, convId, at: Date.now(), uid: caller.uid, email: caller.email });
|
|
216
|
-
|
|
217
|
-
// ACCEPT, then run. The answer arrives on the callback, so holding this response open would
|
|
218
|
-
// only give para-li's trigger a socket to time out on.
|
|
219
|
-
res.json({ status: 'accepted', sessionId: sessionId || convId });
|
|
220
|
-
void runParaTurn({
|
|
221
|
-
callback: cb, convId, msgId, sessionId: sessionId || convId, prompt,
|
|
222
|
-
uid: caller.uid, userEmail: caller.email,
|
|
223
|
-
sendSegments: Array.isArray(accepts) && accepts.includes('segments'),
|
|
224
|
-
});
|
|
225
|
-
});
|
|
226
|
-
|
|
227
|
-
console.log('[para] turn ingress mounted at POST /api/para/turn');
|
|
228
|
-
},
|
|
229
|
-
};
|