shraga 0.1.96 → 0.1.97

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,8 +13,8 @@
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-519evGGj.js"></script>
17
- <link rel="stylesheet" crossorigin href="/assets/index-CJDfTuzn.css">
16
+ <script type="module" crossorigin src="/assets/index-QkJewVCL.js"></script>
17
+ <link rel="stylesheet" crossorigin href="/assets/index-DlgCLI89.css">
18
18
  </head>
19
19
  <body>
20
20
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shraga",
3
- "version": "0.1.96",
3
+ "version": "0.1.97",
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",
@@ -23,6 +23,7 @@ const rehypeBidi: Plugin<[], Root> = () => (tree) => {
23
23
  import 'highlight.js/styles/github.css';
24
24
  import { ChevronRight, Wrench, User, Bot, Copy, Check, RotateCcw, Pencil, X, SendHorizontal, ShieldQuestion, CheckCircle2, XCircle, Eye, EyeOff, Info, Loader2, BrainCircuit, GitFork, Minimize2 } from 'lucide-react';
25
25
  import { cn } from '@/lib/utils';
26
+ import { toolPreview } from '@/lib/tool-preview';
26
27
  import { annotateLimitReset } from '@/lib/limit-reset';
27
28
  import type { ChatMessage, MessageBlock, Attachment } from '@/hooks/useConversation';
28
29
  import type { AskQuestion, QuestionAnswers } from '@/lib/ws';
@@ -622,11 +623,7 @@ function ToolUseBlock({ block, tool, input, result, busy, screenMap }: { block:
622
623
  const [expanded, setExpanded] = useState(false);
623
624
  const inputStr = typeof input === 'string' ? input : JSON.stringify(input, null, 2);
624
625
  const isEmpty = typeof input === 'object' && input !== null && Object.keys(input as object).length === 0;
625
- const preview = isEmpty
626
- ? ''
627
- : typeof input === 'object' && input !== null
628
- ? Object.keys(input as object).join(', ')
629
- : String(input).slice(0, 60);
626
+ const preview = isEmpty ? '' : toolPreview(input);
630
627
 
631
628
  const resultTrimmed = result != null ? stripLineNumbers(result.trim().replace(/\[Image #\d+\]\s*/g, '').trim()) : undefined;
632
629
  const hasResult = result != null;
@@ -9,6 +9,7 @@ import { MachineStats } from './MachineStats';
9
9
  import type { UnreadSession } from '@/hooks/useUnread';
10
10
  import type { AgentSocket } from '@/lib/ws';
11
11
  import { useSessionList, type SessionRow as Session, type ChatsFilter } from '@/hooks/useSessionList';
12
+ import { CLIENT_BUILD_VERSION } from '@/lib/build-version';
12
13
 
13
14
  interface Props {
14
15
  getToken: () => Promise<string | null>;
@@ -123,6 +124,14 @@ export function Sidebar({ getToken, activeSessionId, onSelect, onNew, refreshKey
123
124
  getToken().then(t => t ? fetch('/api/version', { headers: { Authorization: `Bearer ${t}` } }) : null).then(r => r?.json()).then(d => d && setVersion(d.version)).catch(() => {});
124
125
  }, []);
125
126
 
127
+ // Version skew is SILENT otherwise: a `dist/client` left stale by a deploy keeps serving a client
128
+ // written against an older API shape, which discards the new response and renders an empty list —
129
+ // no error, no log (the list routes are quiet on 200). Surface it instead of debugging a ghost.
130
+ const stale = !!version && version !== 'unknown' && CLIENT_BUILD_VERSION !== 'dev' && version !== CLIENT_BUILD_VERSION;
131
+ useEffect(() => {
132
+ if (stale) console.warn(`[shraga] stale client bundle: built from v${CLIENT_BUILD_VERSION}, server runs v${version} — rebuild dist/client`);
133
+ }, [stale, version]);
134
+
126
135
  function renderRow(s: Session) {
127
136
  const unread = unreads[s.sessionId];
128
137
  const isBusy = busySessions.has(s.sessionId) || s.runStatus === 'running' || s.scheduleRunStatus === 'running';
@@ -218,7 +227,17 @@ export function Sidebar({ getToken, activeSessionId, onSelect, onNew, refreshKey
218
227
  <MachineStats socket={socket ?? null} getToken={getToken} />
219
228
  {slots.sidebarExtras?.()}
220
229
  {version && (
221
- <div className="text-[10px] text-muted-foreground/50 text-center">v{version}</div>
230
+ stale ? (
231
+ <button
232
+ onClick={() => location.reload()}
233
+ title={`This page was built from v${CLIENT_BUILD_VERSION}, the server runs v${version} — reload to update`}
234
+ className="text-[10px] text-amber-400 hover:text-amber-300 text-center underline decoration-dotted"
235
+ >
236
+ ⚠️ stale page (v{CLIENT_BUILD_VERSION} → v{version}) · reload
237
+ </button>
238
+ ) : (
239
+ <div className="text-[10px] text-muted-foreground/50 text-center">v{version}</div>
240
+ )
222
241
  )}
223
242
  </div>
224
243
  </div>
@@ -391,6 +391,8 @@ function applyStream(setMessages: Dispatch<SetStateAction<ChatMessage[]>>, event
391
391
  return setMessages((prev) =>
392
392
  appendToAssistant(prev, { type: 'tool_result', toolUseId: event.toolUseId, output: event.output }),
393
393
  );
394
+ case 'tool_result_image':
395
+ return setMessages((prev) => appendToAssistant(prev, { type: 'image', src: event.dataUrl }));
394
396
  }
395
397
  }
396
398
 
@@ -0,0 +1,7 @@
1
+ /**
2
+ * The shraga version this client bundle was built from (stamped by vite's `define`).
3
+ * Falls back to 'dev' wherever the define is absent (tests, an embedder's own vite config), which
4
+ * disables the skew check rather than reporting a false mismatch.
5
+ */
6
+ export const CLIENT_BUILD_VERSION: string =
7
+ typeof __SHRAGA_CLIENT_VERSION__ === 'string' ? __SHRAGA_CLIENT_VERSION__ : 'dev';
@@ -0,0 +1,28 @@
1
+ /**
2
+ * The one-line preview on a collapsed tool pill. Shows the ARGUMENT that identifies the call — the
3
+ * command, the path, the pattern — not the argument NAMES. Listing keys (`command, background,
4
+ * description`) made every Bash pill identical, so a real operation (dispatching a worker, running a
5
+ * build) was indistinguishable from any other and read as "no tool calls shown at all".
6
+ * Order is by how well a field identifies the call; anything unrecognised falls back to the first
7
+ * usable string value, then to the key list.
8
+ */
9
+ const PREVIEW_KEYS = ['command', 'pattern', 'glob_pattern', 'file_path', 'path', 'url', 'query', 'prompt', 'id', 'description', 'name', 'text'];
10
+
11
+ export function toolPreview(input: unknown, max = 120): string {
12
+ if (typeof input === 'string') return oneLine(input, max);
13
+ if (typeof input !== 'object' || input === null) return String(input).slice(0, max);
14
+ const obj = input as Record<string, unknown>;
15
+ for (const k of PREVIEW_KEYS) {
16
+ const v = obj[k];
17
+ if (typeof v === 'string' && v.trim()) return oneLine(v, max);
18
+ }
19
+ const first = Object.values(obj).find((v) => typeof v === 'string' && v.trim());
20
+ if (typeof first === 'string') return oneLine(first, max);
21
+ return Object.keys(obj).join(', ');
22
+ }
23
+
24
+ function oneLine(s: string, max: number): string {
25
+ const flat = s.replace(/\s+/g, ' ').trim();
26
+ return flat.length > max ? flat.slice(0, max) + '…' : flat;
27
+ }
28
+
@@ -40,7 +40,7 @@ export type ServerEvent =
40
40
  | { type: 'session_title_updated'; sessionId: string; title: string }
41
41
  | { type: 'directives'; directives: { model?: string; turns?: number; thinking?: string; engine?: string } }
42
42
  | { type: 'session_busy'; sessionId: string; busy: boolean }
43
- | { type: 'session_stream'; sessionId: string; event: { type: 'thinking_delta'; text: string } | { type: 'text_delta'; text: string } | { type: 'tool_use'; tool: string; toolUseId: string; input: unknown } | { type: 'tool_use_input'; toolUseId: string; input: unknown } | { type: 'tool_result'; toolUseId: string; output: string } }
43
+ | { type: 'session_stream'; sessionId: string; event: { type: 'thinking_delta'; text: string } | { type: 'text_delta'; text: string } | { type: 'tool_use'; tool: string; toolUseId: string; input: unknown } | { type: 'tool_use_input'; toolUseId: string; input: unknown } | { type: 'tool_result'; toolUseId: string; output: string } | { type: 'tool_result_image'; toolUseId: string; dataUrl: string } }
44
44
  | { type: 'artifact'; id: string; sessionId: string; title: string; dimensions: [number, number]; version: number }
45
45
  | { type: 'server_restarting' }
46
46
  | { type: 'unread'; sessionId: string; count: number; preview: string; source: 'response' | 'proactive' | 'schedule'; title?: string }
@@ -0,0 +1,9 @@
1
+ /// <reference types="vite/client" />
2
+
3
+ /**
4
+ * The shraga version the CLIENT BUNDLE was built from, stamped by vite at build time.
5
+ * Compared against the server's `/api/version` at runtime — a mismatch means `dist/client` is
6
+ * stale relative to the running server, which fails SILENTLY (a client written against an older
7
+ * API shape just discards the response). See the version-skew banner in Sidebar.
8
+ */
9
+ declare const __SHRAGA_CLIENT_VERSION__: string;
@@ -27,7 +27,8 @@ import { registerSpaCatchAll } from './spa-catchall.ts';
27
27
  import { slackFeature } from './slack/feature.ts';
28
28
  import { dataPath } from './paths.ts';
29
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, toListItem, pageSessions, isOwnSession, type ConvBlock, type ConvMessage, type SessionMeta } from './sessions.ts';
30
- import { setBroadcaster } from './session-bus.ts';
30
+ import { setBroadcaster, emitToSession } from './session-bus.ts';
31
+ import { TURN_CONTROL_EVENTS } from './turn-stream.ts';
31
32
  import * as scheduler from './scheduler/index.ts';
32
33
  import { initPolls } from './polls.ts';
33
34
  import { initBackgroundJobs } from './background-jobs.ts';
@@ -609,6 +610,8 @@ app.get('/api/schedules/:id/runs', requireAuth, (req, res) => {
609
610
  res.json(getSessionsByScheduleId(id));
610
611
  });
611
612
 
613
+ // Cap on a tool_result pushed to viewers; the transcript keeps the full output.
614
+ const STREAMED_RESULT_MAX = 2000;
612
615
  // ── REST chat endpoint (for automation / CLI triggers / agent-to-agent) ──────
613
616
  /**
614
617
  * Run a single chat turn with all its side effects (session lock, message
@@ -666,7 +669,13 @@ async function runChatTurn(
666
669
  abortController,
667
670
  context: opts.context ?? { source: 'api', user: userEmail },
668
671
  onPermissionRequest: async () => ({ allow: true }),
669
- }), onEvent);
672
+ }), onEvent, {
673
+ // Same reason as the wake lane: an /api/chat or MCP-driven turn used to surface nothing to a
674
+ // web viewer until it ended, and dropped an add-on engine's subagent events entirely.
675
+ maxResultChars: STREAMED_RESULT_MAX,
676
+ onDelta: (event) => broadcast({ type: 'session_stream', sessionId: sid, event }),
677
+ onPassthrough: (event) => emitToSession(sid, event),
678
+ });
670
679
  if (blocks.length) {
671
680
  appendMessage(sid, { id: crypto.randomUUID(), role: 'assistant', blocks });
672
681
  }
@@ -1218,7 +1227,18 @@ initPolls({
1218
1227
  await new Promise((r) => setTimeout(r, 2_000));
1219
1228
  }
1220
1229
  try {
1221
- return await consumeStream(streamChat({ prompt, sessionId, uid, userEmail, mcpServers: getMcpConfig(uid), abortController, onPermissionRequest: async () => ({ allow: true }) }));
1230
+ // Stream it. A woken turn (a background job reporting back, a poll firing) used to surface
1231
+ // NOTHING until it ended — and an add-on engine's subagent events were dropped outright — so a
1232
+ // turn that dispatched a worker was indistinguishable from one that only claimed to.
1233
+ return await consumeStream(
1234
+ streamChat({ prompt, sessionId, uid, userEmail, mcpServers: getMcpConfig(uid), abortController, onPermissionRequest: async () => ({ allow: true }) }),
1235
+ undefined,
1236
+ {
1237
+ maxResultChars: STREAMED_RESULT_MAX,
1238
+ onDelta: (event) => broadcast({ type: 'session_stream', sessionId, event }),
1239
+ onPassthrough: (event) => emitToSession(sessionId, event),
1240
+ },
1241
+ );
1222
1242
  } finally {
1223
1243
  if (releaseSessionLock(sessionId, abortController)) setRunStatus(sessionId, 'idle');
1224
1244
  }
@@ -1550,6 +1570,11 @@ async function runStream(ws: WebSocket, session: WsSession, sid: string, promptT
1550
1570
  send(ws, { type: 'error', message: event.message, sessionId: sid });
1551
1571
  }
1552
1572
  break;
1573
+ } else if (!TURN_CONTROL_EVENTS.has(event.type)) {
1574
+ // Not core-owned — an add-on engine's event (subagent pills, worker cards). The owning
1575
+ // socket already got it verbatim above; mirror it to the session's OTHER viewers too, so a
1576
+ // second device watching the same turn sees the same thing.
1577
+ broadcast({ ...event, sessionId: sid }, ws);
1553
1578
  }
1554
1579
  }
1555
1580
  if (eventCount === 0) {
@@ -3,6 +3,7 @@ import { summarizeText } from './summarize.ts';
3
3
  import { dataSync } from './data-sync.ts';
4
4
  import type { McpConfig } from './mcp.ts';
5
5
  import { loadConversation, saveConversation, appendMessage, getSession, setSessionDirectives, addTriggeredSkills, upsertSession, type ConvMessage, type ConvBlock } from './sessions.ts';
6
+ import { createTurnAccumulator, type TurnStreamHooks } from './turn-stream.ts';
6
7
  import {
7
8
  resolveDefaultSkillsContent,
8
9
  expandMentionedSkills,
@@ -71,6 +72,8 @@ export function saveAgentConfig(config: AgentConfig): void {
71
72
 
72
73
  // ── WS events ───────────────────────────────────────────────────────────────
73
74
 
75
+ export type { TurnStreamHooks };
76
+
74
77
  export type WsEvent =
75
78
  | { type: 'text_delta'; text: string }
76
79
  | { type: 'tool_use'; tool: string; toolUseId: string; input: unknown }
@@ -410,38 +413,16 @@ export async function* streamChat(opts: {
410
413
  * thing — the MCP path said NOTHING at all, so a truncated turn arrived looking finished. */
411
414
  export const MAX_TURNS_NOTICE = '\n\n---\n⚠️ Reached the maximum number of steps for this turn. Send "continue" to pick up where I left off.';
412
415
 
413
- export async function consumeStream(stream: AsyncGenerator<WsEvent>, onEvent?: (ev: WsEvent) => void): Promise<ConvBlock[]> {
414
- let text = '';
415
- let thinking = '';
416
- const blocks: ConvBlock[] = [];
416
+ export async function consumeStream(
417
+ stream: AsyncGenerator<WsEvent>,
418
+ onEvent?: (ev: WsEvent) => void,
419
+ hooks?: TurnStreamHooks,
420
+ ): Promise<ConvBlock[]> {
421
+ const acc = createTurnAccumulator(hooks ?? {});
417
422
  for await (const ev of stream) {
418
423
  onEvent?.(ev);
419
- if (ev.type === 'thinking_delta') {
420
- thinking += ev.text;
421
- } else if (ev.type === 'text_delta') {
422
- if (thinking) { blocks.push({ type: 'thinking', text: thinking }); thinking = ''; }
423
- text += ev.text;
424
- } else if (ev.type === 'tool_use') {
425
- if (thinking) { blocks.push({ type: 'thinking', text: thinking }); thinking = ''; }
426
- if (text) { blocks.push({ type: 'text', text }); text = ''; }
427
- blocks.push({ type: 'tool_use', tool: ev.tool, toolUseId: ev.toolUseId, input: ev.input });
428
- } else if (ev.type === 'tool_use_input') {
429
- const existing = blocks.find((b) => b.type === 'tool_use' && b.toolUseId === ev.toolUseId) as any;
430
- if (existing) existing.input = ev.input;
431
- } else if (ev.type === 'tool_result') {
432
- blocks.push({ type: 'tool_result', toolUseId: ev.toolUseId, output: ev.output });
433
- } else if (ev.type === 'tool_result_image') {
434
- blocks.push({ type: 'image', src: ev.dataUrl });
435
- } else if (ev.type === 'done') {
436
- break;
437
- } else if (ev.type === 'error') {
438
- if (text) { blocks.push({ type: 'text', text }); text = ''; }
439
- blocks.push({ type: 'error', text: ev.message });
440
- break;
441
- }
424
+ if (acc.push(ev)) break;
442
425
  }
443
- if (thinking) blocks.push({ type: 'thinking', text: thinking });
444
- if (text) blocks.push({ type: 'text', text });
445
- return blocks;
426
+ return acc.finish();
446
427
  }
447
428
 
@@ -12,6 +12,7 @@ import { getMcpConfig } from '../mcp.ts';
12
12
  import { dataPath } from '../paths.ts';
13
13
  import { appendMessage, setSlackContext, setRunStatus, setVisibleTo, writePartial, clearPartial, registerLivePartial, unregisterLivePartial, acquireSessionLock, releaseSessionLock, getSession, recordSeenSlackTs, type ConvBlock, type SessionMeta } from '../sessions.ts';
14
14
  import { injectFile } from '../file-inject.ts';
15
+ import { createTurnAccumulator } from '../turn-stream.ts';
15
16
  import {
16
17
  postMessage, addReaction, removeReaction, getBotUserId, getAgentUserId, getThreadMessages, getMessage,
17
18
  getChannelName, getUserName, getUserProfile, resolveUserMentions, isSupportedFile, SUPPORTED_FILE_MIMES,
@@ -72,48 +73,34 @@ async function* pumpStream(
72
73
  sessionId: string,
73
74
  opts: { partial?: boolean; artifacts?: boolean } = {},
74
75
  ): AsyncGenerator<AgentEvent> {
75
- let assistantText = '';
76
- const assistantBlocks: ConvBlock[] = [];
77
- const collect = () => [...assistantBlocks, ...(assistantText ? [{ type: 'text' as const, text: assistantText }] : [])];
76
+ // Transcript + web-viewer streaming is the shared reducer's job (turn-stream.ts); this function
77
+ // only adds what is Slack's: which events become Slack text, and the artifact hook.
78
+ const acc = createTurnAccumulator({
79
+ maxResultChars: 2000,
80
+ onDelta: (event) => broadcastFn({ type: 'session_stream', sessionId, event }),
81
+ onPassthrough: (event) => broadcastFn({ ...event, sessionId }),
82
+ });
78
83
  let partialInterval: ReturnType<typeof setInterval> | undefined;
79
84
  if (opts.partial) {
80
- registerLivePartial(sessionId, collect);
81
- partialInterval = setInterval(() => { const b = collect(); if (b.length) writePartial(sessionId, b); }, 5_000);
85
+ registerLivePartial(sessionId, () => acc.snapshot());
86
+ partialInterval = setInterval(() => { const b = acc.snapshot(); if (b.length) writePartial(sessionId, b); }, 5_000);
82
87
  }
83
- let stopReason = '';
84
88
  try {
85
89
  for await (const ev of gen) {
86
- if (ev.type === 'text_delta') {
87
- assistantText += ev.text;
88
- broadcastFn({ type: 'session_stream', sessionId, event: { type: 'text_delta', text: ev.text } });
89
- yield { type: 'text_delta', text: ev.text };
90
- } else if (ev.type === 'tool_use') {
91
- if (assistantText) { assistantBlocks.push({ type: 'text', text: assistantText }); assistantText = ''; }
92
- assistantBlocks.push({ type: 'tool_use', tool: ev.tool, toolUseId: ev.toolUseId, input: ev.input });
93
- broadcastFn({ type: 'session_stream', sessionId, event: { type: 'tool_use', tool: ev.tool, toolUseId: ev.toolUseId, input: ev.input } });
90
+ const terminal = acc.push(ev);
91
+ if (ev.type === 'text_delta') yield { type: 'text_delta', text: ev.text };
92
+ else if (ev.type === 'tool_use') {
94
93
  if (opts.artifacts) { const e = handleArtifactToolUse(sessionId, ev.tool, ev.input); if (e) broadcastFn(e); }
95
94
  yield { type: 'tool_use', tool: ev.tool, toolUseId: ev.toolUseId, input: ev.input };
96
- } else if (ev.type === 'tool_result') {
97
- assistantBlocks.push({ type: 'tool_result', toolUseId: ev.toolUseId, output: ev.output });
98
- const trimmed = ev.output.length > 2000 ? ev.output.slice(0, 2000) + '…' : ev.output;
99
- broadcastFn({ type: 'session_stream', sessionId, event: { type: 'tool_result', toolUseId: ev.toolUseId, output: trimmed } });
100
- } else if (ev.type === 'tool_result_image') {
101
- assistantBlocks.push({ type: 'image', src: ev.dataUrl });
102
- } else if (ev.type === 'done') {
103
- stopReason = ev.stopReason ?? 'end_turn';
104
- break;
105
95
  } else if (ev.type === 'error') {
106
96
  // Slack gets it as text (it has no block renderer); the transcript gets a real error block.
107
- const t = `\n⚠️ ${ev.message}`;
108
- if (assistantText) { assistantBlocks.push({ type: 'text', text: assistantText }); assistantText = ''; }
109
- assistantBlocks.push({ type: 'error', text: ev.message });
110
- yield { type: 'text_delta', text: t };
111
- break;
97
+ yield { type: 'text_delta', text: `\n⚠️ ${ev.message}` };
112
98
  }
99
+ if (terminal) break;
113
100
  }
114
- if (stopReason === 'max_turns_reached') {
101
+ if (acc.stopReason === 'max_turns_reached') {
115
102
  const notice = '\n\n---\n⚠️ _Reached the maximum number of steps for this turn. Reply "continue" to pick up where I left off._';
116
- assistantText += notice;
103
+ acc.push({ type: 'text_delta', text: notice });
117
104
  yield { type: 'text_delta', text: notice };
118
105
  }
119
106
  } finally {
@@ -121,7 +108,7 @@ async function* pumpStream(
121
108
  if (opts.partial) unregisterLivePartial(sessionId);
122
109
  }
123
110
 
124
- if (assistantText) assistantBlocks.push({ type: 'text', text: assistantText });
111
+ const assistantBlocks = acc.finish();
125
112
  for (const b of assistantBlocks) if (mdText(b)) (b as any).text = await resolveUserMentions(b.text);
126
113
  if (assistantBlocks.length) {
127
114
  if (opts.partial) clearPartial(sessionId);
@@ -0,0 +1,128 @@
1
+ /**
2
+ * The single event→(transcript blocks, client events) reducer for an agent turn.
3
+ *
4
+ * Every lane that runs a turn has to do the same two things with the engine's event stream: build
5
+ * the assistant message's `ConvBlock[]`, and push the live deltas to whoever is watching the session
6
+ * on the web. Each surface (websocket, Slack, webhook-driven, background-job/wake, the REST chat
7
+ * endpoint) grew its own copy, and most were lossy in a different way — a Slack turn rendered tool
8
+ * pills with empty inputs; a job-triggered turn streamed nothing at all and dropped an add-on
9
+ * engine's subagent events on the floor. This is that logic, once.
10
+ *
11
+ * Two output channels, deliberately distinct:
12
+ * - `onDelta` gets the core display events, to be wrapped as `{ type: 'session_stream', event }`.
13
+ * - `onPassthrough` gets everything the CORE DOESN'T OWN (an add-on engine's `duplex_*` subagent
14
+ * pills — see the note on the WsEvent union in claude.ts), forwarded VERBATIM and top-level,
15
+ * which is the shape those clients already listen for. Unknown = forward, never drop: dropping is
16
+ * what made a dispatched worker invisible.
17
+ */
18
+ import type { ConvBlock } from './sessions.ts';
19
+
20
+ /** Events a lane handles itself (transport, control flow) — never a display delta, never forwarded. */
21
+ export const TURN_CONTROL_EVENTS = new Set([
22
+ 'done', 'error', 'permission_request', 'question_request',
23
+ 'model_resolved', 'stats', 'session_id', 'session_busy', 'forked',
24
+ ]);
25
+
26
+ /** The inner payload of a `session_stream` event — the core's display deltas. */
27
+ export type StreamDelta =
28
+ | { type: 'thinking_delta'; text: string }
29
+ | { type: 'text_delta'; text: string }
30
+ | { type: 'tool_use'; tool: string; toolUseId: string; input: unknown }
31
+ | { type: 'tool_use_input'; toolUseId: string; input: unknown }
32
+ | { type: 'tool_result'; toolUseId: string; output: string }
33
+ | { type: 'tool_result_image'; toolUseId: string; dataUrl: string };
34
+
35
+ export interface TurnStreamHooks {
36
+ onDelta?: (ev: StreamDelta) => void;
37
+ onPassthrough?: (ev: object) => void;
38
+ /** Cap on a tool_result's streamed output. The transcript keeps the full text either way. */
39
+ maxResultChars?: number;
40
+ }
41
+
42
+ export interface TurnAccumulator {
43
+ /** The assistant blocks so far — safe to read mid-turn (a live "partial" snapshot). */
44
+ readonly blocks: ConvBlock[];
45
+ /** Feed one engine event. Returns true for a terminal event (`done`/`error`), so lanes can break. */
46
+ push(ev: { type: string } & Record<string, any>): boolean;
47
+ /** Append arbitrary blocks (a lane's own notices, e.g. the max-turns hint). */
48
+ add(block: ConvBlock): void;
49
+ /** Flush trailing thinking/text and return the finished block list. */
50
+ finish(): ConvBlock[];
51
+ /** Blocks as they stand, including a trailing in-progress thinking/text — for live partials. */
52
+ snapshot(): ConvBlock[];
53
+ /** Set once a `done` event arrives. */
54
+ stopReason: string;
55
+ }
56
+
57
+ export function createTurnAccumulator(hooks: TurnStreamHooks = {}): TurnAccumulator {
58
+ const blocks: ConvBlock[] = [];
59
+ const max = hooks.maxResultChars ?? Infinity;
60
+ let text = '';
61
+ let thinking = '';
62
+
63
+ const flushThinking = () => { if (thinking) { blocks.push({ type: 'thinking', text: thinking }); thinking = ''; } };
64
+ const flushText = () => { if (text) { blocks.push({ type: 'text', text }); text = ''; } };
65
+ const delta = (ev: StreamDelta) => hooks.onDelta?.(ev);
66
+
67
+ const acc: TurnAccumulator = {
68
+ blocks,
69
+ stopReason: '',
70
+ add(block) { flushThinking(); flushText(); blocks.push(block); },
71
+ push(ev) {
72
+ switch (ev.type) {
73
+ case 'thinking_delta':
74
+ thinking += ev.text;
75
+ delta({ type: 'thinking_delta', text: ev.text });
76
+ return false;
77
+ case 'text_delta':
78
+ flushThinking();
79
+ text += ev.text;
80
+ delta({ type: 'text_delta', text: ev.text });
81
+ return false;
82
+ case 'tool_use':
83
+ flushThinking(); flushText();
84
+ blocks.push({ type: 'tool_use', tool: ev.tool, toolUseId: ev.toolUseId, input: ev.input });
85
+ delta({ type: 'tool_use', tool: ev.tool, toolUseId: ev.toolUseId, input: ev.input });
86
+ return false;
87
+ case 'tool_use_input': {
88
+ // The claude-code engine opens a tool_use with an EMPTY input and fills it here. A lane
89
+ // that drops this renders pills labelled with nothing.
90
+ const open = blocks.find((b) => b.type === 'tool_use' && b.toolUseId === ev.toolUseId) as { input?: unknown } | undefined;
91
+ if (open) open.input = ev.input;
92
+ delta({ type: 'tool_use_input', toolUseId: ev.toolUseId, input: ev.input });
93
+ return false;
94
+ }
95
+ case 'tool_result':
96
+ blocks.push({ type: 'tool_result', toolUseId: ev.toolUseId, output: ev.output });
97
+ delta({ type: 'tool_result', toolUseId: ev.toolUseId, output: ev.output.length > max ? ev.output.slice(0, max) + '…' : ev.output });
98
+ return false;
99
+ case 'tool_result_image':
100
+ blocks.push({ type: 'image', src: ev.dataUrl });
101
+ delta({ type: 'tool_result_image', toolUseId: ev.toolUseId, dataUrl: ev.dataUrl });
102
+ return false;
103
+ case 'done':
104
+ acc.stopReason = ev.stopReason ?? 'end_turn';
105
+ return true;
106
+ case 'error':
107
+ flushThinking(); flushText();
108
+ blocks.push({ type: 'error', text: ev.message });
109
+ acc.stopReason = 'error';
110
+ return true;
111
+ default:
112
+ // Not core-owned. An add-on engine's event (subagent pills, worker cards) — forward it
113
+ // verbatim rather than discarding it, and leave the transcript to whoever owns it.
114
+ if (!TURN_CONTROL_EVENTS.has(ev.type)) hooks.onPassthrough?.(ev);
115
+ return false;
116
+ }
117
+ },
118
+ finish() { flushThinking(); flushText(); return blocks; },
119
+ snapshot() {
120
+ return [
121
+ ...blocks,
122
+ ...(thinking ? [{ type: 'thinking' as const, text: thinking }] : []),
123
+ ...(text ? [{ type: 'text' as const, text }] : []),
124
+ ];
125
+ },
126
+ };
127
+ return acc;
128
+ }