shraga 0.1.67 → 0.1.69
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/package.json +1 -1
- package/src/server/boot.ts +4 -0
- package/src/server/notify-owners.ts +33 -10
- package/src/server/para/feature.ts +218 -0
- package/src/server/para/streamer.ts +201 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "shraga",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.69",
|
|
4
4
|
"description": "The teammate you delegate coding to — a self-hostable, multi-user AI coding agent web UI (Claude Code, with a pluggable engine seam).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
package/src/server/boot.ts
CHANGED
|
@@ -25,6 +25,7 @@ import { streamChat, consumeStream, getAgentConfig, saveAgentConfig, getClaudeAu
|
|
|
25
25
|
import { mountFeatures, registerFeature, resumeFeatureSession, collectFeatureFlags, collectSidecarRoutes } from './features.ts';
|
|
26
26
|
import { registerSpaCatchAll } from './spa-catchall.ts';
|
|
27
27
|
import { slackFeature } from './slack/feature.ts';
|
|
28
|
+
import { paraFeature } from './para/feature.ts';
|
|
28
29
|
import { dataPath } from './paths.ts';
|
|
29
30
|
import { getAllSessions, getSession, getSessionHistory, upsertSession, appendMessage, saveConversation, loadConversation, setSessionDirectives, getAutoApprove, setAutoApprove, getSessionsByScheduleId, getSessionsVisibleTo, isSessionVisibleTo, setRunStatus, incrementRetryCount, getRunningSessions, getActiveLockCount, updateScheduledSessionStatus, setShuttingDown, backfillSessionVisibility, writePartial, readPartial, clearPartial, registerLivePartial, unregisterLivePartial, readLivePartial, acquireSessionLock, releaseSessionLock, replaceSessionLock, isSessionLocked, getSessionAbortController, forkSession, generateSessionTitle, type ConvBlock, type ConvMessage, type SessionMeta } from './sessions.ts';
|
|
30
31
|
import { setBroadcaster } from './session-bus.ts';
|
|
@@ -1203,6 +1204,9 @@ if (process.env.SHRAGA_OVERLAY) {
|
|
|
1203
1204
|
// so their routes mount ahead of the SPA fallback, identical to the overlay path.
|
|
1204
1205
|
for (const f of __reg.features ?? []) registerFeature(f);
|
|
1205
1206
|
registerFeature(slackFeature);
|
|
1207
|
+
// para-li external-agent lane — a second medium alongside Slack; both subscribe the owner-notice
|
|
1208
|
+
// bus independently, so neither affects the other.
|
|
1209
|
+
registerFeature(paraFeature);
|
|
1206
1210
|
mountFeatures({ app, requireAuth, broadcast, passive: PASSIVE });
|
|
1207
1211
|
// Fold in feature-contributed sidecar WS proxy routes (the core names none; each add-on adds its own).
|
|
1208
1212
|
Object.assign(WS_PROXY_ROUTES, collectSidecarRoutes());
|
|
@@ -10,12 +10,29 @@ import { emitEvent } from './events/bus.ts';
|
|
|
10
10
|
|
|
11
11
|
export type Owner = { name?: string; slackId: string };
|
|
12
12
|
|
|
13
|
+
/** The OWNERS env list, lowercased. THE definition of "an owner of this deployment" — every
|
|
14
|
+
* medium joins on it, each through whatever identity it happens to hold. */
|
|
15
|
+
export function ownerEmails(): string[] {
|
|
16
|
+
return (process.env.OWNERS ?? '').split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Is this email address an owner of this deployment?
|
|
20
|
+
*
|
|
21
|
+
* Exported because a medium that is not Slack cannot use `resolveOwners`: that returns Slack ids
|
|
22
|
+
* (OWNERS ∩ contacts WITH a Slack id), which is a Slack-shaped answer. The para lane holds a
|
|
23
|
+
* shraga uid + the email of the API key that opened the link, so it joins on the email instead.
|
|
24
|
+
* An empty/unknown address is NOT an owner — the fail-closed direction, since the alternative is
|
|
25
|
+
* fanning a deploy report out to whoever happened to link a para. */
|
|
26
|
+
export function isOwnerEmail(email: string | undefined | null): boolean {
|
|
27
|
+
const e = String(email ?? '').trim().toLowerCase();
|
|
28
|
+
return !!e && ownerEmails().includes(e);
|
|
29
|
+
}
|
|
30
|
+
|
|
13
31
|
/** Owners of THIS deployment (OWNERS env ∩ contacts that have a Slack id). */
|
|
14
32
|
export async function resolveOwners(): Promise<Owner[]> {
|
|
15
33
|
const { getAll } = await import('./contacts.ts');
|
|
16
|
-
const ownerEmails = (process.env.OWNERS ?? '').split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
|
|
17
34
|
return getAll()
|
|
18
|
-
.filter(c => c.slackIds.length > 0 && c.emails.some(e =>
|
|
35
|
+
.filter(c => c.slackIds.length > 0 && c.emails.some(e => isOwnerEmail(e)))
|
|
19
36
|
.map(c => ({ name: c.name, slackId: c.slackIds[0] }));
|
|
20
37
|
}
|
|
21
38
|
|
|
@@ -26,16 +43,22 @@ export function senderStamp(): string {
|
|
|
26
43
|
}
|
|
27
44
|
|
|
28
45
|
/**
|
|
29
|
-
*
|
|
30
|
-
* keyed on the notice `kind`, so a new subsystem needs no change on the Slack
|
|
31
|
-
*
|
|
46
|
+
* Publish an owner notice on the bus. `source` is the event-bus source (used for logging/filtering
|
|
47
|
+
* only) — delivery is keyed on the notice `kind`, so a new subsystem needs no change on the Slack
|
|
48
|
+
* side.
|
|
49
|
+
*
|
|
50
|
+
* Returns NOTHING, on purpose. It used to return "was there anyone to tell", which was a truthful
|
|
51
|
+
* answer only while Slack was the sole medium: `resolveOwners` filters on a SLACK id, so once para
|
|
52
|
+
* subscribes the same bus an empty owner list means "no Slack owner", not "nobody was notified".
|
|
53
|
+
* Rather than keep a boolean whose meaning depends on which features happen to be registered — no
|
|
54
|
+
* caller reads it (`data-sync.ts`, `self-upgrade/index.ts`) — the notice is emitted unconditionally
|
|
55
|
+
* and each subscriber decides for itself. The Slack subscriber already no-ops on an empty
|
|
56
|
+
* `owners`, so Slack behaviour is unchanged.
|
|
32
57
|
*/
|
|
33
|
-
export async function notifyOwners(source: string, text: string): Promise<
|
|
58
|
+
export async function notifyOwners(source: string, text: string): Promise<void> {
|
|
34
59
|
const owners = await resolveOwners();
|
|
60
|
+
emitEvent(source as any, { kind: 'deploy', owners, text: `${text}\n\n_from ${senderStamp()}_` });
|
|
35
61
|
if (!owners.length) {
|
|
36
|
-
console.warn(`[${source}] No owners (OWNERS env) with Slack IDs found
|
|
37
|
-
return false;
|
|
62
|
+
console.warn(`[${source}] No owners (OWNERS env) with Slack IDs found — notice emitted for non-Slack subscribers only`);
|
|
38
63
|
}
|
|
39
|
-
emitEvent(source as any, { kind: 'deploy', owners, text: `${text}\n\n_from ${senderStamp()}_` });
|
|
40
|
-
return true;
|
|
41
64
|
}
|
|
@@ -0,0 +1,218 @@
|
|
|
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
|
+
type Link = ParaCallback & { convId: string; at: number; uid: string; email?: string };
|
|
54
|
+
|
|
55
|
+
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;
|
|
90
|
+
}): Promise<void> {
|
|
91
|
+
const { callback, convId, msgId, sessionId, prompt, uid, userEmail } = args;
|
|
92
|
+
const streamer = new ParaStreamer({ callback, convId, msgId });
|
|
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 });
|
|
123
|
+
}
|
|
124
|
+
else if (ev.type === 'tool_result') blocks.push({ type: 'tool_result', toolUseId: ev.toolUseId, output: ev.output });
|
|
125
|
+
else if (ev.type === 'done') break;
|
|
126
|
+
else if (ev.type === 'error') {
|
|
127
|
+
if (text) { blocks.push({ type: 'text', text }); text = ''; }
|
|
128
|
+
blocks.push({ type: 'error', text: ev.message });
|
|
129
|
+
await streamer.fail(ev.message);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (text) blocks.push({ type: 'text', text });
|
|
134
|
+
await streamer.finish();
|
|
135
|
+
} catch (err) {
|
|
136
|
+
console.error('[para] turn failed:', (err as Error).message);
|
|
137
|
+
await streamer.fail((err as Error).message || 'agent error');
|
|
138
|
+
} finally {
|
|
139
|
+
// The transcript is persisted whatever happened, so the shraga UI and the next turn's context
|
|
140
|
+
// see the same history para saw.
|
|
141
|
+
if (blocks.length) appendMessage(sessionId, { id: crypto.randomUUID(), role: 'assistant', blocks });
|
|
142
|
+
if (releaseSessionLock(sessionId, abortController)) setRunStatus(sessionId, 'idle');
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
let mounted = false;
|
|
147
|
+
let busSubscribed = false;
|
|
148
|
+
|
|
149
|
+
export const paraFeature: ServerFeature = {
|
|
150
|
+
name: 'para',
|
|
151
|
+
|
|
152
|
+
// No capability flag. `flags` is the seam's way to tell the CLIENT a surface exists, and nothing
|
|
153
|
+
// in the client gates on para — the lane is driven entirely by para.li calling in. Declaring one
|
|
154
|
+
// would be dead public surface on /api/features (slackFeature declares none for the same reason).
|
|
155
|
+
|
|
156
|
+
register(ctx: FeatureContext): void {
|
|
157
|
+
// Owner notices → the linked para conversations OF THIS DEPLOYMENT'S OWNERS. Keyed on the
|
|
158
|
+
// notice KIND, not the source, for the reason spelled out in slackFeature: self-upgrade emits
|
|
159
|
+
// under its own source and a source-gated subscriber silently dropped every one of them.
|
|
160
|
+
//
|
|
161
|
+
// WHY NOT `payload.owners`. That field is `{name?, slackId}[]` — the SLACK join, computed by
|
|
162
|
+
// `resolveOwners` as OWNERS ∩ contacts-that-have-a-Slack-id. A para link carries no Slack id,
|
|
163
|
+
// so the field is unmatchable here. Unfiltered, this loop posted every deploy / self-upgrade /
|
|
164
|
+
// data-sync report to EVERY entry in para-links.json — and any shraga user with an API key
|
|
165
|
+
// gets an entry on their first turn (`rememberLink`). The `uid` guard does not help: it stops
|
|
166
|
+
// STEALING another user's connId, not adding your own.
|
|
167
|
+
// The join that works is the one OWNERS is actually expressed in — the email of the API key
|
|
168
|
+
// that opened the link — checked with the same `isOwnerEmail` that backs `resolveOwners`.
|
|
169
|
+
if (!ctx.passive && !busSubscribed) {
|
|
170
|
+
busSubscribed = true;
|
|
171
|
+
subscribeEvents((evt) => {
|
|
172
|
+
const payload = evt.payload as DeployNotice;
|
|
173
|
+
if (payload?.kind !== 'deploy' || !payload.text) return;
|
|
174
|
+
for (const link of Object.values(loadLinks())) {
|
|
175
|
+
if (!isOwnerEmail(link.email)) continue;
|
|
176
|
+
postProactive({ url: link.url, secret: link.secret, connId: link.connId }, link.convId, payload.text)
|
|
177
|
+
.then((ok) => console.log(`[para] owner notice ${ok ? 'delivered' : 'FAILED'} → ${link.convId}`))
|
|
178
|
+
.catch((err) => console.warn('[para] owner notice failed:', (err as Error).message));
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (ctx.passive || mounted) return;
|
|
184
|
+
mounted = true;
|
|
185
|
+
|
|
186
|
+
ctx.app.post('/api/para/turn', (req, res) => {
|
|
187
|
+
const bearer = /^Bearer\s+(.+)$/i.exec(req.get('authorization') ?? '')?.[1];
|
|
188
|
+
const caller = bearer ? validateApiKey(bearer) : null;
|
|
189
|
+
if (!caller) return void res.status(401).json({ error: 'unauthorized' });
|
|
190
|
+
|
|
191
|
+
const { connId, convId, sessionId, msgId, prompt, callback } = req.body as {
|
|
192
|
+
connId?: string; convId?: string; sessionId?: string; msgId?: string; prompt?: string;
|
|
193
|
+
callback?: { url?: string; secret?: string };
|
|
194
|
+
};
|
|
195
|
+
if (!connId || !convId || !msgId || !prompt) return void res.status(400).json({ error: 'connId, convId, msgId and prompt are required' });
|
|
196
|
+
if (!callback?.url || !callback?.secret) return void res.status(400).json({ error: 'callback.url and callback.secret are required' });
|
|
197
|
+
try {
|
|
198
|
+
const u = new URL(callback.url);
|
|
199
|
+
// We hold the owner's credential and will POST to whatever this says, so it is validated
|
|
200
|
+
// here too rather than trusted because the request authenticated.
|
|
201
|
+
if (u.protocol !== 'https:' && u.hostname !== 'localhost' && u.hostname !== '127.0.0.1') throw new Error('https required');
|
|
202
|
+
} catch { return void res.status(400).json({ error: 'callback.url must be a valid HTTPS URL' }); }
|
|
203
|
+
|
|
204
|
+
const cb: ParaCallback = { url: callback.url, secret: callback.secret, connId };
|
|
205
|
+
rememberLink({ ...cb, convId, at: Date.now(), uid: caller.uid, email: caller.email });
|
|
206
|
+
|
|
207
|
+
// ACCEPT, then run. The answer arrives on the callback, so holding this response open would
|
|
208
|
+
// only give para-li's trigger a socket to time out on.
|
|
209
|
+
res.json({ status: 'accepted', sessionId: sessionId || convId });
|
|
210
|
+
void runParaTurn({
|
|
211
|
+
callback: cb, convId, msgId, sessionId: sessionId || convId, prompt,
|
|
212
|
+
uid: caller.uid, userEmail: caller.email,
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
console.log('[para] turn ingress mounted at POST /api/para/turn');
|
|
217
|
+
},
|
|
218
|
+
};
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ParaStreamer — progressive delivery of one agent turn into a para-li conversation row.
|
|
3
|
+
*
|
|
4
|
+
* WHY NOT `SlackStreamer`. The brief said to reuse `mcp-slack-use/src/streamer.ts` rather than
|
|
5
|
+
* write a second streamer. Its throttling contract IS reused — buffer, `flushInterval` (300ms),
|
|
6
|
+
* `flushThreshold` (30 chars), and a serialized `flushChain` so sends never overtake each other,
|
|
7
|
+
* all mirrored here deliberately and with the same defaults. Its *transport* cannot be: every
|
|
8
|
+
* send in that class is a `slackApi(token, 'chat.appendStream'|'chat.startStream'|'chat.update')`
|
|
9
|
+
* call against Slack's three-call streaming protocol, and it lives in a vendored package in a
|
|
10
|
+
* different repo. Para's transport is one signed POST per flush carrying the accumulated text —
|
|
11
|
+
* there is no start/append/stop handshake and no ts to thread. Forking that package to
|
|
12
|
+
* parameterize the transport would be a larger, riskier change to Slack's live path than these
|
|
13
|
+
* ~60 lines, so the shared thing is the CONTRACT, not the code, and this comment is the seam.
|
|
14
|
+
*
|
|
15
|
+
* ACCUMULATE, DON'T APPEND: each flush sends the full text so far. para-li patches the message row
|
|
16
|
+
* with a whole-row `set` (its existing partial-update convention), so a dropped or reordered delta
|
|
17
|
+
* self-heals on the next flush instead of leaving a hole. That is worth more than the bytes.
|
|
18
|
+
*/
|
|
19
|
+
import { createHmac, randomUUID } from 'node:crypto';
|
|
20
|
+
|
|
21
|
+
export interface ParaCallback {
|
|
22
|
+
/** Absolute webhook URL, handed to us per-turn by para-li (never configured here). */
|
|
23
|
+
url: string;
|
|
24
|
+
/** HMAC key for THIS connection, handed over per-turn so a rotation lands on the next message. */
|
|
25
|
+
secret: string;
|
|
26
|
+
/** Connection id — inside the signed material, so a delivery is bound to its connection. */
|
|
27
|
+
connId: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ParaStreamerOptions {
|
|
31
|
+
callback: ParaCallback;
|
|
32
|
+
convId: string;
|
|
33
|
+
/** The row to patch. Omit for a PROACTIVE post (no preceding user turn) — see `post()`. */
|
|
34
|
+
msgId?: string;
|
|
35
|
+
flushInterval?: number;
|
|
36
|
+
flushThreshold?: number;
|
|
37
|
+
/** Show a transient inline marker per tool call, as the Slack streamer does. Default on. */
|
|
38
|
+
toolMarkers?: boolean;
|
|
39
|
+
/** Per-POST wall clock. See `POST_TIMEOUT_MS`. Overridable for tests. */
|
|
40
|
+
postTimeout?: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Signature contract, mirrored byte-for-byte in para-li's `lib/agent-conn.ts#signPayload`.
|
|
44
|
+
* The two repos are separately published, so this is duplicated on purpose; if you change one,
|
|
45
|
+
* change both — a drift here presents as a silent 401 on every delta.
|
|
46
|
+
*
|
|
47
|
+
* The DELIVERY id is in the material because para-li's replay guard dedupes on that header alone;
|
|
48
|
+
* unsigned, it would be the one field an attacker could vary freely to replay a captured delivery
|
|
49
|
+
* inside the signature window.
|
|
50
|
+
*
|
|
51
|
+
* UNESCAPED `.` — why the field boundaries cannot be shifted. There are no length prefixes, so in
|
|
52
|
+
* general `a.b.c` is ambiguous. It holds here because the receiver PINS every field but the last
|
|
53
|
+
* before it verifies: `connId` must be exactly 24 lowercase hex chars (`isConnId`, checked before
|
|
54
|
+
* the signature) and `ts` is `Number(header)` re-stringified, so it is a canonical, dot-free digit
|
|
55
|
+
* run that must also land within 300s of now. `rawBody` is trailing and can absorb nothing. That
|
|
56
|
+
* leaves `deliveryId` as the only free field, and it sits between two fixed-shape neighbours, so
|
|
57
|
+
* no (deliveryId, ts) pair can be re-cut into a different one. If either check is ever relaxed,
|
|
58
|
+
* length-prefix the material instead of relying on this. */
|
|
59
|
+
export function signPara(secret: string, connId: string, deliveryId: string, ts: number, rawBody: string): string {
|
|
60
|
+
return 'v1=' + createHmac('sha256', secret).update(`${connId}.${deliveryId}.${ts}.${rawBody}`).digest('hex');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Wall clock on ONE delivery. Mirrors the 20s AbortController on para-li's own `dispatchAgentTurn`,
|
|
65
|
+
* which is the other half of this lane.
|
|
66
|
+
*
|
|
67
|
+
* WHY A TIMEOUT IS LOAD-BEARING HERE AND NOT A NICETY: flushes are serialized through `flushChain`,
|
|
68
|
+
* and `finish()` awaits that chain. `fetch` has no default timeout, so ONE POST that connects and
|
|
69
|
+
* then never answers (a stalled proxy, a receiver wedged mid-handler, a half-open socket a dead NAT
|
|
70
|
+
* entry never RSTs) blocks every later delta AND the `final` — for the process's lifetime. The
|
|
71
|
+
* visible symptom is not an error: the reply freezes mid-sentence, no `final` ever lands, and
|
|
72
|
+
* nothing is logged, because the failure never returns. A bounded POST turns that permanent wedge
|
|
73
|
+
* into one logged, skipped delta and a `final` that still arrives.
|
|
74
|
+
*/
|
|
75
|
+
export const POST_TIMEOUT_MS = 20_000;
|
|
76
|
+
|
|
77
|
+
/** One signed POST. Returns false on any non-2xx, timeout, or network error, having logged it — the
|
|
78
|
+
* caller keeps streaming rather than aborting the agent's turn over a transport hiccup. */
|
|
79
|
+
export async function postPara(cb: ParaCallback, payload: object, timeoutMs: number = POST_TIMEOUT_MS): Promise<boolean> {
|
|
80
|
+
const raw = JSON.stringify(payload);
|
|
81
|
+
const ts = Date.now();
|
|
82
|
+
// Per-DELIVERY id, not per-turn: para-li's replay guard dedupes on this, so a shared id across
|
|
83
|
+
// the deltas of one turn would drop every delta after the first. Minted here so the exact same
|
|
84
|
+
// value goes into the header AND the signature — they must not be able to diverge.
|
|
85
|
+
const delivery = randomUUID();
|
|
86
|
+
// Abort on a timer rather than `AbortSignal.timeout`: the same shape para-li uses, and the timer
|
|
87
|
+
// is cleared in `finally` so a fast POST leaves nothing pending on the event loop.
|
|
88
|
+
const ac = new AbortController();
|
|
89
|
+
const timer = setTimeout(() => ac.abort(), timeoutMs);
|
|
90
|
+
try {
|
|
91
|
+
const res = await fetch(cb.url, {
|
|
92
|
+
signal: ac.signal,
|
|
93
|
+
method: 'POST',
|
|
94
|
+
headers: {
|
|
95
|
+
'Content-Type': 'application/json',
|
|
96
|
+
'x-agent-conn': cb.connId,
|
|
97
|
+
'x-agent-timestamp': String(ts),
|
|
98
|
+
'x-agent-signature': signPara(cb.secret, cb.connId, delivery, ts, raw),
|
|
99
|
+
'x-agent-delivery': delivery,
|
|
100
|
+
},
|
|
101
|
+
body: raw,
|
|
102
|
+
});
|
|
103
|
+
if (!res.ok) {
|
|
104
|
+
console.warn(`[para-streamer] ${(payload as any).type} rejected: ${res.status} ${await res.text().catch(() => '')}`.slice(0, 300));
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
return true;
|
|
108
|
+
} catch (err) {
|
|
109
|
+
const e = err as Error;
|
|
110
|
+
console.warn('[para-streamer] delivery failed:', e.name === 'AbortError' ? `no response within ${timeoutMs}ms` : e.message);
|
|
111
|
+
return false;
|
|
112
|
+
} finally {
|
|
113
|
+
clearTimeout(timer);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export class ParaStreamer {
|
|
118
|
+
private buffer = '';
|
|
119
|
+
private fullText = '';
|
|
120
|
+
private timer: ReturnType<typeof setTimeout> | null = null;
|
|
121
|
+
private flushChain: Promise<void> = Promise.resolve();
|
|
122
|
+
private aborted = false;
|
|
123
|
+
private afterTool = false;
|
|
124
|
+
|
|
125
|
+
private readonly flushInterval: number;
|
|
126
|
+
private readonly flushThreshold: number;
|
|
127
|
+
private readonly toolMarkers: boolean;
|
|
128
|
+
private readonly postTimeout: number;
|
|
129
|
+
|
|
130
|
+
constructor(private readonly opts: ParaStreamerOptions) {
|
|
131
|
+
this.flushInterval = opts.flushInterval ?? 300;
|
|
132
|
+
this.flushThreshold = opts.flushThreshold ?? 30;
|
|
133
|
+
this.toolMarkers = opts.toolMarkers ?? true;
|
|
134
|
+
this.postTimeout = opts.postTimeout ?? POST_TIMEOUT_MS;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
feed(ev: { type: string; text?: string; tool?: string }): void {
|
|
138
|
+
if (this.aborted || !this.opts.msgId) return;
|
|
139
|
+
|
|
140
|
+
if (ev.type === 'text_delta' && ev.text) {
|
|
141
|
+
if (this.afterTool) { this.fullText += '\n'; this.afterTool = false; }
|
|
142
|
+
this.buffer += ev.text;
|
|
143
|
+
this.fullText += ev.text;
|
|
144
|
+
if (this.buffer.length >= this.flushThreshold) this.enqueueFlush();
|
|
145
|
+
else this.scheduleTimer();
|
|
146
|
+
} else if (ev.type === 'tool_use' && ev.tool && this.toolMarkers) {
|
|
147
|
+
// In-band, transient: `finish()` sends the clean final text, which replaces the row wholesale
|
|
148
|
+
// (para-li writes the whole row), so the marker disappears on its own.
|
|
149
|
+
this.afterTool = true;
|
|
150
|
+
this.fullText += `\n\n_🔧 ${ev.tool.slice(0, 200)}_\n\n`;
|
|
151
|
+
this.enqueueFlush();
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Settle the row with the final text. Returns the text actually sent. */
|
|
156
|
+
async finish(): Promise<string> {
|
|
157
|
+
this.clearTimer();
|
|
158
|
+
this.buffer = '';
|
|
159
|
+
await this.flushChain;
|
|
160
|
+
if (this.aborted || !this.opts.msgId) return this.fullText;
|
|
161
|
+
const text = this.fullText.trim() || '(no output)';
|
|
162
|
+
await postPara(this.opts.callback, { type: 'final', convId: this.opts.convId, msgId: this.opts.msgId, text }, this.postTimeout);
|
|
163
|
+
return text;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Settle the row as a visible failure. The owner sees WHY, in the thread, not only in a log. */
|
|
167
|
+
async fail(message: string): Promise<void> {
|
|
168
|
+
this.aborted = true;
|
|
169
|
+
this.clearTimer();
|
|
170
|
+
await this.flushChain;
|
|
171
|
+
if (!this.opts.msgId) return;
|
|
172
|
+
await postPara(this.opts.callback, { type: 'error', convId: this.opts.convId, msgId: this.opts.msgId, message }, this.postTimeout);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private enqueueFlush(): void {
|
|
176
|
+
this.clearTimer();
|
|
177
|
+
if (!this.fullText) return;
|
|
178
|
+
this.buffer = '';
|
|
179
|
+
const snapshot = this.fullText;
|
|
180
|
+
// Serialized: a later, longer snapshot must never be overtaken by an earlier one, or the row
|
|
181
|
+
// visibly rewinds mid-stream.
|
|
182
|
+
this.flushChain = this.flushChain
|
|
183
|
+
.then(async () => { await postPara(this.opts.callback, { type: 'delta', convId: this.opts.convId, msgId: this.opts.msgId, text: snapshot }, this.postTimeout); })
|
|
184
|
+
.catch((err) => console.warn('[para-streamer] flush error:', (err as Error).message));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private scheduleTimer(): void {
|
|
188
|
+
this.clearTimer();
|
|
189
|
+
this.timer = setTimeout(() => this.enqueueFlush(), this.flushInterval);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
private clearTimer(): void {
|
|
193
|
+
if (this.timer) { clearTimeout(this.timer); this.timer = null; }
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** PROACTIVE post — a scheduled run, a deploy notice, a downtime report. No preceding user turn,
|
|
198
|
+
* so there is no row to patch: para-li mints one. Same signed transport, same fence. */
|
|
199
|
+
export function postProactive(cb: ParaCallback, convId: string, text: string): Promise<boolean> {
|
|
200
|
+
return postPara(cb, { type: 'post', convId, text });
|
|
201
|
+
}
|