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.
- package/dist/client/assets/{index-6SI-XJ2-.js → index-BHcdo8Ga.js} +195 -195
- package/dist/client/index.html +1 -1
- package/package.json +1 -1
- package/src/client/components/MachineStats.tsx +20 -4
- package/src/server/claude-usage.ts +36 -1
- package/src/server/para/feature.ts +17 -6
- package/src/server/para/streamer.ts +182 -16
package/dist/client/index.html
CHANGED
|
@@ -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-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
89
|
-
const
|
|
90
|
-
|
|
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 =
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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, {
|
|
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
|
-
|
|
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
|
|