@yeaft/webchat-agent 0.1.487 → 0.1.489

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.487",
3
+ "version": "0.1.489",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -0,0 +1,373 @@
1
+ /**
2
+ * pipeline/dispatcher.js — task-310 Phase 2 integration.
3
+ *
4
+ * Composes the three Phase-2 building blocks into a single linear pipeline:
5
+ *
6
+ * unify_chat input
7
+ * │
8
+ * ▼
9
+ * ┌──────────────┐ (task-307b)
10
+ * │ InputQueue │ persistent FIFO of pending user inputs
11
+ * └──────┬───────┘
12
+ * │ claim() (transition pending → routing)
13
+ * ▼
14
+ * ┌──────────────┐ (task-309)
15
+ * │ IntentClass. │ explicit @prefix / override / LLM / fallback
16
+ * └──────┬───────┘
17
+ * │ { action, targetThreadId, source, reason }
18
+ * ▼
19
+ * ┌──────────────┐ (task-308)
20
+ * │ EngineReg. │ ensure(threadId) → EngineInstance
21
+ * └──────┬───────┘
22
+ * │ inst.query({ prompt })
23
+ * ▼
24
+ * ┌──────────────┐
25
+ * │ Engine events│ text_delta / tool_call / tool_end / …
26
+ * └──────┬───────┘
27
+ * │ each event tagged { ...ev, threadId } by EngineInstance
28
+ * ▼
29
+ * web-bridge forwards to `unify_output`
30
+ *
31
+ * ### Responsibilities
32
+ *
33
+ * This module OWNS the pipeline's control-flow decisions:
34
+ *
35
+ * - `submit(input)` — enqueue + return the queue entry, non-streaming.
36
+ * The caller either invokes `drain()` to actually dispatch, or lets a
37
+ * future background worker pick it up. (We go with the simple "drain
38
+ * on submit" default because the web-bridge is the sole producer and
39
+ * cannot afford a stuck pending entry.)
40
+ *
41
+ * - `dispatch(entry)` async-generator — runs one entry through router +
42
+ * engine. Yields a stream of bridge events:
43
+ *
44
+ * { type: 'input_queue_updated', pending, routing, … }
45
+ * { type: 'routing_decision', entryId, action, targetThreadId, source, reason }
46
+ * { type: 'thread_list_updated', threads, currentThreadId } (on fork)
47
+ * { type: 'engine_event', threadId, event } // raw Engine event
48
+ * { type: 'error', error: Error, retryable }
49
+ *
50
+ * The web-bridge translates `engine_event`s into claude_output (the
51
+ * existing code path) and forwards the pipeline-level events as
52
+ * `unify_output.event`.
53
+ *
54
+ * - `drain()` — convenience: claim + dispatch repeatedly until the queue
55
+ * is empty. Web-bridge calls this after every submit().
56
+ *
57
+ * ### What this module does NOT own
58
+ *
59
+ * - Persistence of messages (Engine / EngineInstance).
60
+ * - ThreadStore mutations beyond incrementing currentId on 'switch' /
61
+ * creating a fork thread on 'fork'.
62
+ * - WebSocket framing (web-bridge does that).
63
+ * - Abort semantics — each caller wraps `dispatch()` with its own
64
+ * AbortController / signal (we forward it to EngineInstance.query).
65
+ *
66
+ * ### Concurrent reflow (spec point 5)
67
+ *
68
+ * Node's single-thread event loop means two concurrent `dispatch()` calls
69
+ * interleave at await points. Every yielded `engine_event` carries a
70
+ * `threadId` (EngineInstance re-tags), so the web-bridge can render events
71
+ * into the correct UI bubble. The dispatcher itself holds NO per-turn
72
+ * state on `this` — all state lives in the async generator's closure, so
73
+ * two pipelines can be in-flight at the same time without aliasing.
74
+ */
75
+
76
+ import { MAIN_THREAD_ID } from '../threads/store.js';
77
+ import { getTaskStore } from '../tools/task-tools.js';
78
+
79
+ /**
80
+ * Per-entry transient metadata (messageId, override) lives here — a
81
+ * WeakMap keyed by the queue entry object so it is NEVER persisted to
82
+ * disk by InputQueueStore.#writeEntry. Entries are GC'd together with
83
+ * the entry once the queue drops the strong reference.
84
+ * @type {WeakMap<object, {messageId?: string, override?: {threadId: string}}>}
85
+ */
86
+ const transientMeta = new WeakMap();
87
+
88
+ /**
89
+ * @typedef {'continue'|'interrupt'|'fork'|'switch'} RouterAction
90
+ *
91
+ * @typedef {Object} SubmitOptions
92
+ * @property {string} [messageId] — optional stable id for override()
93
+ * @property {{ threadId: string }} [override] — UI-side `@thread-name` hint
94
+ * or user correction: skip router, go straight to `switch`/`continue`
95
+ * targeting the given threadId.
96
+ *
97
+ * @typedef {Object} DispatcherDeps
98
+ * @property {import('../input-queue/store.js').InputQueueStore} inputQueue
99
+ * @property {import('../router/intent-classifier.js').IntentClassifier} router
100
+ * @property {import('../threads/engine-registry.js').ThreadEngineRegistry} engineRegistry
101
+ * @property {import('../threads/store.js').ThreadStore} threadStore
102
+ * @property {object} [trace]
103
+ */
104
+
105
+ export class Dispatcher {
106
+ /** @type {DispatcherDeps} */
107
+ #deps;
108
+
109
+ constructor(deps) {
110
+ const { inputQueue, router, engineRegistry, threadStore } = deps || {};
111
+ if (!inputQueue || typeof inputQueue.enqueue !== 'function') {
112
+ throw new Error('Dispatcher: inputQueue is required');
113
+ }
114
+ if (!router || typeof router.classify !== 'function') {
115
+ throw new Error('Dispatcher: router is required');
116
+ }
117
+ if (!engineRegistry || typeof engineRegistry.ensure !== 'function') {
118
+ throw new Error('Dispatcher: engineRegistry is required');
119
+ }
120
+ if (!threadStore || typeof threadStore.list !== 'function') {
121
+ throw new Error('Dispatcher: threadStore is required');
122
+ }
123
+ this.#deps = deps;
124
+ }
125
+
126
+ /** Snapshot of queue counters for the UI, post-mutation. */
127
+ #queueSnapshot() {
128
+ const { inputQueue } = this.#deps;
129
+ const entries = inputQueue.list();
130
+ const counts = { pending: 0, routing: 0, dispatched: 0 };
131
+ for (const e of entries) {
132
+ if (counts[e.status] !== undefined) counts[e.status] += 1;
133
+ }
134
+ return {
135
+ type: 'input_queue_updated',
136
+ total: entries.length,
137
+ pending: counts.pending,
138
+ routing: counts.routing,
139
+ dispatched: counts.dispatched,
140
+ head: entries[0] ? { id: entries[0].id, status: entries[0].status, text: entries[0].text.slice(0, 80) } : null,
141
+ };
142
+ }
143
+
144
+ /**
145
+ * Enqueue a user input. Does NOT dispatch — caller invokes `drain()` or
146
+ * `dispatch(entry)` next. Separated so callers can atomically observe
147
+ * the `input_queue_updated` snapshot before the first router call fires.
148
+ *
149
+ * @param {string} text
150
+ * @param {SubmitOptions} [opts]
151
+ * @returns {{ entry: object, snapshot: object }}
152
+ */
153
+ submit(text, opts = {}) {
154
+ if (typeof text !== 'string' || !text.trim()) {
155
+ throw new Error('Dispatcher.submit: text required');
156
+ }
157
+ const { inputQueue } = this.#deps;
158
+ const entry = inputQueue.enqueue(text);
159
+ // Transient metadata lives in a WeakMap keyed by the entry — it is
160
+ // intentionally off the entry object itself so InputQueueStore's
161
+ // JSON.stringify write path does NOT leak `_messageId`/`_override`
162
+ // to disk. The WeakMap entry is dropped when the queue releases the
163
+ // entry reference (after markRouted removes it from memory).
164
+ transientMeta.set(entry, {
165
+ messageId: opts.messageId || undefined,
166
+ override: opts.override || undefined,
167
+ });
168
+ const snapshot = this.#queueSnapshot();
169
+ return { entry, snapshot };
170
+ }
171
+
172
+ /**
173
+ * Drain the queue: claim → dispatch in a loop until empty.
174
+ * Yields the union of every `dispatch()`'s events, interleaved naturally.
175
+ *
176
+ * @param {{ signal?: AbortSignal }} [opts]
177
+ * @yields {object} bridge events
178
+ */
179
+ async *drain(opts = {}) {
180
+ const { inputQueue } = this.#deps;
181
+ while (true) {
182
+ const head = inputQueue.peek();
183
+ if (!head) return;
184
+ if (head.status !== 'pending') return; // another dispatcher holds it
185
+ for await (const ev of this.dispatch(head, opts)) yield ev;
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Dispatch one queue entry through router + engine. Transitions the
191
+ * entry: pending → routing (on claim) → dispatched (on success) or back
192
+ * to pending (on router exception, which is already guarded inside the
193
+ * classifier — so in practice this branch is very rare).
194
+ *
195
+ * @param {object} entry — from inputQueue.peek() or inputQueue.enqueue()
196
+ * @param {{ signal?: AbortSignal }} [opts]
197
+ * @yields {object} bridge events
198
+ */
199
+ async *dispatch(entry, opts = {}) {
200
+ const { inputQueue, router, engineRegistry, threadStore } = this.#deps;
201
+ const { signal } = opts;
202
+
203
+ // ── Step 1: claim (pending → routing) ──
204
+ // Note: we assume the caller already found `entry` as the head. A
205
+ // concurrent dispatcher would have claimed it first; we check for that.
206
+ let claimed = entry;
207
+ if (entry.status === 'pending') {
208
+ claimed = inputQueue.claim();
209
+ if (!claimed || claimed.id !== entry.id) {
210
+ // Another worker took it. Treat as a no-op success.
211
+ yield this.#queueSnapshot();
212
+ return;
213
+ }
214
+ }
215
+ yield this.#queueSnapshot();
216
+
217
+ // ── Step 2: gather router context ──
218
+ const currentThreadId = threadStore.currentId || MAIN_THREAD_ID;
219
+ const allThreads = threadStore.list().map(t => ({
220
+ id: t.id, name: t.name, goal: t.goal, status: t.status,
221
+ }));
222
+ const pendingTasks = this.#listPendingTasks();
223
+
224
+ // ── Step 3: classify (explicit override > classifier) ──
225
+ /** @type {import('../router/intent-classifier.js').RouterDecision} */
226
+ let decision;
227
+ const meta = transientMeta.get(entry) || {};
228
+ const ov = meta.override;
229
+ if (ov && typeof ov.threadId === 'string' && ov.threadId) {
230
+ const known = allThreads.some(t => t.id === ov.threadId) || ov.threadId === currentThreadId;
231
+ if (known) {
232
+ const action = ov.threadId === currentThreadId ? 'continue' : 'switch';
233
+ decision = {
234
+ action,
235
+ targetThreadId: ov.threadId,
236
+ reason: 'ui_override',
237
+ source: 'override',
238
+ };
239
+ }
240
+ }
241
+ if (!decision) {
242
+ try {
243
+ decision = await router.classify({
244
+ userMessage: claimed.text,
245
+ currentThreadId,
246
+ allThreads,
247
+ pendingTasks,
248
+ messageId: meta.messageId || undefined,
249
+ });
250
+ } catch (err) {
251
+ // Classifier is wrapped in its own try/catch already; reaching here
252
+ // means a truly unexpected failure. Degrade to continue.
253
+ decision = {
254
+ action: 'continue',
255
+ targetThreadId: currentThreadId,
256
+ reason: `dispatcher_classify_exception: ${err.message}`,
257
+ source: 'fallback',
258
+ };
259
+ }
260
+ }
261
+
262
+ yield {
263
+ type: 'routing_decision',
264
+ entryId: claimed.id,
265
+ action: decision.action,
266
+ targetThreadId: decision.targetThreadId,
267
+ source: decision.source || 'llm',
268
+ reason: decision.reason || '',
269
+ };
270
+
271
+ // ── Step 4: resolve target thread (fork may create a new one) ──
272
+ let targetThreadId = decision.targetThreadId;
273
+ if (decision.action === 'fork') {
274
+ const parentId = decision.targetThreadId || currentThreadId;
275
+ const forked = this.#spawnForkedThread(parentId, claimed.text);
276
+ if (forked) {
277
+ targetThreadId = forked.id;
278
+ yield this.#threadListSnapshot();
279
+ }
280
+ } else if (decision.action === 'switch') {
281
+ // Move the ThreadStore cursor so subsequent tool-originated events
282
+ // see the right thread for persistence hooks. Guard: not every
283
+ // ThreadStore implementation exposes has() (e.g. historic mocks).
284
+ const hasFn = typeof threadStore.has === 'function' ? (id) => threadStore.has(id) : () => true;
285
+ if (hasFn(targetThreadId)) {
286
+ try { threadStore.switch(targetThreadId); } catch { /* ignore */ }
287
+ }
288
+ }
289
+
290
+ // ── Step 5: dispatch to EngineInstance ──
291
+ let instance;
292
+ try {
293
+ instance = engineRegistry.ensure(targetThreadId);
294
+ } catch (err) {
295
+ inputQueue.markFailed(claimed.id, err);
296
+ yield this.#queueSnapshot();
297
+ yield { type: 'error', error: err, retryable: false };
298
+ return;
299
+ }
300
+
301
+ try {
302
+ for await (const event of instance.query({ prompt: claimed.text, signal })) {
303
+ yield { type: 'engine_event', threadId: targetThreadId, event };
304
+ }
305
+ inputQueue.markRouted(claimed.id, targetThreadId);
306
+ yield this.#queueSnapshot();
307
+ } catch (err) {
308
+ inputQueue.markFailed(claimed.id, err);
309
+ yield this.#queueSnapshot();
310
+ yield { type: 'error', error: err, retryable: true };
311
+ }
312
+ }
313
+
314
+ // ──────────────────────────────────────────────────────────────
315
+
316
+ #threadListSnapshot() {
317
+ const { threadStore } = this.#deps;
318
+ const threads = threadStore.list().map(t => ({
319
+ id: t.id,
320
+ name: t.name,
321
+ goal: t.goal || '',
322
+ parentThreadId: t.parentThreadId || null,
323
+ status: t.status,
324
+ archived: !!t.archived,
325
+ messageCount: t.messageCount || 0,
326
+ lastMessageAt: t.lastMessageAt || null,
327
+ }));
328
+ return {
329
+ type: 'thread_list_updated',
330
+ threads,
331
+ currentThreadId: threadStore.currentId,
332
+ };
333
+ }
334
+
335
+ #spawnForkedThread(parentId, promptText) {
336
+ const { threadStore } = this.#deps;
337
+ if (!threadStore.create) return null;
338
+ // Short label from first non-empty line, capped at 40 chars.
339
+ const firstLine = (promptText || '').split(/\r?\n/).find(l => l.trim()) || 'fork';
340
+ const name = firstLine.trim().slice(0, 40);
341
+ try {
342
+ return threadStore.create({ name, parentThreadId: parentId });
343
+ } catch {
344
+ return null;
345
+ }
346
+ }
347
+
348
+ #listPendingTasks() {
349
+ // Best-effort: the TaskStore is a singleton initialised in loadSession().
350
+ // If the store isn't available (e.g. unit tests without a session) we
351
+ // just return []. Never let a TaskStore exception break routing.
352
+ try {
353
+ const store = getTaskStore();
354
+ if (!store || typeof store.list !== 'function') return [];
355
+ const pending = store.list({ status: 'pending' }) || [];
356
+ return pending.map(t => ({
357
+ id: t.id,
358
+ title: t.title || '',
359
+ threadId: t.threadId || null,
360
+ }));
361
+ } catch { /* ignore */ }
362
+ return [];
363
+ }
364
+ }
365
+
366
+ /**
367
+ * Build a Dispatcher from session-level deps.
368
+ * @param {DispatcherDeps} deps
369
+ * @returns {Dispatcher}
370
+ */
371
+ export function createDispatcher(deps) {
372
+ return new Dispatcher(deps);
373
+ }
package/unify/session.js CHANGED
@@ -27,7 +27,10 @@ import { initThreadStore } from './threads/store.js';
27
27
  import { Engine } from './engine.js';
28
28
  import { createThreadEngineRegistry } from './threads/engine-registry.js';
29
29
  import { MAIN_THREAD_ID } from './threads/store.js';
30
+ import { getThreadStore } from './threads/store.js';
30
31
  import { createIntentClassifier } from './router/intent-classifier.js';
32
+ import { initInputQueueStore } from './input-queue/store.js';
33
+ import { createDispatcher } from './pipeline/dispatcher.js';
31
34
  import { join } from 'path';
32
35
 
33
36
  /**
@@ -193,6 +196,22 @@ export async function loadSession(options = {}) {
193
196
  // config as the engines so it can use primaryModel for classification.
194
197
  const router = createIntentClassifier({ adapter, trace, config });
195
198
 
199
+ // task-310 Phase 2 integration: wire InputQueue + Dispatcher so the
200
+ // web-bridge can submit `unify_chat` inputs through the unified pipeline
201
+ // (queue → router → engineRegistry → EngineInstance). In read-only mode
202
+ // the queue is memory-only (no disk writes).
203
+ const inputQueue = initInputQueueStore({
204
+ yeaftDir: config._readOnly ? null : yeaftDir,
205
+ force: true,
206
+ });
207
+ const dispatcher = createDispatcher({
208
+ inputQueue,
209
+ router,
210
+ engineRegistry,
211
+ threadStore: getThreadStore(),
212
+ trace,
213
+ });
214
+
196
215
  // ─── 10. Build session ─────────────────────────────────
197
216
  const status = {
198
217
  skills: skillManager.size,
@@ -224,6 +243,8 @@ export async function loadSession(options = {}) {
224
243
  engine,
225
244
  engineRegistry,
226
245
  router,
246
+ inputQueue,
247
+ dispatcher,
227
248
  adapter,
228
249
  config,
229
250
  conversationStore,
@@ -116,6 +116,254 @@ const THREAD_MUTATING_TOOLS = new Set([
116
116
  'AttachThreadToTask',
117
117
  ]);
118
118
 
119
+ /**
120
+ * task-310: parse a leading `@thread-<id>` or `@thread-<name>` marker on
121
+ * the user's input and return it as a dispatcher override. The marker
122
+ * itself is STRIPPED from the prompt before it reaches the engine —
123
+ * users don't want to see `@thread-foo` echoed back into their
124
+ * conversation.
125
+ *
126
+ * Thread IDs are `main` or `thr-<8 hex>`, so the match captures the id
127
+ * name AFTER the literal `@thread-`. The returned `override.threadId`
128
+ * is the fully-qualified thread id (e.g. `thread-main`, `thread-thr-abcd1234`).
129
+ *
130
+ * Returns { prompt, override? } where override = { threadId } if matched.
131
+ */
132
+ export function parseThreadPrefix(text) {
133
+ if (!text || typeof text !== 'string') return { prompt: text || '', override: null };
134
+ // Capture the id portion after the literal `@thread-` prefix.
135
+ const m = text.match(/^\s*@thread-([A-Za-z0-9_-]+)\b\s*/);
136
+ if (!m) return { prompt: text, override: null };
137
+ const rest = text.slice(m[0].length);
138
+ // The captured id may already include a `thr-` sub-prefix (for non-main
139
+ // threads). For the canonical `main` thread, the override is the bare
140
+ // string `main`; for `thr-xxxxxxxx` threads, pass through verbatim.
141
+ const threadId = m[1];
142
+ return { prompt: rest || text, override: { threadId } };
143
+ }
144
+
145
+ /**
146
+ * Translate a pipeline event (from Dispatcher) into web-bridge outputs.
147
+ * Pipeline events are distinct from engine events — they carry queue /
148
+ * routing state for the UI. Engine events are unwrapped and forwarded
149
+ * through the existing sendUnifyOutput / sendUnifyEvent path.
150
+ *
151
+ * Returns whether the pipeline is complete (terminal error / no more).
152
+ */
153
+ function forwardPipelineEvent(ev, ctx) {
154
+ if (!ev || typeof ev !== 'object') return false;
155
+ switch (ev.type) {
156
+ case 'input_queue_updated':
157
+ sendUnifyEvent({
158
+ type: 'input_queue_updated',
159
+ total: ev.total,
160
+ pending: ev.pending,
161
+ routing: ev.routing,
162
+ dispatched: ev.dispatched,
163
+ head: ev.head,
164
+ });
165
+ return false;
166
+ case 'routing_decision':
167
+ sendUnifyEvent({
168
+ type: 'routing_decision',
169
+ entryId: ev.entryId,
170
+ action: ev.action,
171
+ targetThreadId: ev.targetThreadId,
172
+ source: ev.source,
173
+ reason: ev.reason,
174
+ });
175
+ return false;
176
+ case 'thread_list_updated':
177
+ // Dispatcher built it already; just forward.
178
+ sendUnifyEvent({
179
+ type: 'thread_list_updated',
180
+ threads: ev.threads,
181
+ currentThreadId: ev.currentThreadId,
182
+ });
183
+ return false;
184
+ case 'engine_event':
185
+ ctx.onEngineEvent(ev.event, ev.threadId);
186
+ return false;
187
+ case 'error':
188
+ ctx.onError(ev.error);
189
+ return true;
190
+ default:
191
+ return false;
192
+ }
193
+ }
194
+
195
+ /**
196
+ * Handle a single engine event unwrapped from an `engine_event` pipeline
197
+ * envelope. Contains the event-type switch previously inlined in the
198
+ * streaming loop. `threadId` is propagated onto tool_use / tool_result
199
+ * blocks so the UI can render per-thread bubbles.
200
+ *
201
+ * @param {object} event — engine event (text_delta / tool_call / …)
202
+ * @param {string} threadId — owning thread id (from envelope)
203
+ * @param {{assistantTextParts:string[], resetQueryTimer:Function}} hctx
204
+ */
205
+ function handleEngineEvent(event, threadId, hctx) {
206
+ hctx.resetQueryTimer();
207
+ switch (event.type) {
208
+ case 'text_delta':
209
+ hctx.assistantTextParts.push(event.text);
210
+ sendUnifyOutput({
211
+ type: 'assistant',
212
+ message: { content: [{ type: 'text', text: event.text }] },
213
+ threadId,
214
+ });
215
+ break;
216
+
217
+ case 'thinking_delta':
218
+ sendUnifyEvent({ type: 'thinking_delta', text: event.text, threadId });
219
+ break;
220
+
221
+ case 'tool_call':
222
+ // Finish any in-progress text streaming so UI shows typing dots
223
+ sendUnifyOutput({
224
+ type: 'assistant',
225
+ message: { content: [] },
226
+ threadId,
227
+ });
228
+ sendUnifyOutput({
229
+ type: 'assistant',
230
+ message: {
231
+ content: [{
232
+ type: 'tool_use',
233
+ id: event.id,
234
+ name: event.name,
235
+ input: event.input,
236
+ }],
237
+ },
238
+ threadId: event.threadId || threadId,
239
+ });
240
+ break;
241
+
242
+ case 'tool_start':
243
+ sendUnifyEvent({
244
+ type: 'tool_start',
245
+ id: event.id,
246
+ name: event.name,
247
+ threadId: event.threadId || threadId,
248
+ });
249
+ break;
250
+
251
+ case 'tool_end':
252
+ sendUnifyOutput({
253
+ type: 'user',
254
+ tool_use_result: [{
255
+ type: 'tool_result',
256
+ tool_use_id: event.id,
257
+ content: event.output || '',
258
+ is_error: event.isError || false,
259
+ }],
260
+ threadId: event.threadId || threadId,
261
+ });
262
+ if (THREAD_MUTATING_TOOLS.has(event.name)) {
263
+ sendThreadListUpdate();
264
+ }
265
+ break;
266
+
267
+ case 'turn_start':
268
+ case 'turn_end':
269
+ case 'stop':
270
+ // No UI action needed; outer loop sends the final result.
271
+ break;
272
+
273
+ case 'usage':
274
+ sendUnifyEvent({
275
+ type: 'context_usage',
276
+ inputTokens: event.inputTokens,
277
+ outputTokens: event.outputTokens,
278
+ threadId,
279
+ });
280
+ break;
281
+
282
+ case 'recall':
283
+ sendUnifyEvent({
284
+ type: 'recall',
285
+ entryCount: event.entryCount,
286
+ cached: event.cached,
287
+ threadId,
288
+ });
289
+ break;
290
+
291
+ case 'consolidate':
292
+ // Engine compressed the context — clear our accumulated history.
293
+ conversationMessages = [];
294
+ sendUnifyEvent({
295
+ type: 'consolidate',
296
+ archivedCount: event.archivedCount,
297
+ extractedCount: event.extractedCount,
298
+ threadId,
299
+ });
300
+ break;
301
+
302
+ case 'fallback':
303
+ sendUnifyEvent({
304
+ type: 'fallback',
305
+ from: event.from,
306
+ to: event.to,
307
+ reason: event.reason,
308
+ threadId,
309
+ });
310
+ break;
311
+
312
+ case 'debug_turn':
313
+ sendUnifyEvent({
314
+ type: 'debug_turn',
315
+ turnNumber: event.turnNumber,
316
+ model: event.model,
317
+ systemPrompt: event.systemPrompt,
318
+ messages: event.messages,
319
+ response: event.response,
320
+ toolCalls: event.toolCalls,
321
+ usage: event.usage,
322
+ latencyMs: event.latencyMs,
323
+ ttfbMs: event.ttfbMs,
324
+ stopReason: event.stopReason,
325
+ threadId,
326
+ });
327
+ break;
328
+
329
+ case 'error': {
330
+ const errMsg = event.error?.message || 'Unknown error';
331
+ // Filter permission errors: show friendly one-time diagnostic
332
+ // instead of raw error. Subsequent permission errors are suppressed
333
+ // — the user already saw the actionable message once.
334
+ if (isPermissionErrorMsg(errMsg)) {
335
+ if (!_permissionDiagnosticSent) {
336
+ _permissionDiagnosticSent = true;
337
+ sendUnifyOutput({
338
+ type: 'assistant',
339
+ message: {
340
+ content: [{
341
+ type: 'text',
342
+ text: '⚠️ Cannot write to ~/.yeaft/ directory — some features (memory, history) are unavailable. Please check directory permissions: `chmod -R u+rw ~/.yeaft/`',
343
+ }],
344
+ },
345
+ threadId,
346
+ });
347
+ }
348
+ // Don't show subsequent permission errors.
349
+ } else {
350
+ sendUnifyOutput({
351
+ type: 'assistant',
352
+ message: {
353
+ content: [{ type: 'text', text: `⚠️ Error: ${errMsg}` }],
354
+ },
355
+ threadId,
356
+ });
357
+ }
358
+ break;
359
+ }
360
+
361
+ default:
362
+ // Silently consume unknown events.
363
+ break;
364
+ }
365
+ }
366
+
119
367
  /**
120
368
  * Handle a unify_chat message from the web UI.
121
369
  *
@@ -193,192 +441,44 @@ export async function handleUnifyChat(msg) {
193
441
  // ─── Collect assistant response for conversation history ──
194
442
  let assistantTextParts = [];
195
443
 
196
- // ─── Stream Engine eventsclaude_output format ──
197
- for await (const event of session.engine.query({
198
- prompt,
199
- messages: conversationMessages,
200
- signal: currentAbort.signal,
201
- })) {
202
- // Reset timeout on every event activity means the query is alive
203
- resetQueryTimer();
204
- switch (event.type) {
205
- // ── Text streaming ──
206
- case 'text_delta':
207
- assistantTextParts.push(event.text);
208
- sendUnifyOutput({
209
- type: 'assistant',
210
- message: {
211
- content: [{ type: 'text', text: event.text }],
212
- },
213
- });
214
- break;
215
-
216
- // ── Thinking streaming (extended thinking) ──
217
- case 'thinking_delta':
218
- // Currently not rendered in UI, but forward for future use
219
- sendUnifyEvent({ type: 'thinking_delta', text: event.text });
220
- break;
444
+ // task-310: route via Dispatcher pipeline (queue router registry →
445
+ // EngineInstance). The input is enqueued first so the UI observes the
446
+ // `input_queue_updated` snapshot before the router runs. An explicit
447
+ // `@thread-xxx` prefix on the message or an `override` field on the
448
+ // `unify_chat` payload becomes a dispatcher override — skipping the LLM.
449
+ const { prompt: cleanedPrompt, override: prefixOverride } = parseThreadPrefix(prompt);
450
+ const override = msg.override && typeof msg.override === 'object' && msg.override.threadId
451
+ ? msg.override
452
+ : prefixOverride;
453
+
454
+ const { entry } = session.dispatcher.submit(cleanedPrompt, {
455
+ messageId: msg.messageId,
456
+ override: override || undefined,
457
+ });
458
+ sendUnifyEvent({
459
+ type: 'input_queue_updated',
460
+ total: 1,
461
+ pending: 1,
462
+ routing: 0,
463
+ dispatched: 0,
464
+ head: { id: entry.id, status: entry.status, text: entry.text.slice(0, 80) },
465
+ });
221
466
 
222
- // ── Tool call announced by LLM ──
223
- case 'tool_call':
224
- // Finish any in-progress text streaming so UI shows typing dots
225
- sendUnifyOutput({
226
- type: 'assistant',
227
- message: { content: [] },
228
- });
229
- // Send tool_use block
230
- sendUnifyOutput({
231
- type: 'assistant',
232
- message: {
233
- content: [{
234
- type: 'tool_use',
235
- id: event.id,
236
- name: event.name,
237
- input: event.input,
238
- }],
239
- },
240
- threadId: event.threadId,
241
- });
242
- break;
243
-
244
- // ── Tool execution started ──
245
- case 'tool_start':
246
- // Tool is running — the UI already shows it from tool_use block above.
247
- // Forward threadId so the UI can group tool activity by thread (Phase 1).
248
- sendUnifyEvent({ type: 'tool_start', id: event.id, name: event.name, threadId: event.threadId });
249
- break;
250
-
251
- // ── Tool execution completed ──
252
- case 'tool_end':
253
- // Send tool_result as a user message (matches Claude CLI format)
254
- sendUnifyOutput({
255
- type: 'user',
256
- tool_use_result: [{
257
- type: 'tool_result',
258
- tool_use_id: event.id,
259
- content: event.output || '',
260
- is_error: event.isError || false,
261
- }],
262
- threadId: event.threadId,
263
- });
264
- // task-301 Part 2: if this tool mutates ThreadStore, push a
265
- // fresh snapshot to the sidebar immediately.
266
- if (THREAD_MUTATING_TOOLS.has(event.name)) {
267
- sendThreadListUpdate();
268
- }
269
- break;
270
-
271
- // ── Turn boundaries ──
272
- case 'turn_start':
273
- // No UI action needed
274
- break;
275
-
276
- case 'turn_end':
277
- // Don't send result/done here — wait for the outermost loop to finish
278
- break;
279
-
280
- // ── Token usage ──
281
- case 'usage':
282
- sendUnifyEvent({
283
- type: 'context_usage',
284
- inputTokens: event.inputTokens,
285
- outputTokens: event.outputTokens,
286
- });
287
- break;
288
-
289
- // ── Stop reason from LLM ──
290
- case 'stop':
291
- // Intermediate signal — final done is sent after the loop
292
- break;
293
-
294
- // ── Memory recall ──
295
- case 'recall':
296
- sendUnifyEvent({
297
- type: 'recall',
298
- entryCount: event.entryCount,
299
- cached: event.cached,
300
- });
301
- break;
302
-
303
- // ── Context consolidation ──
304
- case 'consolidate':
305
- // Engine has compressed the context — clear our accumulated history.
306
- // The engine's compactSummary will provide context on next query.
307
- conversationMessages = [];
308
- sendUnifyEvent({
309
- type: 'consolidate',
310
- archivedCount: event.archivedCount,
311
- extractedCount: event.extractedCount,
312
- });
313
- break;
314
-
315
- // ── Model fallback ──
316
- case 'fallback':
317
- sendUnifyEvent({
318
- type: 'fallback',
319
- from: event.from,
320
- to: event.to,
321
- reason: event.reason,
322
- });
323
- break;
324
-
325
- // ── Debug turn data for web debug panel ──
326
- case 'debug_turn':
327
- sendUnifyEvent({
328
- type: 'debug_turn',
329
- turnNumber: event.turnNumber,
330
- model: event.model,
331
- systemPrompt: event.systemPrompt,
332
- messages: event.messages,
333
- response: event.response,
334
- toolCalls: event.toolCalls,
335
- usage: event.usage,
336
- latencyMs: event.latencyMs,
337
- ttfbMs: event.ttfbMs,
338
- stopReason: event.stopReason,
339
- });
340
- break;
341
-
342
- // ── Errors ──
343
- case 'error': {
344
- const errMsg = event.error?.message || 'Unknown error';
345
- // Filter permission errors: show friendly one-time diagnostic instead of raw error
346
- if (isPermissionErrorMsg(errMsg)) {
347
- if (!_permissionDiagnosticSent) {
348
- _permissionDiagnosticSent = true;
349
- sendUnifyOutput({
350
- type: 'assistant',
351
- message: {
352
- content: [{
353
- type: 'text',
354
- text: '⚠️ Cannot write to ~/.yeaft/ directory — some features (memory, history) are unavailable. Please check directory permissions: `chmod -R u+rw ~/.yeaft/`',
355
- }],
356
- },
357
- });
358
- }
359
- // Don't show subsequent permission errors
360
- } else {
361
- sendUnifyOutput({
362
- type: 'assistant',
363
- message: {
364
- content: [{
365
- type: 'text',
366
- text: `⚠️ Error: ${errMsg}`,
367
- }],
368
- },
369
- });
370
- }
371
- break;
372
- }
467
+ const pipelineCtx = {
468
+ onEngineEvent: (event, threadId) => handleEngineEvent(event, threadId, {
469
+ assistantTextParts,
470
+ resetQueryTimer,
471
+ }),
472
+ onError: (err) => { throw err; },
473
+ };
373
474
 
374
- default:
375
- // Silently consume unknown events
376
- break;
377
- }
475
+ for await (const pev of session.dispatcher.drain({ signal: currentAbort.signal })) {
476
+ resetQueryTimer();
477
+ forwardPipelineEvent(pev, pipelineCtx);
378
478
  }
379
479
 
380
480
  // ─── Query complete — accumulate messages for context continuity ──
381
- conversationMessages.push({ role: 'user', content: prompt });
481
+ conversationMessages.push({ role: 'user', content: cleanedPrompt });
382
482
 
383
483
  const fullText = assistantTextParts.join('');
384
484
  if (fullText) {