shraga 0.1.68 → 0.1.70

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.68",
3
+ "version": "0.1.70",
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,6 +121,10 @@ export class ClaudeUsageReader {
113
121
  },
114
122
  signal: AbortSignal.timeout(this.options.timeoutMs),
115
123
  });
124
+ if (res.status === 429) {
125
+ this.enterCooldown(res.headers.get('retry-after'));
126
+ return null;
127
+ }
116
128
  if (!res.ok) {
117
129
  console.warn(`${TAG} usage endpoint returned ${res.status}; hiding widget`);
118
130
  return null;
@@ -123,6 +135,7 @@ export class ClaudeUsageReader {
123
135
  console.warn(`${TAG} usage response carried no limits[]; hiding widget`);
124
136
  return null;
125
137
  }
138
+ this.rateLimitStreak = 0;
126
139
  return { subscriptionType: creds.subscriptionType, limits };
127
140
  } catch (err) {
128
141
  console.warn(`${TAG} usage lookup failed:`, (err as Error).message);
@@ -130,6 +143,18 @@ export class ClaudeUsageReader {
130
143
  }
131
144
  }
132
145
 
146
+ /** Climb the backoff ladder one rung per consecutive 429, capped at the last rung. `Retry-After`
147
+ * (seconds, or an HTTP-date) only ever EXTENDS the wait — never shortens the rung we earned. */
148
+ private enterCooldown(retryAfter: string | null) {
149
+ const ladder = this.options.rateLimitBackoffMs;
150
+ const rung = ladder[Math.min(this.rateLimitStreak, ladder.length - 1)] ?? 60_000;
151
+ this.rateLimitStreak++;
152
+ const hinted = parseRetryAfter(retryAfter);
153
+ const waitMs = Math.max(rung, hinted ?? 0);
154
+ this.cooldownUntil = Date.now() + waitMs;
155
+ console.warn(`${TAG} usage endpoint returned 429; hiding widget and backing off ${Math.round(waitMs / 1000)}s`);
156
+ }
157
+
133
158
  /** Re-read per poll from whichever source holds them — the token lives ~8h and Claude Code
134
159
  * refreshes it in place, so nothing here may be cached in a field. File first, Keychain second. */
135
160
  private async readCredentials(): Promise<{ accessToken: string; subscriptionType: string | null } | null> {
@@ -183,6 +208,16 @@ function parseCredentials(raw: string, source: string): { accessToken: string; s
183
208
  return { accessToken: oauth.accessToken, subscriptionType: oauth.subscriptionType ?? null };
184
209
  }
185
210
 
211
+ /** `Retry-After` is either delta-seconds or an HTTP-date. Anything unparseable => no hint. */
212
+ function parseRetryAfter(raw: string | null): number | null {
213
+ if (!raw) return null;
214
+ const secs = Number(raw.trim());
215
+ if (Number.isFinite(secs) && secs >= 0) return secs * 1000;
216
+ const at = Date.parse(raw);
217
+ if (!Number.isNaN(at)) return Math.max(0, at - Date.now());
218
+ return null;
219
+ }
220
+
186
221
  function toLimit(l: any): ClaudeUsageLimit | null {
187
222
  if (!l || typeof l.percent !== 'number' || !Number.isFinite(l.percent)) return null;
188
223
  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,24 @@ 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;
53
+ /** Per-POST wall clock. See `POST_TIMEOUT_MS`. Overridable for tests. */
54
+ postTimeout?: number;
39
55
  }
40
56
 
41
57
  /** Signature contract, mirrored byte-for-byte in para-li's `lib/agent-conn.ts#signPayload`.
@@ -58,17 +74,101 @@ export function signPara(secret: string, connId: string, deliveryId: string, ts:
58
74
  return 'v1=' + createHmac('sha256', secret).update(`${connId}.${deliveryId}.${ts}.${rawBody}`).digest('hex');
59
75
  }
60
76
 
61
- /** One signed POST. Returns false on any non-2xx or network error, having logged it — the caller
62
- * keeps streaming rather than aborting the agent's turn over a transport hiccup. */
63
- export async function postPara(cb: ParaCallback, payload: object): Promise<boolean> {
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
+
142
+ /**
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.
145
+ *
146
+ * WHY A TIMEOUT IS LOAD-BEARING HERE AND NOT A NICETY: flushes are serialized through `flushChain`,
147
+ * and `finish()` awaits that chain. `fetch` has no default timeout, so ONE POST that connects and
148
+ * then never answers (a stalled proxy, a receiver wedged mid-handler, a half-open socket a dead NAT
149
+ * entry never RSTs) blocks every later delta AND the `final` — for the process's lifetime. The
150
+ * visible symptom is not an error: the reply freezes mid-sentence, no `final` ever lands, and
151
+ * nothing is logged, because the failure never returns. A bounded POST turns that permanent wedge
152
+ * into one logged, skipped delta and a `final` that still arrives.
153
+ */
154
+ export const POST_TIMEOUT_MS = 20_000;
155
+
156
+ /** One signed POST. Returns false on any non-2xx, timeout, or network error, having logged it — the
157
+ * 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> {
64
159
  const raw = JSON.stringify(payload);
65
160
  const ts = Date.now();
66
161
  // Per-DELIVERY id, not per-turn: para-li's replay guard dedupes on this, so a shared id across
67
162
  // the deltas of one turn would drop every delta after the first. Minted here so the exact same
68
163
  // value goes into the header AND the signature — they must not be able to diverge.
69
164
  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.
167
+ const ac = new AbortController();
168
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
70
169
  try {
71
170
  const res = await fetch(cb.url, {
171
+ signal: ac.signal,
72
172
  method: 'POST',
73
173
  headers: {
74
174
  'Content-Type': 'application/json',
@@ -85,8 +185,11 @@ export async function postPara(cb: ParaCallback, payload: object): Promise<boole
85
185
  }
86
186
  return true;
87
187
  } catch (err) {
88
- console.warn('[para-streamer] delivery failed:', (err as Error).message);
188
+ const e = err as Error;
189
+ console.warn('[para-streamer] delivery failed:', e.name === 'AbortError' ? `no response within ${timeoutMs}ms` : e.message);
89
190
  return false;
191
+ } finally {
192
+ clearTimeout(timer);
90
193
  }
91
194
  }
92
195
 
@@ -97,35 +200,90 @@ export class ParaStreamer {
97
200
  private flushChain: Promise<void> = Promise.resolve();
98
201
  private aborted = false;
99
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;
100
209
 
101
210
  private readonly flushInterval: number;
102
211
  private readonly flushThreshold: number;
103
212
  private readonly toolMarkers: boolean;
213
+ private readonly sendSegments: boolean;
214
+ private readonly postTimeout: number;
104
215
 
105
216
  constructor(private readonly opts: ParaStreamerOptions) {
106
217
  this.flushInterval = opts.flushInterval ?? 300;
107
218
  this.flushThreshold = opts.flushThreshold ?? 30;
108
- 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);
224
+ this.postTimeout = opts.postTimeout ?? POST_TIMEOUT_MS;
109
225
  }
110
226
 
111
- 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 {
112
228
  if (this.aborted || !this.opts.msgId) return;
113
229
 
114
230
  if (ev.type === 'text_delta' && ev.text) {
115
231
  if (this.afterTool) { this.fullText += '\n'; this.afterTool = false; }
116
232
  this.buffer += ev.text;
117
233
  this.fullText += ev.text;
234
+ this.appendSegmentText(ev.text);
118
235
  if (this.buffer.length >= this.flushThreshold) this.enqueueFlush();
119
236
  else this.scheduleTimer();
120
- } else if (ev.type === 'tool_use' && ev.tool && this.toolMarkers) {
121
- // In-band, transient: `finish()` sends the clean final text, which replaces the row wholesale
122
- // (para-li writes the whole row), so the marker disappears on its own.
123
- this.afterTool = true;
124
- 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);
125
263
  this.enqueueFlush();
126
264
  }
127
265
  }
128
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
+
129
287
  /** Settle the row with the final text. Returns the text actually sent. */
130
288
  async finish(): Promise<string> {
131
289
  this.clearTimer();
@@ -133,7 +291,10 @@ export class ParaStreamer {
133
291
  await this.flushChain;
134
292
  if (this.aborted || !this.opts.msgId) return this.fullText;
135
293
  const text = this.fullText.trim() || '(no output)';
136
- await postPara(this.opts.callback, { type: 'final', convId: this.opts.convId, msgId: this.opts.msgId, text });
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);
137
298
  return text;
138
299
  }
139
300
 
@@ -143,18 +304,23 @@ export class ParaStreamer {
143
304
  this.clearTimer();
144
305
  await this.flushChain;
145
306
  if (!this.opts.msgId) return;
146
- await postPara(this.opts.callback, { type: 'error', convId: this.opts.convId, msgId: this.opts.msgId, message });
307
+ await postPara(this.opts.callback, { type: 'error', convId: this.opts.convId, msgId: this.opts.msgId, message }, this.postTimeout);
147
308
  }
148
309
 
149
310
  private enqueueFlush(): void {
150
311
  this.clearTimer();
151
- 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;
152
317
  this.buffer = '';
153
318
  const snapshot = this.fullText;
319
+ const segs = this.sendSegments ? this.segmentSnapshot() : null;
154
320
  // Serialized: a later, longer snapshot must never be overtaken by an earlier one, or the row
155
321
  // visibly rewinds mid-stream.
156
322
  this.flushChain = this.flushChain
157
- .then(async () => { await postPara(this.opts.callback, { type: 'delta', convId: this.opts.convId, msgId: this.opts.msgId, text: snapshot }); })
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); })
158
324
  .catch((err) => console.warn('[para-streamer] flush error:', (err as Error).message));
159
325
  }
160
326