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.
@@ -219,10 +219,11 @@ export async function runSchedule(
219
219
  // task.engine/task.model ride the same prompt-directive channel users type by hand —
220
220
  // parseDirectives strips them and resolves aliases. Prepending (vs new plumbing) also persists the
221
221
  // choice into the saved prompt, so the session UI shows what the schedule actually requested.
222
- if (!resume) {
223
- const pins = [task.engine && `engine:${task.engine}`, task.model && `model:${task.model}`].filter(Boolean);
224
- if (pins.length) prompt = `[${pins.join(',')}] ${prompt}`;
225
- }
222
+ // Applied on RESUME too: a resume's prompt is a fresh "continue where you left off" string, not the
223
+ // saved original, so skipping this here silently dropped the schedule's engine/model pin and let the
224
+ // resumed turn run on the global default — a different engine, and a different vendor's bill.
225
+ const pins = [task.engine && `engine:${task.engine}`, task.model && `model:${task.model}`].filter(Boolean);
226
+ if (pins.length) prompt = `[${pins.join(',')}] ${prompt}`;
226
227
 
227
228
  // Save the synthesized user prompt to the conversation (skip on resume — task prompt already persisted).
228
229
  if (!resume) {
@@ -38,6 +38,9 @@ export interface SessionMeta {
38
38
  directives?: Directives;
39
39
  /** Actual model the engine resolved at runtime (from the SDK init message) — ground truth, unlike directives.model which is the request. */
40
40
  lastModel?: string;
41
+ /** Engine that actually ran the last turn — ground truth beside `lastModel`, so the UI can report
42
+ * what executed instead of inferring the engine from the model id's shape. */
43
+ lastEngine?: string;
41
44
  forkedFrom?: string;
42
45
  }
43
46
 
@@ -189,19 +192,24 @@ export function setSessionDirectives(sessionId: string, directives: NonNullable<
189
192
  // Resolved model per running session (set by the engine from the SDK init message).
190
193
  // appendMessage stamps assistant messages from this map so every channel records it.
191
194
  const liveModels = new Map<string, string>();
195
+ const liveEngines = new Map<string, string>();
192
196
 
193
197
  /** Last resolved model for a session (live map first, then persisted lastModel). */
194
198
  export function getSessionModel(sessionId: string): string | undefined {
195
199
  return liveModels.get(sessionId) ?? loadIndex().find((s) => s.sessionId === sessionId)?.lastModel;
196
200
  }
197
201
 
198
- export function setSessionModel(sessionId: string, model: string): void {
199
- if (liveModels.get(sessionId) === model) return;
202
+ /** Record the runtime ground truth for a turn. `engine` is the engine that actually ran it — the
203
+ * pair is what the header reports, so neither half may be inferred from the other. */
204
+ export function setSessionModel(sessionId: string, model: string, engine?: string): void {
205
+ if (liveModels.get(sessionId) === model && (!engine || liveEngines.get(sessionId) === engine)) return;
200
206
  liveModels.set(sessionId, model);
207
+ if (engine) liveEngines.set(sessionId, engine);
201
208
  const sessions = loadIndex();
202
209
  const s = sessions.find((s) => s.sessionId === sessionId);
203
- if (s && s.lastModel !== model) {
210
+ if (s && (s.lastModel !== model || (engine && s.lastEngine !== engine))) {
204
211
  s.lastModel = model;
212
+ if (engine) s.lastEngine = engine;
205
213
  saveIndex(sessions);
206
214
  }
207
215
  }
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Webhook lane — the public extension point for wiring an EXTERNAL CHAT PRODUCT to this agent.
3
+ *
4
+ * The shape of the problem, name-neutrally: some other product holds the conversation UI. It wants
5
+ * to hand this agent one turn and render the answer as it is produced. It cannot hold an HTTP
6
+ * response open for the minutes an agent run takes, and it needs the answer authenticated when it
7
+ * comes back. So the lane is TWO independent requests:
8
+ *
9
+ * 1. INGRESS — the product POSTs one turn to a route THIS module mounts for you.
10
+ * 2. CALLBACK — this agent POSTs the answer back, progressively, to a URL the product supplied
11
+ * IN that turn, each delivery HMAC-signed with a secret the product supplied too.
12
+ *
13
+ * The core registers NOTHING here, exactly as in `features.ts`. It ships the machinery — the signed
14
+ * transport, the flush/coalescing policy, the segment wire shape, and the turn-ingress contract —
15
+ * and an add-on names its own product:
16
+ *
17
+ * ```ts
18
+ * import { registerFeature } from 'shraga/server/features';
19
+ * import { createWebhookLaneFeature } from 'shraga/server/webhook-lane/feature';
20
+ *
21
+ * registerFeature(createWebhookLaneFeature({
22
+ * name: 'acme',
23
+ * route: '/api/acme/turn',
24
+ * onTurnAccepted: (link) => rememberLink(link), // your persistence, your policy
25
+ * }));
26
+ * ```
27
+ *
28
+ * WHAT THE ADD-ON OWNS, and why the core cannot: where links are persisted and under what filename,
29
+ * which links may receive PROACTIVE posts (an owner-notice policy is a product decision — see
30
+ * `postNotice` in `streamer.ts` for the transport), the route path, and any CLI surface. None of
31
+ * that is generalizable without inventing policy, so the seam hands it back.
32
+ *
33
+ * TRUST. The ingress is authenticated with an ordinary shraga API key (`POST /api/api-keys`), so
34
+ * reaching the route requires a credential the owner minted. The callback URL + secret arrive IN
35
+ * that authenticated request — per turn — which is what makes a rotated webhook secret take effect
36
+ * on the very next message with no configuration on this side. The URL is still validated here
37
+ * (https, or loopback for development) rather than trusted because the request authenticated: we
38
+ * hold the owner's credential and will POST to whatever it names.
39
+ */
40
+ import crypto from 'node:crypto';
41
+ import type { ServerFeature, FeatureContext } from '../features.ts';
42
+ import { streamChat } from '../claude.ts';
43
+ import { getMcpConfig } from '../mcp.ts';
44
+ import {
45
+ appendMessage, upsertSession, setRunStatus, acquireSessionLock, releaseSessionLock,
46
+ type ConvBlock,
47
+ } from '../sessions.ts';
48
+ import { validateApiKey } from '../api-keys.ts';
49
+ import { WebhookStreamer, type WebhookTarget } from './streamer.ts';
50
+
51
+ /** One receiver connection, as learned from a turn. Handed to `onTurnAccepted` so an add-on can
52
+ * persist it and later reach the same conversation proactively.
53
+ *
54
+ * `uid` is the shraga user whose API key opened this link, and `email` is that user's address,
55
+ * taken from `validateApiKey` and never from the request body. An add-on that fans PROACTIVE
56
+ * notices out to "owners" must join on the email — a uid does not join to an owner list. */
57
+ export type WebhookLaneLink = WebhookTarget & { convId: string; at: number; uid: string; email?: string };
58
+
59
+ export interface WebhookLaneOptions {
60
+ /** Feature name (must be unique in the registry). Also the default transcript `channel` and
61
+ * turn-context `source` tag, so a one-line registration is coherent. */
62
+ name: string;
63
+ /** Absolute ingress path to mount, e.g. `/api/acme/turn`. Named by the add-on: the core must not
64
+ * contain a product's route. */
65
+ route: string;
66
+ /** Transcript channel tag stamped on the user message. Defaults to `name`. */
67
+ channel?: string;
68
+ /** `context.source` handed to the turn-context seam. Defaults to `name`. */
69
+ source?: string;
70
+ /**
71
+ * Called once per ACCEPTED turn, before the run starts, with the connection this turn negotiated.
72
+ * This is the add-on's chance to persist the link for its proactive lane.
73
+ *
74
+ * NOTIFICATION, NOT A GATE — and deliberately so. Whether a `connId` may be re-pointed at a
75
+ * different user's callback is the add-on's model, so the add-on refuses the WRITE inside this
76
+ * hook; the turn itself still runs and still answers on the callback the caller supplied, because
77
+ * that caller authenticated with their own API key and is entitled to their own answer. Throwing
78
+ * is contained: a broken persistence layer must not cost the user their reply.
79
+ */
80
+ onTurnAccepted?(link: WebhookLaneLink): void;
81
+ /** Streamer knobs, if the defaults in `streamer.ts` do not suit the receiver. */
82
+ streamer?: { flushInterval?: number; flushThreshold?: number; postTimeout?: number };
83
+ }
84
+
85
+ /** The ingress body, as it arrives on the wire. This IS the contract a receiver implements. */
86
+ export interface TurnRequestBody {
87
+ /** Stable id for the receiver connection. Inside the signed material of every callback. */
88
+ connId?: string;
89
+ /** The receiver's conversation id. Echoed on every callback. */
90
+ convId?: string;
91
+ /** Agent session to run in. Defaults to `convId`, so a receiver may omit it entirely. */
92
+ sessionId?: string;
93
+ /** The row to patch with the answer. The receiver mints it before calling. */
94
+ msgId?: string;
95
+ prompt?: string;
96
+ callback?: { url?: string; secret?: string };
97
+ /** Receiver capability negotiation. `'segments'` means "I can store and render structured tool
98
+ * segments" — see `WebhookStreamerOptions.sendSegments`. ABSENT means no, and the receiver gets
99
+ * the flattened in-text tool markers instead. A list, so it can grow without a new field. */
100
+ accepts?: unknown;
101
+ }
102
+
103
+ /** Run one turn, streaming into the receiver's row. Errors settle the row VISIBLY — a reader must
104
+ * never be left watching a "typing…" placeholder that will never resolve. Exported so an add-on
105
+ * with its own ingress (a queue consumer, say) can reuse the run half without the route half. */
106
+ export async function runWebhookTurn(args: {
107
+ callback: WebhookTarget; convId: string; msgId: string; sessionId: string; prompt: string;
108
+ uid: string; userEmail: string; sendSegments: boolean;
109
+ channel: string; source: string;
110
+ streamer?: WebhookLaneOptions['streamer'];
111
+ }): Promise<void> {
112
+ const { callback, convId, msgId, sessionId, prompt, uid, userEmail, sendSegments, channel, source } = args;
113
+ const streamer = new WebhookStreamer({ callback, convId, msgId, sendSegments, ...(args.streamer ?? {}) });
114
+ const abortController = new AbortController();
115
+
116
+ // Lock origin is 'api': the union in sessions.ts is a closed set ('web'|'slack'|'scheduler'|
117
+ // 'api') and this is an authenticated API caller. Widening it just to label the medium would
118
+ // touch recovery and status code paths for no behavioural gain.
119
+ if (!acquireSessionLock(sessionId, 'api', abortController)) {
120
+ // sessionId defaults to convId, so this is genuinely "you sent two messages into the same
121
+ // thread while the first was still running". Say so rather than dropping it silently.
122
+ await streamer.fail('That conversation is already processing a message — wait for it to finish.');
123
+ return;
124
+ }
125
+ upsertSession(sessionId, prompt, { uid, email: userEmail });
126
+ appendMessage(sessionId, { id: crypto.randomUUID(), role: 'user', blocks: [{ type: 'text', text: prompt }], channel });
127
+ setRunStatus(sessionId, 'running', 'web');
128
+
129
+ const blocks: ConvBlock[] = [];
130
+ let text = '';
131
+ try {
132
+ for await (const ev of streamChat({
133
+ prompt, sessionId, uid, userEmail,
134
+ mcpServers: getMcpConfig(uid),
135
+ abortController,
136
+ context: { source, user: userEmail },
137
+ onPermissionRequest: async () => ({ allow: true }),
138
+ })) {
139
+ if (ev.type === 'text_delta') { text += ev.text; streamer.feed({ type: 'text_delta', text: ev.text }); }
140
+ else if (ev.type === 'tool_use') {
141
+ if (text) { blocks.push({ type: 'text', text }); text = ''; }
142
+ blocks.push({ type: 'tool_use', tool: ev.tool, toolUseId: ev.toolUseId, input: ev.input });
143
+ streamer.feed({ type: 'tool_use', tool: ev.tool, toolUseId: ev.toolUseId, input: ev.input });
144
+ }
145
+ else if (ev.type === 'tool_result') {
146
+ blocks.push({ type: 'tool_result', toolUseId: ev.toolUseId, output: ev.output });
147
+ // The `running` → `completed`/`error` transition. Fed unconditionally; the streamer ignores
148
+ // it unless the receiver negotiated segments.
149
+ streamer.feed({ type: 'tool_result', toolUseId: ev.toolUseId, output: ev.output, isError: ev.isError });
150
+ }
151
+ else if (ev.type === 'done') break;
152
+ else if (ev.type === 'error') {
153
+ if (text) { blocks.push({ type: 'text', text }); text = ''; }
154
+ blocks.push({ type: 'error', text: ev.message });
155
+ await streamer.fail(ev.message);
156
+ return;
157
+ }
158
+ }
159
+ if (text) blocks.push({ type: 'text', text });
160
+ await streamer.finish();
161
+ } catch (err) {
162
+ console.error(`[${channel}] turn failed:`, (err as Error).message);
163
+ await streamer.fail((err as Error).message || 'agent error');
164
+ } finally {
165
+ // The transcript is persisted whatever happened, so the shraga UI and the next turn's context
166
+ // see the same history the receiver saw.
167
+ if (blocks.length) appendMessage(sessionId, { id: crypto.randomUUID(), role: 'assistant', blocks });
168
+ if (releaseSessionLock(sessionId, abortController)) setRunStatus(sessionId, 'idle');
169
+ }
170
+ }
171
+
172
+ /**
173
+ * Build a `ServerFeature` that mounts one turn ingress. Register it with `registerFeature()`.
174
+ *
175
+ * No capability flag is declared: `flags` tells the CLIENT that a surface exists, and this lane is
176
+ * driven entirely by the external product calling in — nothing in the client gates on it. An add-on
177
+ * that DOES have a client surface can declare its own flag by spreading this feature.
178
+ */
179
+ export function createWebhookLaneFeature(opts: WebhookLaneOptions): ServerFeature {
180
+ const channel = opts.channel ?? opts.name;
181
+ const source = opts.source ?? opts.name;
182
+ // Per-INSTANCE, not module-global: two lanes may be registered, and a shared guard would let the
183
+ // first one mounted silence the second.
184
+ let mounted = false;
185
+
186
+ return {
187
+ name: opts.name,
188
+
189
+ register(ctx: FeatureContext): void {
190
+ if (ctx.passive || mounted) return;
191
+ mounted = true;
192
+
193
+ ctx.app.post(opts.route, (req, res) => {
194
+ const bearer = /^Bearer\s+(.+)$/i.exec(req.get('authorization') ?? '')?.[1];
195
+ const caller = bearer ? validateApiKey(bearer) : null;
196
+ if (!caller) return void res.status(401).json({ error: 'unauthorized' });
197
+
198
+ const { connId, convId, sessionId, msgId, prompt, callback, accepts } = req.body as TurnRequestBody;
199
+ if (!connId || !convId || !msgId || !prompt) return void res.status(400).json({ error: 'connId, convId, msgId and prompt are required' });
200
+ if (!callback?.url || !callback?.secret) return void res.status(400).json({ error: 'callback.url and callback.secret are required' });
201
+ try {
202
+ const u = new URL(callback.url);
203
+ if (u.protocol !== 'https:' && u.hostname !== 'localhost' && u.hostname !== '127.0.0.1') throw new Error('https required');
204
+ } catch { return void res.status(400).json({ error: 'callback.url must be a valid HTTPS URL' }); }
205
+
206
+ const cb: WebhookTarget = { url: callback.url, secret: callback.secret, connId };
207
+ try {
208
+ opts.onTurnAccepted?.({ ...cb, convId, at: Date.now(), uid: caller.uid, email: caller.email });
209
+ } catch (err) {
210
+ console.warn(`[${opts.name}] onTurnAccepted threw:`, (err as Error).message);
211
+ }
212
+
213
+ // ACCEPT, then run. The answer arrives on the callback, so holding this response open would
214
+ // only give the caller's trigger a socket to time out on.
215
+ res.json({ status: 'accepted', sessionId: sessionId || convId });
216
+ void runWebhookTurn({
217
+ callback: cb, convId, msgId, sessionId: sessionId || convId, prompt,
218
+ uid: caller.uid, userEmail: caller.email,
219
+ sendSegments: Array.isArray(accepts) && accepts.includes('segments'),
220
+ channel, source, streamer: opts.streamer,
221
+ });
222
+ });
223
+
224
+ console.log(`[${opts.name}] turn ingress mounted at POST ${opts.route}`);
225
+ },
226
+ };
227
+ }
@@ -1,25 +1,30 @@
1
1
  /**
2
- * ParaStreamer — progressive delivery of one agent turn into a para-li conversation row.
2
+ * WebhookStreamer — progressive delivery of ONE agent turn to an external receiver over signed
3
+ * webhook callbacks. Name-neutral half of the webhook lane; see `feature.ts` for the seam that
4
+ * mounts it.
3
5
  *
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.
6
+ * WHY A CALLBACK AND NOT A HELD RESPONSE. The receiver POSTs one turn in and we answer on a second,
7
+ * independent request rather than streaming down the ingress response. An agent run is minutes
8
+ * long, so a held body dies to a proxy idle timeout with a half-written row; and a proactive post
9
+ * (a deploy notice, a scheduled digest) gets the same transport for free, with no inbound turn to
10
+ * ride.
14
11
  *
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.
12
+ * WHY NOT `SlackStreamer`. Its throttling CONTRACT is reused deliberately buffer, `flushInterval`
13
+ * (300ms), `flushThreshold` (30 chars), and a serialized `flushChain` so sends never overtake each
14
+ * other, all mirrored here with the same defaults. Its *transport* cannot be: every send in that
15
+ * class is a Slack `chat.appendStream`/`chat.startStream`/`chat.update` call against Slack's
16
+ * three-call streaming protocol. This transport is one signed POST per flush carrying the
17
+ * accumulated text — no start/append/stop handshake, no ts to thread. So the shared thing is the
18
+ * CONTRACT, not the code, and this comment is the seam.
19
+ *
20
+ * ACCUMULATE, DON'T APPEND: each flush sends the full text so far, and the receiver is expected to
21
+ * patch the message row with a whole-row `set`. A dropped or reordered delta then self-heals on the
22
+ * next flush instead of leaving a hole — worth more than the bytes.
18
23
  */
19
24
  import { createHmac, randomUUID } from 'node:crypto';
20
25
 
21
- export interface ParaCallback {
22
- /** Absolute webhook URL, handed to us per-turn by para-li (never configured here). */
26
+ export interface WebhookTarget {
27
+ /** Absolute webhook URL, handed to us per-turn by the receiver (never configured here). */
23
28
  url: string;
24
29
  /** HMAC key for THIS connection, handed over per-turn so a rotation lands on the next message. */
25
30
  secret: string;
@@ -27,15 +32,15 @@ export interface ParaCallback {
27
32
  connId: string;
28
33
  }
29
34
 
30
- export interface ParaStreamerOptions {
31
- callback: ParaCallback;
35
+ export interface WebhookStreamerOptions {
36
+ callback: WebhookTarget;
32
37
  convId: string;
33
38
  /** The row to patch. Omit for a PROACTIVE post (no preceding user turn) — see `post()`. */
34
39
  msgId?: string;
35
40
  flushInterval?: number;
36
41
  flushThreshold?: number;
37
42
  /** Show a transient inline marker per tool call, as the Slack streamer does. Default on.
38
- * Ignored when `sendSegments` is on — see `ParaStreamerOptions.sendSegments`. */
43
+ * Ignored when `sendSegments` is on — see `WebhookStreamerOptions.sendSegments`. */
39
44
  toolMarkers?: boolean;
40
45
  /**
41
46
  * Emit STRUCTURED segments (`text` + `tool`) alongside the accumulated text.
@@ -54,11 +59,12 @@ export interface ParaStreamerOptions {
54
59
  postTimeout?: number;
55
60
  }
56
61
 
57
- /** Signature contract, mirrored byte-for-byte in para-li's `lib/agent-conn.ts#signPayload`.
58
- * The two repos are separately published, so this is duplicated on purpose; if you change one,
59
- * change both a drift here presents as a silent 401 on every delta.
62
+ /** Signature over one delivery. THIS IS A CROSS-PROCESS CONTRACT: the receiver reimplements it in
63
+ * its own codebase, so changing a single byte of the material silently 401s every delta. Anything
64
+ * built on this seam should freeze a known-answer vector on BOTH sides (see the frozen digest in
65
+ * `__tests__/webhook-streamer.test.ts`).
60
66
  *
61
- * The DELIVERY id is in the material because para-li's replay guard dedupes on that header alone;
67
+ * The DELIVERY id is in the material because a receiver's replay guard dedupes on that header alone;
62
68
  * unsigned, it would be the one field an attacker could vary freely to replay a captured delivery
63
69
  * inside the signature window.
64
70
  *
@@ -70,17 +76,15 @@ export interface ParaStreamerOptions {
70
76
  * leaves `deliveryId` as the only free field, and it sits between two fixed-shape neighbours, so
71
77
  * no (deliveryId, ts) pair can be re-cut into a different one. If either check is ever relaxed,
72
78
  * length-prefix the material instead of relying on this. */
73
- export function signPara(secret: string, connId: string, deliveryId: string, ts: number, rawBody: string): string {
79
+ export function signDelivery(secret: string, connId: string, deliveryId: string, ts: number, rawBody: string): string {
74
80
  return 'v1=' + createHmac('sha256', secret).update(`${connId}.${deliveryId}.${ts}.${rawBody}`).digest('hex');
75
81
  }
76
82
 
77
83
  // ── Structured segments ──────────────────────────────────────────────────────
78
84
  //
79
- // SHAPE-COMPATIBLE WITH para-li's OWN `MessageSegment`/`ToolCallInfo` (`lib/para-relay.ts`), which
80
- // its `ConversationChannel` already renders as collapsible tool pills. This is a deliberate reuse:
81
- // the external-agent lane emits the SAME shape the Para's own turns do, so no second renderer, no
82
- // second row field, and no second thing to keep in sync. Duplicated here rather than imported for
83
- // the same reason `signPara` is — the two repos publish separately.
85
+ // The structured mirror of a turn: what the receiver renders as collapsible tool pills alongside
86
+ // the text. It is a WIRE shape, not an internal one — a receiver reimplements it, so treat a field
87
+ // rename here as a breaking change and version it on both sides.
84
88
 
85
89
  export interface ToolCallInfo {
86
90
  id: string;
@@ -100,13 +104,13 @@ export type MessageSegment =
100
104
  * APPEND at the top). Unclamped, one such call would be re-uploaded on every subsequent delta for
101
105
  * the rest of the turn and then parked in a database row forever.
102
106
  *
103
- * The caps are chosen against what the pill actually shows: para-li's `ToolPill` renders args as
104
- * one JSON line and slices the result at 500 chars, so anything past a few KB is invisible detail
105
- * that still costs bandwidth and storage. Truncation is MARKED with the true length — a silently
107
+ * The caps are chosen against what a tool pill actually shows: args as one JSON line and a result
108
+ * sliced at a few hundred chars, so anything past a few KB is invisible detail that still costs
109
+ * bandwidth and storage. Truncation is MARKED with the true length — a silently
106
110
  * shortened `Bash` output is a lie the reader cannot detect.
107
111
  *
108
- * These are the SENDER's caps. para-li re-clamps on ingest (`parseSegments`) because a cap that
109
- * only exists on the sender is not a cap.
112
+ * These are the SENDER's caps. A receiver must re-clamp on ingest too, because a cap that only
113
+ * exists on the sender is not a cap.
110
114
  */
111
115
  export const MAX_TOOL_ARGS_CHARS = 2_000;
112
116
  export const MAX_TOOL_RESULT_CHARS = 4_000;
@@ -140,8 +144,7 @@ export function clampArgs(input: unknown): Record<string, unknown> | undefined {
140
144
  }
141
145
 
142
146
  /**
143
- * Wall clock on ONE delivery. Mirrors the 20s AbortController on para-li's own `dispatchAgentTurn`,
144
- * which is the other half of this lane.
147
+ * Wall clock on ONE delivery.
145
148
  *
146
149
  * WHY A TIMEOUT IS LOAD-BEARING HERE AND NOT A NICETY: flushes are serialized through `flushChain`,
147
150
  * and `finish()` awaits that chain. `fetch` has no default timeout, so ONE POST that connects and
@@ -155,15 +158,15 @@ export const POST_TIMEOUT_MS = 20_000;
155
158
 
156
159
  /** One signed POST. Returns false on any non-2xx, timeout, or network error, having logged it — the
157
160
  * caller keeps streaming rather than aborting the agent's turn over a transport hiccup. */
158
- export async function postPara(cb: ParaCallback, payload: object, timeoutMs: number = POST_TIMEOUT_MS): Promise<boolean> {
161
+ export async function postDelivery(cb: WebhookTarget, payload: object, timeoutMs: number = POST_TIMEOUT_MS): Promise<boolean> {
159
162
  const raw = JSON.stringify(payload);
160
163
  const ts = Date.now();
161
- // Per-DELIVERY id, not per-turn: para-li's replay guard dedupes on this, so a shared id across
164
+ // Per-DELIVERY id, not per-turn: a receiver's replay guard dedupes on this, so a shared id across
162
165
  // the deltas of one turn would drop every delta after the first. Minted here so the exact same
163
166
  // value goes into the header AND the signature — they must not be able to diverge.
164
167
  const delivery = randomUUID();
165
- // Abort on a timer rather than `AbortSignal.timeout`: the same shape para-li uses, and the timer
166
- // is cleared in `finally` so a fast POST leaves nothing pending on the event loop.
168
+ // Abort on a timer rather than `AbortSignal.timeout` so the timer can be cleared in `finally`
169
+ // a fast POST then leaves nothing pending on the event loop.
167
170
  const ac = new AbortController();
168
171
  const timer = setTimeout(() => ac.abort(), timeoutMs);
169
172
  try {
@@ -174,29 +177,34 @@ export async function postPara(cb: ParaCallback, payload: object, timeoutMs: num
174
177
  'Content-Type': 'application/json',
175
178
  'x-agent-conn': cb.connId,
176
179
  'x-agent-timestamp': String(ts),
177
- 'x-agent-signature': signPara(cb.secret, cb.connId, delivery, ts, raw),
180
+ 'x-agent-signature': signDelivery(cb.secret, cb.connId, delivery, ts, raw),
178
181
  'x-agent-delivery': delivery,
179
182
  },
180
183
  body: raw,
181
184
  });
182
185
  if (!res.ok) {
183
- console.warn(`[para-streamer] ${(payload as any).type} rejected: ${res.status} ${await res.text().catch(() => '')}`.slice(0, 300));
186
+ console.warn(`[webhook-lane] ${(payload as any).type} rejected: ${res.status} ${await res.text().catch(() => '')}`.slice(0, 300));
184
187
  return false;
185
188
  }
186
189
  return true;
187
190
  } catch (err) {
188
191
  const e = err as Error;
189
- console.warn('[para-streamer] delivery failed:', e.name === 'AbortError' ? `no response within ${timeoutMs}ms` : e.message);
192
+ console.warn('[webhook-lane] delivery failed:', e.name === 'AbortError' ? `no response within ${timeoutMs}ms` : e.message);
190
193
  return false;
191
194
  } finally {
192
195
  clearTimeout(timer);
193
196
  }
194
197
  }
195
198
 
196
- export class ParaStreamer {
199
+ export class WebhookStreamer {
197
200
  private buffer = '';
198
201
  private fullText = '';
199
202
  private timer: ReturnType<typeof setTimeout> | null = null;
203
+ /** When a TOOL event last forced a delta out, so a burst of them is rate-limited by the same
204
+ * `flushInterval` text uses. Deliberately NOT bumped by text flushes: a tool call arriving right
205
+ * after the model spoke is exactly the case that must show its pill at once. `0` = never, so the
206
+ * first tool of a turn is always immediate. */
207
+ private lastToolFlushAt = 0;
200
208
  private flushChain: Promise<void> = Promise.resolve();
201
209
  private aborted = false;
202
210
  private afterTool = false;
@@ -213,7 +221,7 @@ export class ParaStreamer {
213
221
  private readonly sendSegments: boolean;
214
222
  private readonly postTimeout: number;
215
223
 
216
- constructor(private readonly opts: ParaStreamerOptions) {
224
+ constructor(private readonly opts: WebhookStreamerOptions) {
217
225
  this.flushInterval = opts.flushInterval ?? 300;
218
226
  this.flushThreshold = opts.flushThreshold ?? 30;
219
227
  this.sendSegments = opts.sendSegments ?? false;
@@ -245,10 +253,10 @@ export class ParaStreamer {
245
253
  this.segments.push({ type: 'tool', tool: info });
246
254
  }
247
255
  this.toolCount++;
248
- this.enqueueFlush();
256
+ this.flushToolChange();
249
257
  } else if (this.toolMarkers) {
250
258
  // In-band, transient: `finish()` sends the clean final text, which replaces the row wholesale
251
- // (para-li writes the whole row), so the marker disappears on its own.
259
+ // (the receiver writes the whole row), so the marker disappears on its own.
252
260
  this.afterTool = true;
253
261
  this.fullText += `\n\n_🔧 ${ev.tool.slice(0, 200)}_\n\n`;
254
262
  this.enqueueFlush();
@@ -260,13 +268,12 @@ export class ParaStreamer {
260
268
  if (!info) return;
261
269
  info.status = ev.isError ? 'error' : 'completed';
262
270
  if (ev.output) info.result = clampText(ev.output, MAX_TOOL_RESULT_CHARS);
263
- this.enqueueFlush();
271
+ this.flushToolChange();
264
272
  }
265
273
  }
266
274
 
267
- /** Grow the trailing text segment, or start one. Mirrors `buildSegmentHost` in para-li's own
268
- * `lib/para-agent.ts` consecutive text stays ONE segment, so the pills sit between paragraphs
269
- * instead of shredding the answer into a segment per delta. */
275
+ /** Grow the trailing text segment, or start one: consecutive text stays ONE segment, so the pills
276
+ * sit between paragraphs instead of shredding the answer into a segment per delta. */
270
277
  private appendSegmentText(text: string): void {
271
278
  if (!this.sendSegments) return;
272
279
  const last = this.segments[this.segments.length - 1];
@@ -291,7 +298,7 @@ export class ParaStreamer {
291
298
  await this.flushChain;
292
299
  if (this.aborted || !this.opts.msgId) return this.fullText;
293
300
  const text = this.fullText.trim() || '(no output)';
294
- await postPara(this.opts.callback, {
301
+ await postDelivery(this.opts.callback, {
295
302
  type: 'final', convId: this.opts.convId, msgId: this.opts.msgId, text,
296
303
  ...(this.sendSegments ? { segments: this.segmentSnapshot() } : {}),
297
304
  }, this.postTimeout);
@@ -304,15 +311,38 @@ export class ParaStreamer {
304
311
  this.clearTimer();
305
312
  await this.flushChain;
306
313
  if (!this.opts.msgId) return;
307
- await postPara(this.opts.callback, { type: 'error', convId: this.opts.convId, msgId: this.opts.msgId, message }, this.postTimeout);
314
+ await postDelivery(this.opts.callback, { type: 'error', convId: this.opts.convId, msgId: this.opts.msgId, message }, this.postTimeout);
315
+ }
316
+
317
+ /**
318
+ * Tool events, throttled — but never at the cost of "a tool that is still running reads as
319
+ * running", which is the whole point of the pill.
320
+ *
321
+ * Each flush re-sends the WHOLE accumulated snapshot (up to ~600 KB at the ingest caps) and
322
+ * costs a whole-row `set` plus a live re-render on the receiver, so a POST per `tool_use` AND
323
+ * per `tool_result` made a 100-tool turn ~200 POSTs and tens of MB — quadratic in the tool
324
+ * count. Text has always been throttled for exactly this reason; tool events simply were not.
325
+ *
326
+ * So they take the same `flushInterval` budget: go out NOW if nothing has been sent within it
327
+ * (a turn that opens with a tool still shows its pill immediately), otherwise arm the timer. The
328
+ * timer is the guarantee — every state change is delivered within `flushInterval`, so no pill is
329
+ * ever stranded and the receiver's stall watchdog keeps getting fed.
330
+ */
331
+ private flushToolChange(): void {
332
+ if (Date.now() - this.lastToolFlushAt >= this.flushInterval) {
333
+ this.lastToolFlushAt = Date.now();
334
+ this.enqueueFlush();
335
+ } else {
336
+ this.scheduleTimer();
337
+ }
308
338
  }
309
339
 
310
340
  private enqueueFlush(): void {
311
341
  this.clearTimer();
312
342
  // `segments` matters here too: a turn that opens with a tool call has produced NO text yet, and
313
343
  // the old guard would have swallowed that flush — so the first pill would not appear until the
314
- // model started talking, and, worse, the delta that bumps para-li's stall watchdog
315
- // (`lastActivityAt`) would never be sent for a long tool-only stretch.
344
+ // model started talking, and, worse, the delta that feeds a receiver's stall watchdog would
345
+ // never be sent for a long tool-only stretch.
316
346
  if (!this.fullText && !this.segments.length) return;
317
347
  this.buffer = '';
318
348
  const snapshot = this.fullText;
@@ -320,8 +350,8 @@ export class ParaStreamer {
320
350
  // Serialized: a later, longer snapshot must never be overtaken by an earlier one, or the row
321
351
  // visibly rewinds mid-stream.
322
352
  this.flushChain = this.flushChain
323
- .then(async () => { await postPara(this.opts.callback, { type: 'delta', convId: this.opts.convId, msgId: this.opts.msgId, text: snapshot, ...(segs ? { segments: segs } : {}) }, this.postTimeout); })
324
- .catch((err) => console.warn('[para-streamer] flush error:', (err as Error).message));
353
+ .then(async () => { await postDelivery(this.opts.callback, { type: 'delta', convId: this.opts.convId, msgId: this.opts.msgId, text: snapshot, ...(segs ? { segments: segs } : {}) }, this.postTimeout); })
354
+ .catch((err) => console.warn('[webhook-lane] flush error:', (err as Error).message));
325
355
  }
326
356
 
327
357
  private scheduleTimer(): void {
@@ -335,7 +365,7 @@ export class ParaStreamer {
335
365
  }
336
366
 
337
367
  /** PROACTIVE post — a scheduled run, a deploy notice, a downtime report. No preceding user turn,
338
- * so there is no row to patch: para-li mints one. Same signed transport, same fence. */
339
- export function postProactive(cb: ParaCallback, convId: string, text: string): Promise<boolean> {
340
- return postPara(cb, { type: 'post', convId, text });
368
+ * so there is no row to patch: the receiver mints one. Same signed transport, same fence. */
369
+ export function postNotice(cb: WebhookTarget, convId: string, text: string): Promise<boolean> {
370
+ return postDelivery(cb, { type: 'post', convId, text });
341
371
  }