shraga 0.1.86 → 0.1.88

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 CHANGED
@@ -85,7 +85,9 @@ The core is deliberately small and grows through documented seams (see
85
85
  | **Server features** (`src/server/features.ts`) | Mount new server-side surfaces via `registerFeature`. |
86
86
  | **Client slots** (`src/client/lib/slots.tsx`) | Inject UI into typed render slots without touching core. |
87
87
  | **Route extensions** (`data/extensions/*.ext.ts`) | Drop-in public routes (webhooks, OAuth callbacks) per deployment. |
88
+ | **Webhook lane** (`src/server/webhook-lane/`) | Give an external chat product a turn ingress that streams the answer back to a signed callback. |
88
89
  | **`SHRAGA_OVERLAY`** | Load a whole external add-on module at startup (the optional add-ons above). |
90
+ | **`SHRAGA_CLI_EXT`** | Hand any subcommand the core does not own to an external CLI module. |
89
91
 
90
92
  ## Use as a library
91
93
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shraga",
3
- "version": "0.1.86",
3
+ "version": "0.1.88",
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/cli.ts CHANGED
@@ -29,19 +29,23 @@ Subcommands:
29
29
  ingress Run the host-header TCP router (INGRESS_PORT, default 3100)
30
30
  for previews + blue-green flips. Own process, survives restarts.
31
31
  user add <email> <pw> Seed a local username/password user
32
- para post [text] Push a message into a linked para.li conversation.
33
- Text from [text], --file <path>, or stdin (preferred for
34
- multi-line reports). --conn <id> picks a link when several exist.
32
+ <add-on> Any other subcommand is handed to SHRAGA_CLI_EXT, if set.
35
33
 
36
34
  Environment:
37
35
  CLOUDFLARE_TUNNEL_TOKEN If set, starts a Cloudflare Tunnel alongside the server.
38
36
  Get the token from Cloudflare Zero Trust > Tunnels > Configure.
39
37
  ANTHROPIC_API_KEY Claude API key (or use \`claude auth login\` for subscription auth)
40
38
  VITE_FIREBASE_CONFIG_PROD Firebase config JSON for auth (prod project)
39
+ SHRAGA_CLI_EXT Absolute path to an add-on CLI module. Any subcommand this CLI
40
+ does not own is handed to it before the server boots. See the
41
+ CLI EXTENSION SEAM comment in src/cli.ts.
41
42
  `.trim());
42
43
  process.exit(0);
43
44
  }
44
45
 
46
+ /** Subcommands the core owns. Everything else is offered to SHRAGA_CLI_EXT (see below). */
47
+ const CORE_SUBCOMMANDS = new Set(['user', 'ingress']);
48
+
45
49
  const port = flag('port', 'p');
46
50
  const dataDir = flag('data-dir', 'd');
47
51
 
@@ -62,58 +66,31 @@ if (args[0] === 'user' && args[1] === 'add') {
62
66
  process.exit(0);
63
67
  }
64
68
 
65
- // `shraga para post [text]` — the PROACTIVE half of the para.li agent lane.
69
+ // ── CLI EXTENSION SEAM ───────────────────────────────────────────────────────
66
70
  //
67
- // The reactive half (para asks, shraga answers) is driven by para.li calling `/api/para/turn`, and
68
- // the only other outbound path is the deploy-notice bus subscriber in `para/feature.ts` which is
69
- // gated on `kind === 'deploy'`. So a SCHEDULED run (a daily digest) had no way to reach the lane at
70
- // all: it composes text on its own clock with no inbound turn to answer and no deploy event to ride.
71
- // This is that door, and it is a CLI rather than a route because the caller is the agent itself,
72
- // running Bash on this very box: a local process reading the same `para-links.json` the feature
73
- // writes needs no listener, no API key, and no second copy of the callback secret.
71
+ // The twin of the `registerFeature` / SHRAGA_OVERLAY seam, for the command line. The core owns a
72
+ // short, fixed list of subcommands; ANY other one is handed to the module named by SHRAGA_CLI_EXT,
73
+ // so a downstream distribution can ship its own commands without the core naming them.
74
74
  //
75
- // Text comes from stdin by default. A digest is multi-line markdown with backticks and emoji, and
76
- // making a model shell-quote that into argv is a defect generator; `... | shraga para post` is not.
77
- if (args[0] === 'para' && args[1] === 'post') {
78
- const { loadLinks } = await import('./server/para/feature.ts');
79
- const { postProactive } = await import('./server/para/streamer.ts');
80
-
81
- const links = loadLinks();
82
- const wanted = flag('conn');
83
- const ids = Object.keys(links);
84
- // Refuse to guess. Picking "the first" would silently deliver a private report to whichever
85
- // connection happened to sort first the day a second one is added.
86
- const connId = wanted ?? (ids.length === 1 ? ids[0] : undefined);
87
- if (!connId || !links[connId]) {
88
- console.error(ids.length
89
- ? `usage: shraga para post --conn <connId> (linked: ${ids.join(', ')})`
90
- : 'no para link yet — send one message from para.li to this agent first, then retry.');
91
- process.exit(1);
92
- }
93
- const link = links[connId];
94
-
95
- const file = flag('file');
96
- let text: string;
97
- if (file) {
98
- text = (await import('node:fs')).readFileSync(file, 'utf-8');
99
- } else {
100
- // A bare positional (not a flag, and not a flag's VALUE) is accepted for one-liners.
101
- const flagVals = new Set<string>();
102
- for (let i = 0; i < args.length; i++) if (args[i].startsWith('--')) flagVals.add(args[i + 1]);
103
- const positional = args.slice(2).find((a) => !a.startsWith('--') && !flagVals.has(a));
104
- text = positional ?? await new Response(Bun.stdin.stream()).text();
105
- }
106
- if (!text.trim()) {
107
- console.error('nothing to post: text was empty (pipe it on stdin, pass --file, or give it as an argument)');
75
+ // CONTRACT. The module is imported for side effects, with `process.argv` untouched it reads argv
76
+ // itself, and calls `process.exit()` when it has handled the command. If it returns without
77
+ // exiting, we fall through to the normal server boot, which is exactly what an unrecognised
78
+ // subcommand does today. It runs AFTER env resolution (so DATA_DIR/PORT are settled and the
79
+ // extension sees the same data dir the server would) and BEFORE any server boot.
80
+ //
81
+ // WHY AN ENV VAR AND NOT A REGISTRY. There is no server process to register against — this is a
82
+ // one-shot CLI. The path must come from the deployment, and the deployment already sets DATA_DIR
83
+ // and friends the same way.
84
+ const cliExt = process.env.SHRAGA_CLI_EXT?.trim();
85
+ if (cliExt && args[0] && !args[0].startsWith('-') && !CORE_SUBCOMMANDS.has(args[0])) {
86
+ const { pathToFileURL } = await import('node:url');
87
+ const { resolve } = await import('node:path');
88
+ try {
89
+ await import(pathToFileURL(resolve(cliExt)).href);
90
+ } catch (err) {
91
+ console.error(`[cli-ext] failed to load ${cliExt}:`, (err as Error)?.stack || err);
108
92
  process.exit(1);
109
93
  }
110
-
111
- const ok = await postProactive({ url: link.url, secret: link.secret, connId }, link.convId, text);
112
- // Exit code is the point: `postProactive` swallows transport failures into `false`, so a caller
113
- // that only looked at stdout would read a silent drop as a successful delivery.
114
- if (!ok) { console.error(`\u2716 para post FAILED \u2192 ${link.convId}`); process.exit(1); }
115
- console.log(`\u2705 posted to ${link.convId}`);
116
- process.exit(0);
117
94
  }
118
95
 
119
96
  // `shraga ingress` — host-header TCP router for previews + blue-green flips.
@@ -25,7 +25,6 @@ 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';
29
28
  import { dataPath } from './paths.ts';
30
29
  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';
31
30
  import { setBroadcaster } from './session-bus.ts';
@@ -113,7 +112,15 @@ const PASSIVE_FLAG = process.env.SHRAGA_PASSIVE ?? process.env.UNCLAW_PASSIVE;
113
112
  const PASSIVE = PASSIVE_FLAG === '1' || PASSIVE_FLAG === 'true';
114
113
  if (PASSIVE) console.log('[server] PASSIVE mode — schedulers, consumers and background writers disabled');
115
114
 
115
+ // Local-only prep (git config, gitignore, untracking). The NETWORK half — fetch/merge — is
116
+ // deliberately NOT awaited here; it runs via dataSync.syncOnBoot() AFTER listen(). See syncOnBoot().
116
117
  if (!PASSIVE) await dataSync.init();
118
+
119
+ // Kick the boot-time data sync detached, AFTER the port is bound. syncOnBoot() never rejects, and the
120
+ // extra .catch() is belt-and-braces: an unhandled rejection here would hit the process-level handler.
121
+ function bootDataSync(): void {
122
+ dataSync.syncOnBoot().catch(err => console.error('[data-sync] boot sync error:', (err as Error).message));
123
+ }
117
124
  await loadShragaConfig();
118
125
  // Programmatic engines register through the same seam an overlay uses — BEFORE initEngines() so
119
126
  // getAvailableEngines() includes them and a directive can resolve to one immediately.
@@ -993,8 +1000,8 @@ function proxySidecarWebSocket(req: import('node:http').IncomingMessage, socket:
993
1000
  // proxy-local reply only proves THIS hop is alive: if the proxy→sidecar leg is half-open, or the
994
1001
  // sidecar has already dropped this client from its subscriber set, the browser still gets pongs,
995
1002
  // keeps `readyState === OPEN`, shows a green "connected" dot, and every keystroke disappears.
996
- // The probe is only worth anything end-to-end, so the sidecar owns the reply (para-pty answers in
997
- // its ws message handler); a sidecar that doesn't reply fails the probe, which is the honest
1003
+ // The probe is only worth anything end-to-end, so the sidecar owns the reply (it answers in its
1004
+ // own ws message handler); a sidecar that doesn't reply fails the probe, which is the honest
998
1005
  // outcome — the client then reconnects rather than trusting a dead pipe.
999
1006
  if (targetWs.readyState === WebSocket.OPEN) targetWs.send(data, { binary: isBinary });
1000
1007
  else if (!opened && pending.length < 256) pending.push({ data, isBinary }); // bounded: never buffer unboundedly
@@ -1058,7 +1065,7 @@ server.on('upgrade', (req, socket, head) => {
1058
1065
  } else {
1059
1066
  const port = req.url ? resolveSidecarPort(req.url) : null;
1060
1067
  if (port) {
1061
- // A sidecar socket is a LIVE ATTACHED SHELL (para-pty) — read/write on the user's terminals. It
1068
+ // A sidecar socket is a LIVE ATTACHED SHELL (a terminal daemon) — read/write on the user's terminals. It
1062
1069
  // must be authenticated BEFORE we bridge it, like the HTTP routes (`requireAuth`) and the `/ws`
1063
1070
  // control socket (its `auth` message). NOTE the limit of this gate: it is IDENTITY-ONLY. It proves
1064
1071
  // the token belongs to a valid user; it does NOT check that the user owns (or may access) the
@@ -1215,9 +1222,6 @@ if (process.env.SHRAGA_OVERLAY) {
1215
1222
  // so their routes mount ahead of the SPA fallback, identical to the overlay path.
1216
1223
  for (const f of __reg.features ?? []) registerFeature(f);
1217
1224
  registerFeature(slackFeature);
1218
- // para-li external-agent lane — a second medium alongside Slack; both subscribe the owner-notice
1219
- // bus independently, so neither affects the other.
1220
- registerFeature(paraFeature);
1221
1225
  mountFeatures({ app, requireAuth, broadcast, passive: PASSIVE });
1222
1226
  // Fold in feature-contributed sidecar WS proxy routes (the core names none; each add-on adds its own).
1223
1227
  Object.assign(WS_PROXY_ROUTES, collectSidecarRoutes());
@@ -1233,6 +1237,7 @@ async function activateConsumers() {
1233
1237
  activated = true;
1234
1238
  console.log('[server] ACTIVATING — starting consumers and background writers');
1235
1239
  await dataSync.init();
1240
+ bootDataSync();
1236
1241
  syncVendorRepos().catch(err => console.warn('[vendor-sync] error:', (err as Error).message));
1237
1242
  recordBootGap();
1238
1243
  startHeartbeat();
@@ -2150,6 +2155,7 @@ await new Promise<void>((resolve) => {
2150
2155
  catch (err) { console.warn('[self-upgrade] could not deliver report:', (err as Error).message); }
2151
2156
  startSidecars().catch(err => console.error('[sidecar] startup error:', err));
2152
2157
  recoverInterruptedSessions().catch(err => console.error('[recovery] failed:', err));
2158
+ bootDataSync();
2153
2159
  // The disk MCP catalog is warmed off the turn path by whichever engine consumes it — the CE default
2154
2160
  // (Claude Code) hands MCP servers straight to its SDK and needs no catalog. An add-on engine that
2155
2161
  // uses the shared catalog registers its own boot/interval warm-up through the overlay.
@@ -66,6 +66,15 @@ export function extractCommitSubject(raw: string): string {
66
66
  return '';
67
67
  }
68
68
 
69
+ /** Paths that are REWRITTEN or discarded by design: per-run worker logs, per-run task files, and
70
+ * `.bak-*` copies. The shrink guard exists to catch a shared file being gutted (contacts.json
71
+ * 111 -> 5 lines); a task file losing 58 lines because the next run rewrote it is not that. And the
72
+ * guard aborts the WHOLE commit, so one churn file stalls every other file's sync indefinitely —
73
+ * measured 2026-09-07: repeated BLOCKED alerts and a 27-commit push backlog behind two task files. */
74
+ export function isChurnPath(file: string): boolean {
75
+ return /(^|\/)workspace\/[^/]+\/workers\/(logs|tasks)\//.test(file) || /\.bak(-|\.|$)/.test(file);
76
+ }
77
+
69
78
  export class DataSyncOptions {
70
79
  repoUrl = process.env.DATA_SYNC_REPO || '';
71
80
  branch = process.env.DATA_SYNC_BRANCH || 'main';
@@ -170,8 +179,31 @@ export class DataSync {
170
179
  await this.untrackIgnored();
171
180
  this.ready = true;
172
181
  }
182
+ }
173
183
 
174
- await this.pull();
184
+ /**
185
+ * Boot-time NETWORK sync: the remote fetch/merge plus the deferred conflict-scan + integrity audit.
186
+ * Split out of init() so the HTTP port can bind BEFORE any of this runs.
187
+ *
188
+ * Why: this used to be the tail of init(), which bootServer awaits before listen(). A stalled pull
189
+ * (observed on liv-mac-1: `Could not resolve host: github.com`, plus a 60s LLM commit-message
190
+ * timeout) therefore held the ENTIRE boot — process alive, no listener on any port, 502s for
191
+ * minutes. The port watchdog then SIGTERM/SIGKILLed the boot (LastExitStatus=9) and the next boot
192
+ * re-entered the same stall. Serving possibly-stale data and refreshing a moment later is strictly
193
+ * better than not serving at all.
194
+ *
195
+ * Never rejects: the caller runs this detached, and an unhandled rejection here would reach the
196
+ * process-level handler.
197
+ */
198
+ async syncOnBoot(): Promise<void> {
199
+ if (!this.ready) return; // init() disabled/aborted — nothing to sync against
200
+ console.log(`${TAG} Boot sync started (background — the server is already serving)`);
201
+ try {
202
+ await this.pull();
203
+ console.log(`${TAG} Boot sync complete`);
204
+ } catch (err) {
205
+ console.error(`${TAG} Boot sync FAILED — serving stale data until the next pull:`, (err as Error).message);
206
+ }
175
207
  // Defer heavy sync I/O (reads all tracked files + execSync) to avoid blocking
176
208
  // WS connections and page loads during startup.
177
209
  setTimeout(() => {
@@ -180,7 +212,8 @@ export class DataSync {
180
212
  // execSync inside the audit blocks too — keep it off the scan's tick so the two
181
213
  // never add up into one long freeze.
182
214
  .then(() => new Promise<void>(r => setImmediate(r)))
183
- .then(() => this.runIntegrityAudit());
215
+ .then(() => this.runIntegrityAudit())
216
+ .catch(err => console.warn(`${TAG} Post-init integrity audit failed:`, (err as Error).message));
184
217
  }, 60_000);
185
218
  }
186
219
 
@@ -236,7 +269,7 @@ export class DataSync {
236
269
  const [addRaw, delRaw, file] = line.split('\t');
237
270
  if (addRaw === '-' || delRaw === '-' || !file) continue; // binary
238
271
  const net = (parseInt(delRaw, 10) || 0) - (parseInt(addRaw, 10) || 0);
239
- if (net > shrinkThreshold) shrunk.push(`• ${file} (−${net} net lines)`);
272
+ if (net > shrinkThreshold && !isChurnPath(file)) shrunk.push(`• ${file} (−${net} net lines)`);
240
273
  }
241
274
  if (shrunk.length) {
242
275
  console.error(`${TAG} 🚫 BLOCKED large content shrink (${context}): ${shrunk.length} file(s) — net-removal threshold is ${shrinkThreshold}`);
@@ -19,10 +19,10 @@ export function ownerEmails(): string[] {
19
19
  /** Is this email address an owner of this deployment?
20
20
  *
21
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. */
22
+ * (OWNERS ∩ contacts WITH a Slack id), which is a Slack-shaped answer. A webhook-lane add-on, for
23
+ * instance, holds a shraga uid + the email of the API key that opened the link, so it joins on the
24
+ * email instead. An empty/unknown address is NOT an owner — the fail-closed direction, since the
25
+ * alternative is fanning a deploy report out to whoever happened to open a link. */
26
26
  export function isOwnerEmail(email: string | undefined | null): boolean {
27
27
  const e = String(email ?? '').trim().toLowerCase();
28
28
  return !!e && ownerEmails().includes(e);
@@ -48,8 +48,9 @@ export function senderStamp(): string {
48
48
  * side.
49
49
  *
50
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".
51
+ * answer only while Slack was the sole medium: `resolveOwners` filters on a SLACK id, so once another
52
+ * medium subscribes the same bus an empty owner list means "no Slack owner", not "nobody was
53
+ * notified".
53
54
  * Rather than keep a boolean whose meaning depends on which features happen to be registered — no
54
55
  * caller reads it (`data-sync.ts`, `self-upgrade/index.ts`) — the notice is emitted unconditionally
55
56
  * and each subscriber decides for itself. The Slack subscriber already no-ops on an empty
@@ -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
  }
@@ -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
- };