shraga 0.1.69 → 0.1.71

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.
@@ -13,7 +13,7 @@
13
13
  <link rel="preconnect" href="https://fonts.googleapis.com" />
14
14
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
15
15
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet" />
16
- <script type="module" crossorigin src="/assets/index-6SI-XJ2-.js"></script>
16
+ <script type="module" crossorigin src="/assets/index-BHcdo8Ga.js"></script>
17
17
  <link rel="stylesheet" crossorigin href="/assets/index-uvYJNXVj.css">
18
18
  </head>
19
19
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shraga",
3
- "version": "0.1.69",
3
+ "version": "0.1.71",
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",
@@ -16,7 +16,10 @@ export interface UsageLimit {
16
16
  export interface Usage { subscriptionType: string | null; limits: UsageLimit[] }
17
17
 
18
18
  const WINDOW = 120;
19
- const USAGE_POLL_MS = 60_000;
19
+ // The upstream usage endpoint rate-limits hard and the numbers move slowly, so poll lazily: every
20
+ // 5 min, and ONLY while this tab is visible. A wall of forgotten tabs was what kept the server in a
21
+ // permanent 429 penalty box.
22
+ const USAGE_POLL_MS = 300_000;
20
23
 
21
24
  interface Props {
22
25
  socket: AgentSocket | null;
@@ -69,10 +72,12 @@ function ClaudeUsageMetric({ getToken }: { getToken: () => Promise<string | null
69
72
 
70
73
  useEffect(() => {
71
74
  let alive = true;
75
+ let last = 0;
72
76
  const poll = async () => {
73
77
  try {
74
78
  const t = await getToken();
75
79
  if (!t) return;
80
+ last = Date.now();
76
81
  const r = await fetch('/api/claude-usage', { headers: { Authorization: `Bearer ${t}` } });
77
82
  if (!alive) return;
78
83
  // 204 = not a subscription deployment. Anything non-OK = fail closed, same outcome.
@@ -85,9 +90,20 @@ function ClaudeUsageMetric({ getToken }: { getToken: () => Promise<string | null
85
90
  if (alive) setUsage(null);
86
91
  }
87
92
  };
88
- void poll();
89
- const id = setInterval(poll, USAGE_POLL_MS);
90
- return () => { alive = false; clearInterval(id); };
93
+ // A hidden tab is nobody watching: skip the tick entirely rather than paying an upstream call.
94
+ const tick = () => { if (document.visibilityState === 'visible') void poll(); };
95
+ // Coming back to the tab shows a stale gauge otherwise — refresh only if the interval was missed.
96
+ const onVisible = () => {
97
+ if (document.visibilityState === 'visible' && Date.now() - last >= USAGE_POLL_MS) void poll();
98
+ };
99
+ tick();
100
+ const id = setInterval(tick, USAGE_POLL_MS);
101
+ document.addEventListener('visibilitychange', onVisible);
102
+ return () => {
103
+ alive = false;
104
+ clearInterval(id);
105
+ document.removeEventListener('visibilitychange', onVisible);
106
+ };
91
107
  }, [getToken]);
92
108
 
93
109
  return <UsageMetric usage={usage} />;
@@ -41,8 +41,12 @@ export class ClaudeUsageOptions {
41
41
  * deduped separately onto one in-flight request, so a reload storm or a wall of open tabs costs at
42
42
  * most one upstream call per window — this endpoint answers 429 aggressively. Failures are cached
43
43
  * on the same terms, so a 403/API-key box does not retry on every client poll. */
44
- ttlMs = 60_000;
44
+ ttlMs = 300_000;
45
45
  timeoutMs = 8_000;
46
+ /** A 429 is not a transient blip here: retrying it on the ordinary TTL is what keeps a box wedged in
47
+ * the penalty box all day (25 straight 429s on prod). Each consecutive 429 climbs this ladder and a
48
+ * success drops back to the first rung; a `Retry-After` header wins over the rung when it is longer. */
49
+ rateLimitBackoffMs = [60_000, 300_000, 900_000, 1_800_000];
46
50
  /** macOS stores Claude Code's OAuth credentials in the login Keychain and writes NO credentials
47
51
  * file, so on darwin an absent file is not proof of an API-key deployment — we look there second.
48
52
  * Linux keeps the file as the only source; we never shell out there. */
@@ -79,6 +83,9 @@ export class ClaudeUsageReader {
79
83
  * Without this the TTL is useless against a stampede: the cache is only written once the request
80
84
  * RESOLVES, so N simultaneous callers all miss and all hit upstream. */
81
85
  private inflight: Promise<ClaudeUsage | null> | null = null;
86
+ /** While set in the future, `get()` answers null WITHOUT touching upstream — see rateLimitBackoffMs. */
87
+ private cooldownUntil = 0;
88
+ private rateLimitStreak = 0;
82
89
 
83
90
  public constructor(options?: Partial<ClaudeUsageOptions>) {
84
91
  this.options = { ...new ClaudeUsageOptions(), ...options };
@@ -87,6 +94,7 @@ export class ClaudeUsageReader {
87
94
  /** null => this box is not on a Claude subscription, or we could not prove that it is. */
88
95
  async get(): Promise<ClaudeUsage | null> {
89
96
  const now = Date.now();
97
+ if (now < this.cooldownUntil) return null;
90
98
  if (this.cache && now - this.cache.at < this.options.ttlMs) return this.cache.value;
91
99
  if (this.inflight) return this.inflight;
92
100
  // Clear inflight before the value is handed out, so a rejection can never wedge the reader:
@@ -113,8 +121,15 @@ export class ClaudeUsageReader {
113
121
  },
114
122
  signal: AbortSignal.timeout(this.options.timeoutMs),
115
123
  });
124
+ if (res.status === 429) {
125
+ // The reason lives in the BODY, not the status: "too many requests" (our own poll rate) and
126
+ // an account/org-level throttle read identically from the outside, and only the first one is
127
+ // ours to fix. Backing off blind once cost a day of a hidden gauge, so surface it.
128
+ this.enterCooldown(res.headers.get('retry-after'), await describeError(res));
129
+ return null;
130
+ }
116
131
  if (!res.ok) {
117
- console.warn(`${TAG} usage endpoint returned ${res.status}; hiding widget`);
132
+ console.warn(`${TAG} usage endpoint returned ${res.status}; hiding widget — ${await describeError(res)}`);
118
133
  return null;
119
134
  }
120
135
  const body: any = await res.json();
@@ -123,6 +138,7 @@ export class ClaudeUsageReader {
123
138
  console.warn(`${TAG} usage response carried no limits[]; hiding widget`);
124
139
  return null;
125
140
  }
141
+ this.rateLimitStreak = 0;
126
142
  return { subscriptionType: creds.subscriptionType, limits };
127
143
  } catch (err) {
128
144
  console.warn(`${TAG} usage lookup failed:`, (err as Error).message);
@@ -130,6 +146,21 @@ export class ClaudeUsageReader {
130
146
  }
131
147
  }
132
148
 
149
+ /** Climb the backoff ladder one rung per consecutive 429, capped at the last rung. `Retry-After`
150
+ * (seconds, or an HTTP-date) only ever EXTENDS the wait — never shortens the rung we earned. */
151
+ private enterCooldown(retryAfter: string | null, detail: string) {
152
+ const ladder = this.options.rateLimitBackoffMs;
153
+ const rung = ladder[Math.min(this.rateLimitStreak, ladder.length - 1)] ?? 60_000;
154
+ this.rateLimitStreak++;
155
+ const hinted = parseRetryAfter(retryAfter);
156
+ const waitMs = Math.max(rung, hinted ?? 0);
157
+ this.cooldownUntil = Date.now() + waitMs;
158
+ console.warn(
159
+ `${TAG} usage endpoint returned 429 (ua=claude-code/${claudeCodeVersion()}, retry-after=${retryAfter ?? 'none'}); ` +
160
+ `hiding widget and backing off ${Math.round(waitMs / 1000)}s — ${detail}`,
161
+ );
162
+ }
163
+
133
164
  /** Re-read per poll from whichever source holds them — the token lives ~8h and Claude Code
134
165
  * refreshes it in place, so nothing here may be cached in a field. File first, Keychain second. */
135
166
  private async readCredentials(): Promise<{ accessToken: string; subscriptionType: string | null } | null> {
@@ -183,6 +214,28 @@ function parseCredentials(raw: string, source: string): { accessToken: string; s
183
214
  return { accessToken: oauth.accessToken, subscriptionType: oauth.subscriptionType ?? null };
184
215
  }
185
216
 
217
+ /** Anthropic answers errors as `{ error: { type, message } }`. Never throws and never returns more
218
+ * than a line: this only ever lands in a log, next to a status we already decided to fail on. */
219
+ async function describeError(res: Response): Promise<string> {
220
+ try {
221
+ const raw = (await res.text()).slice(0, 300);
222
+ const parsed = JSON.parse(raw)?.error;
223
+ return parsed?.message ? `${parsed.type ?? 'error'}: ${parsed.message}` : raw || '(empty body)';
224
+ } catch (err) {
225
+ return `(unreadable body: ${(err as Error).message})`;
226
+ }
227
+ }
228
+
229
+ /** `Retry-After` is either delta-seconds or an HTTP-date. Anything unparseable => no hint. */
230
+ function parseRetryAfter(raw: string | null): number | null {
231
+ if (!raw) return null;
232
+ const secs = Number(raw.trim());
233
+ if (Number.isFinite(secs) && secs >= 0) return secs * 1000;
234
+ const at = Date.parse(raw);
235
+ if (!Number.isNaN(at)) return Math.max(0, at - Date.now());
236
+ return null;
237
+ }
238
+
186
239
  function toLimit(l: any): ClaudeUsageLimit | null {
187
240
  if (!l || typeof l.percent !== 'number' || !Number.isFinite(l.percent)) return null;
188
241
  return {
@@ -86,10 +86,10 @@ function rememberLink(link: Link): void {
86
86
  * be left watching a "typing…" placeholder that will never resolve. */
87
87
  async function runParaTurn(args: {
88
88
  callback: ParaCallback; convId: string; msgId: string; sessionId: string; prompt: string;
89
- uid: string; userEmail: string;
89
+ uid: string; userEmail: string; sendSegments: boolean;
90
90
  }): Promise<void> {
91
- const { callback, convId, msgId, sessionId, prompt, uid, userEmail } = args;
92
- const streamer = new ParaStreamer({ callback, convId, msgId });
91
+ const { callback, convId, msgId, sessionId, prompt, uid, userEmail, sendSegments } = args;
92
+ const streamer = new ParaStreamer({ callback, convId, msgId, sendSegments });
93
93
  const abortController = new AbortController();
94
94
 
95
95
  // Lock origin is 'api': the union in sessions.ts is a closed set ('web'|'slack'|'scheduler'|
@@ -119,9 +119,14 @@ async function runParaTurn(args: {
119
119
  else if (ev.type === 'tool_use') {
120
120
  if (text) { blocks.push({ type: 'text', text }); text = ''; }
121
121
  blocks.push({ type: 'tool_use', tool: ev.tool, toolUseId: ev.toolUseId, input: ev.input });
122
- streamer.feed({ type: 'tool_use', tool: ev.tool });
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 });
123
129
  }
124
- else if (ev.type === 'tool_result') blocks.push({ type: 'tool_result', toolUseId: ev.toolUseId, output: ev.output });
125
130
  else if (ev.type === 'done') break;
126
131
  else if (ev.type === 'error') {
127
132
  if (text) { blocks.push({ type: 'text', text }); text = ''; }
@@ -188,9 +193,14 @@ export const paraFeature: ServerFeature = {
188
193
  const caller = bearer ? validateApiKey(bearer) : null;
189
194
  if (!caller) return void res.status(401).json({ error: 'unauthorized' });
190
195
 
191
- const { connId, convId, sessionId, msgId, prompt, callback } = req.body as {
196
+ const { connId, convId, sessionId, msgId, prompt, callback, accepts } = req.body as {
192
197
  connId?: string; convId?: string; sessionId?: string; msgId?: string; prompt?: string;
193
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;
194
204
  };
195
205
  if (!connId || !convId || !msgId || !prompt) return void res.status(400).json({ error: 'connId, convId, msgId and prompt are required' });
196
206
  if (!callback?.url || !callback?.secret) return void res.status(400).json({ error: 'callback.url and callback.secret are required' });
@@ -210,6 +220,7 @@ export const paraFeature: ServerFeature = {
210
220
  void runParaTurn({
211
221
  callback: cb, convId, msgId, sessionId: sessionId || convId, prompt,
212
222
  uid: caller.uid, userEmail: caller.email,
223
+ sendSegments: Array.isArray(accepts) && accepts.includes('segments'),
213
224
  });
214
225
  });
215
226
 
@@ -34,8 +34,22 @@ export interface ParaStreamerOptions {
34
34
  msgId?: string;
35
35
  flushInterval?: number;
36
36
  flushThreshold?: number;
37
- /** Show a transient inline marker per tool call, as the Slack streamer does. Default on. */
37
+ /** Show a transient inline marker per tool call, as the Slack streamer does. Default on.
38
+ * Ignored when `sendSegments` is on — see `ParaStreamerOptions.sendSegments`. */
38
39
  toolMarkers?: boolean;
40
+ /**
41
+ * Emit STRUCTURED segments (`text` + `tool`) alongside the accumulated text.
42
+ *
43
+ * OFF BY DEFAULT, AND THAT IS THE POINT. This is a negotiated capability, not a preference: the
44
+ * receiver tells us per turn (`accepts: ['segments']` on the turn request) whether it can store
45
+ * and render them. A receiver that cannot gets the flattened `_🔧 Tool_` markers in the text, as
46
+ * it always did. So the fallback is EXPLICIT — one flag, decided by the receiver — instead of
47
+ * "the field is there, hopefully they ignore it".
48
+ *
49
+ * The two representations are mutually exclusive on purpose. Sending both would double-render on
50
+ * a receiver that shows segments AND falls back to text for a preview.
51
+ */
52
+ sendSegments?: boolean;
39
53
  /** Per-POST wall clock. See `POST_TIMEOUT_MS`. Overridable for tests. */
40
54
  postTimeout?: number;
41
55
  }
@@ -60,6 +74,71 @@ export function signPara(secret: string, connId: string, deliveryId: string, ts:
60
74
  return 'v1=' + createHmac('sha256', secret).update(`${connId}.${deliveryId}.${ts}.${rawBody}`).digest('hex');
61
75
  }
62
76
 
77
+ // ── Structured segments ──────────────────────────────────────────────────────
78
+ //
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.
84
+
85
+ export interface ToolCallInfo {
86
+ id: string;
87
+ tool: string;
88
+ status: 'running' | 'completed' | 'error';
89
+ args?: Record<string, unknown>;
90
+ result?: string;
91
+ }
92
+
93
+ export type MessageSegment =
94
+ | { type: 'text'; content: string }
95
+ | { type: 'tool'; tool: ToolCallInfo };
96
+
97
+ /**
98
+ * SIZE DISCIPLINE. A `Read` of a big file or a chatty `Bash` produces tool input/output measured in
99
+ * hundreds of KB, and every flush re-sends the WHOLE accumulated state (see ACCUMULATE, DON'T
100
+ * APPEND at the top). Unclamped, one such call would be re-uploaded on every subsequent delta for
101
+ * the rest of the turn and then parked in a database row forever.
102
+ *
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
106
+ * shortened `Bash` output is a lie the reader cannot detect.
107
+ *
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.
110
+ */
111
+ export const MAX_TOOL_ARGS_CHARS = 2_000;
112
+ export const MAX_TOOL_RESULT_CHARS = 4_000;
113
+ /** Beyond this many tool calls in one turn, later calls stop being recorded as segments (the text
114
+ * reply is unaffected). A 100-call turn is already unreadable as pills; the cap is what stops a
115
+ * runaway loop from growing the row without bound. */
116
+ export const MAX_TOOL_SEGMENTS = 100;
117
+
118
+ /** Truncate with an honest marker naming the TRUE length. */
119
+ export function clampText(s: string, cap: number): string {
120
+ return s.length <= cap ? s : `${s.slice(0, cap)}… [truncated, ${s.length} chars total]`;
121
+ }
122
+
123
+ /** Clamp a tool's input to `MAX_TOOL_ARGS_CHARS` across all its values, preserving the key
124
+ * structure (that is what makes the pill readable) and marking every truncation. */
125
+ export function clampArgs(input: unknown): Record<string, unknown> | undefined {
126
+ if (input === undefined || input === null) return undefined;
127
+ if (typeof input !== 'object' || Array.isArray(input)) {
128
+ return { value: clampText(String(input), MAX_TOOL_ARGS_CHARS) };
129
+ }
130
+ const out: Record<string, unknown> = {};
131
+ let budget = MAX_TOOL_ARGS_CHARS;
132
+ for (const [k, v] of Object.entries(input as Record<string, unknown>)) {
133
+ if (budget <= 0) { out['…'] = 'more arguments omitted'; break; }
134
+ const s = typeof v === 'string' ? v : (JSON.stringify(v) ?? String(v));
135
+ if (s.length > budget) out[k] = clampText(s, budget);
136
+ else out[k] = v;
137
+ budget -= Math.min(s.length, budget);
138
+ }
139
+ return out;
140
+ }
141
+
63
142
  /**
64
143
  * Wall clock on ONE delivery. Mirrors the 20s AbortController on para-li's own `dispatchAgentTurn`,
65
144
  * which is the other half of this lane.
@@ -121,37 +200,90 @@ export class ParaStreamer {
121
200
  private flushChain: Promise<void> = Promise.resolve();
122
201
  private aborted = false;
123
202
  private afterTool = false;
203
+ /** Structured mirror of the turn. Empty unless `sendSegments`. */
204
+ private segments: MessageSegment[] = [];
205
+ /** toolUseId → the live `ToolCallInfo` inside `segments`, so a `tool_result` can flip the status
206
+ * of the call it belongs to rather than of whichever ran last. */
207
+ private readonly toolById = new Map<string, ToolCallInfo>();
208
+ private toolCount = 0;
124
209
 
125
210
  private readonly flushInterval: number;
126
211
  private readonly flushThreshold: number;
127
212
  private readonly toolMarkers: boolean;
213
+ private readonly sendSegments: boolean;
128
214
  private readonly postTimeout: number;
129
215
 
130
216
  constructor(private readonly opts: ParaStreamerOptions) {
131
217
  this.flushInterval = opts.flushInterval ?? 300;
132
218
  this.flushThreshold = opts.flushThreshold ?? 30;
133
- this.toolMarkers = opts.toolMarkers ?? true;
219
+ this.sendSegments = opts.sendSegments ?? false;
220
+ // The marker is the FALLBACK, so it is off exactly when segments are on. Not "both, harmless" —
221
+ // a receiver that renders segments and also derives a preview from the text would show the
222
+ // markers in the preview of a reply whose body has real pills.
223
+ this.toolMarkers = this.sendSegments ? false : (opts.toolMarkers ?? true);
134
224
  this.postTimeout = opts.postTimeout ?? POST_TIMEOUT_MS;
135
225
  }
136
226
 
137
- feed(ev: { type: string; text?: string; tool?: string }): void {
227
+ feed(ev: { type: string; text?: string; tool?: string; toolUseId?: string; input?: unknown; output?: string; isError?: boolean }): void {
138
228
  if (this.aborted || !this.opts.msgId) return;
139
229
 
140
230
  if (ev.type === 'text_delta' && ev.text) {
141
231
  if (this.afterTool) { this.fullText += '\n'; this.afterTool = false; }
142
232
  this.buffer += ev.text;
143
233
  this.fullText += ev.text;
234
+ this.appendSegmentText(ev.text);
144
235
  if (this.buffer.length >= this.flushThreshold) this.enqueueFlush();
145
236
  else this.scheduleTimer();
146
- } else if (ev.type === 'tool_use' && ev.tool && this.toolMarkers) {
147
- // In-band, transient: `finish()` sends the clean final text, which replaces the row wholesale
148
- // (para-li writes the whole row), so the marker disappears on its own.
149
- this.afterTool = true;
150
- this.fullText += `\n\n_🔧 ${ev.tool.slice(0, 200)}_\n\n`;
237
+ } else if (ev.type === 'tool_use' && ev.tool) {
238
+ if (this.sendSegments) {
239
+ // A tool call is progress the reader wants IMMEDIATELY, at `running` that is the whole
240
+ // point of the pill. So it flushes rather than waiting for the text threshold.
241
+ if (this.toolCount < MAX_TOOL_SEGMENTS) {
242
+ const id = ev.toolUseId || `tool_${this.toolCount}`;
243
+ const info: ToolCallInfo = { id, tool: ev.tool.slice(0, 200), status: 'running', ...(clampArgs(ev.input) ? { args: clampArgs(ev.input) } : {}) };
244
+ this.toolById.set(id, info);
245
+ this.segments.push({ type: 'tool', tool: info });
246
+ }
247
+ this.toolCount++;
248
+ this.enqueueFlush();
249
+ } else if (this.toolMarkers) {
250
+ // 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.
252
+ this.afterTool = true;
253
+ this.fullText += `\n\n_🔧 ${ev.tool.slice(0, 200)}_\n\n`;
254
+ this.enqueueFlush();
255
+ }
256
+ } else if (ev.type === 'tool_result' && this.sendSegments && ev.toolUseId) {
257
+ const info = this.toolById.get(ev.toolUseId);
258
+ // A result for a call past MAX_TOOL_SEGMENTS (or for one we never saw) has nothing to settle;
259
+ // dropping it is correct — inventing a segment for it would put the pill out of order.
260
+ if (!info) return;
261
+ info.status = ev.isError ? 'error' : 'completed';
262
+ if (ev.output) info.result = clampText(ev.output, MAX_TOOL_RESULT_CHARS);
151
263
  this.enqueueFlush();
152
264
  }
153
265
  }
154
266
 
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. */
270
+ private appendSegmentText(text: string): void {
271
+ if (!this.sendSegments) return;
272
+ const last = this.segments[this.segments.length - 1];
273
+ if (last && last.type === 'text') last.content += text;
274
+ else this.segments.push({ type: 'text', content: text });
275
+ }
276
+
277
+ /** A deep copy with empty text segments dropped. Deep because `toolById` holds live references
278
+ * into `segments`: a flush snapshot that shared them would be MUTATED by a later `tool_result`
279
+ * while its POST was still in flight, so an in-order pair of deltas could show the same call as
280
+ * `completed` and then `running`. */
281
+ private segmentSnapshot(): MessageSegment[] {
282
+ return this.segments
283
+ .filter((s) => s.type === 'tool' || s.content.trim().length > 0)
284
+ .map((s) => (s.type === 'tool' ? { type: 'tool' as const, tool: { ...s.tool } } : { type: 'text' as const, content: s.content }));
285
+ }
286
+
155
287
  /** Settle the row with the final text. Returns the text actually sent. */
156
288
  async finish(): Promise<string> {
157
289
  this.clearTimer();
@@ -159,7 +291,10 @@ export class ParaStreamer {
159
291
  await this.flushChain;
160
292
  if (this.aborted || !this.opts.msgId) return this.fullText;
161
293
  const text = this.fullText.trim() || '(no output)';
162
- await postPara(this.opts.callback, { type: 'final', convId: this.opts.convId, msgId: this.opts.msgId, text }, this.postTimeout);
294
+ await postPara(this.opts.callback, {
295
+ type: 'final', convId: this.opts.convId, msgId: this.opts.msgId, text,
296
+ ...(this.sendSegments ? { segments: this.segmentSnapshot() } : {}),
297
+ }, this.postTimeout);
163
298
  return text;
164
299
  }
165
300
 
@@ -174,13 +309,18 @@ export class ParaStreamer {
174
309
 
175
310
  private enqueueFlush(): void {
176
311
  this.clearTimer();
177
- if (!this.fullText) return;
312
+ // `segments` matters here too: a turn that opens with a tool call has produced NO text yet, and
313
+ // 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.
316
+ if (!this.fullText && !this.segments.length) return;
178
317
  this.buffer = '';
179
318
  const snapshot = this.fullText;
319
+ const segs = this.sendSegments ? this.segmentSnapshot() : null;
180
320
  // Serialized: a later, longer snapshot must never be overtaken by an earlier one, or the row
181
321
  // visibly rewinds mid-stream.
182
322
  this.flushChain = this.flushChain
183
- .then(async () => { await postPara(this.opts.callback, { type: 'delta', convId: this.opts.convId, msgId: this.opts.msgId, text: snapshot }, this.postTimeout); })
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); })
184
324
  .catch((err) => console.warn('[para-streamer] flush error:', (err as Error).message));
185
325
  }
186
326