@yeaft/webchat-agent 0.1.486 → 0.1.488

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.
Files changed (43) hide show
  1. package/package.json +1 -1
  2. package/unify/cli.js +5 -28
  3. package/unify/engine.js +8 -20
  4. package/unify/eval/cases/tool-use.js +1 -22
  5. package/unify/pipeline/dispatcher.js +373 -0
  6. package/unify/session.js +21 -0
  7. package/unify/skills.js +15 -9
  8. package/unify/tools/agent.js +0 -1
  9. package/unify/tools/apply-patch.js +0 -1
  10. package/unify/tools/ask-user.js +0 -1
  11. package/unify/tools/bash.js +0 -1
  12. package/unify/tools/close-agent.js +0 -1
  13. package/unify/tools/enter-worktree.js +0 -1
  14. package/unify/tools/exit-worktree.js +0 -1
  15. package/unify/tools/file-edit.js +0 -1
  16. package/unify/tools/file-read.js +0 -1
  17. package/unify/tools/file-write.js +0 -1
  18. package/unify/tools/glob.js +0 -1
  19. package/unify/tools/grep.js +0 -1
  20. package/unify/tools/history-search.js +0 -1
  21. package/unify/tools/image-generation.js +0 -1
  22. package/unify/tools/js-repl.js +0 -2
  23. package/unify/tools/list-agents.js +0 -1
  24. package/unify/tools/list-dir.js +0 -1
  25. package/unify/tools/mcp-tools.js +0 -2
  26. package/unify/tools/memory-query.js +0 -1
  27. package/unify/tools/memory-read.js +0 -1
  28. package/unify/tools/memory-search.js +0 -1
  29. package/unify/tools/memory-write.js +0 -1
  30. package/unify/tools/notebook-edit.js +0 -1
  31. package/unify/tools/request-permissions.js +0 -1
  32. package/unify/tools/send-message.js +0 -1
  33. package/unify/tools/skill.js +0 -1
  34. package/unify/tools/task-tools.js +0 -8
  35. package/unify/tools/thread-tools.js +0 -7
  36. package/unify/tools/tool-search.js +47 -56
  37. package/unify/tools/types.js +3 -6
  38. package/unify/tools/view-image.js +0 -1
  39. package/unify/tools/wait-agent.js +0 -1
  40. package/unify/tools/web-fetch.js +0 -1
  41. package/unify/tools/web-search.js +0 -1
  42. package/unify/tools/write-stdin.js +0 -1
  43. package/unify/web-bridge.js +281 -181
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.486",
3
+ "version": "0.1.488",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
package/unify/cli.js CHANGED
@@ -37,7 +37,6 @@ import { consolidate } from './memory/consolidate.js';
37
37
 
38
38
  function parseArgs(argv) {
39
39
  const args = {
40
- mode: 'chat',
41
40
  debug: false,
42
41
  interactive: false,
43
42
  verbose: false,
@@ -57,10 +56,6 @@ function parseArgs(argv) {
57
56
  while (i < rest.length) {
58
57
  const arg = rest[i];
59
58
  switch (arg) {
60
- case '-m':
61
- case '--mode':
62
- args.mode = rest[++i] || 'chat';
63
- break;
64
59
  case '-d':
65
60
  case '--debug':
66
61
  args.debug = true;
@@ -176,7 +171,7 @@ function handleTraceQuery(args, config) {
176
171
  // ─── Dry-run handler ───────────────────────────────────────────
177
172
 
178
173
  function handleDryRun(args, config) {
179
- const systemPrompt = buildSystemPrompt({ language: config.language, mode: args.mode });
174
+ const systemPrompt = buildSystemPrompt({ language: config.language });
180
175
  const messages = [];
181
176
 
182
177
  if (args.prompt) {
@@ -188,7 +183,6 @@ function handleDryRun(args, config) {
188
183
  console.log('--- Config ---');
189
184
  console.log(` Model: ${config.model}`);
190
185
  console.log(` Adapter: ${config.adapter || 'auto'}`);
191
- console.log(` Mode: ${args.mode}`);
192
186
  console.log(` Debug: ${config.debug}`);
193
187
  console.log();
194
188
  console.log('--- System Prompt ---');
@@ -218,7 +212,6 @@ async function runREPL(config, args) {
218
212
  });
219
213
 
220
214
  const { engine, conversationStore, memoryStore, trace, skillManager, mcpManager, toolRegistry } = session;
221
- let currentMode = args.mode;
222
215
 
223
216
  // Load persisted conversation as initial messages
224
217
  let conversationMessages = conversationStore.loadRecent(50).map(m => ({
@@ -232,7 +225,7 @@ async function runREPL(config, args) {
232
225
  const coldCount = conversationStore.countCold();
233
226
  const memStats = memoryStore.stats();
234
227
 
235
- console.log(`Yeaft Unify REPL (model: ${session.config.model}, mode: ${currentMode})`);
228
+ console.log(`Yeaft Unify REPL (model: ${session.config.model})`);
236
229
  console.log(`Conversation: ${hotCount} hot, ${coldCount} cold | Memory: ${memStats.entryCount} entries`);
237
230
  console.log(`Tools: ${session.status.tools} | Skills: ${session.status.skills}`);
238
231
  if (session.status.mcpServers.length > 0) {
@@ -247,7 +240,7 @@ async function runREPL(config, args) {
247
240
  const rl = createInterface({
248
241
  input: process.stdin,
249
242
  output: process.stdout,
250
- prompt: `yeaft:${currentMode}> `,
243
+ prompt: `yeaft> `,
251
244
  });
252
245
 
253
246
  rl.prompt();
@@ -265,7 +258,6 @@ async function runREPL(config, args) {
265
258
  switch (cmd) {
266
259
  case 'help':
267
260
  console.log('Commands:');
268
- console.log(' /mode <chat|work|dream> — Switch mode');
269
261
  console.log(' /debug — Toggle debug mode');
270
262
  console.log(' /trace <stats|recent> — Query debug trace');
271
263
  console.log(' /memory [add|clear|stats] — Memory management');
@@ -285,16 +277,6 @@ async function runREPL(config, args) {
285
277
  console.log(' /quit — Exit');
286
278
  break;
287
279
 
288
- case 'mode':
289
- if (cmdArgs[0]) {
290
- currentMode = cmdArgs[0];
291
- rl.setPrompt(`yeaft:${currentMode}> `);
292
- console.log(`Mode switched to: ${currentMode}`);
293
- } else {
294
- console.log(`Current mode: ${currentMode}`);
295
- }
296
- break;
297
-
298
280
  case 'debug':
299
281
  session.config.debug = !session.config.debug;
300
282
  console.log(`Debug mode: ${session.config.debug ? 'ON' : 'OFF'}`);
@@ -433,10 +415,9 @@ async function runREPL(config, args) {
433
415
  case 'context':
434
416
  console.log(`Context info:`);
435
417
  console.log(` Model: ${session.config.model}`);
436
- console.log(` Mode: ${currentMode}`);
437
418
  console.log(` Language: ${session.config.language}`);
438
419
  console.log(` Max context: ${session.config.maxContextTokens} tokens`);
439
- console.log(` System prompt: ${buildSystemPrompt({ language: session.config.language, mode: currentMode }).length} chars`);
420
+ console.log(` System prompt: ${buildSystemPrompt({ language: session.config.language }).length} chars`);
440
421
  console.log(` Hot messages: ${conversationStore.countHot()}`);
441
422
  console.log(` Hot tokens: ${conversationStore.hotTokens()}`);
442
423
  console.log(` Cold messages: ${conversationStore.countCold()}`);
@@ -446,13 +427,12 @@ async function runREPL(config, args) {
446
427
  break;
447
428
 
448
429
  case 'dry-run':
449
- handleDryRun({ ...args, mode: currentMode, prompt: cmdArgs.join(' ') || null }, session.config);
430
+ handleDryRun({ ...args, prompt: cmdArgs.join(' ') || null }, session.config);
450
431
  break;
451
432
 
452
433
  case 'stats': {
453
434
  const s = trace.stats();
454
435
  console.log(`Session stats:`);
455
- console.log(` Mode: ${currentMode}`);
456
436
  console.log(` Debug: ${session.config.debug}`);
457
437
  console.log(` Turns: ${s.turnCount}`);
458
438
  console.log(` Tools: ${s.toolCount}`);
@@ -607,7 +587,6 @@ async function runREPL(config, args) {
607
587
 
608
588
  for await (const event of engine.query({
609
589
  prompt: input,
610
- mode: currentMode,
611
590
  messages: conversationMessages,
612
591
  })) {
613
592
  switch (event.type) {
@@ -709,7 +688,6 @@ async function runOnce(config, args) {
709
688
 
710
689
  for await (const event of engine.query({
711
690
  prompt: args.prompt,
712
- mode: args.mode,
713
691
  messages: priorMessages,
714
692
  })) {
715
693
  switch (event.type) {
@@ -819,7 +797,6 @@ async function main() {
819
797
  console.log(' node cli.js --trace search "keyword" — Search traces');
820
798
  console.log();
821
799
  console.log('Options:');
822
- console.log(' -m, --mode <mode> Mode: chat, work, dream (default: chat)');
823
800
  console.log(' -d, --debug Enable debug tracing');
824
801
  console.log(' -i, --interactive Start REPL');
825
802
  console.log(' -v, --verbose Verbose output');
package/unify/engine.js CHANGED
@@ -167,18 +167,17 @@ export class Engine {
167
167
  /**
168
168
  * Build the system prompt with memory, compact summary, and skill content.
169
169
  *
170
- * @param {string} mode
171
170
  * @param {{ profile?: string, entries?: object[] }} [memory]
172
171
  * @param {string} [compactSummary]
173
172
  * @param {string} [prompt] — user prompt (for skill relevance matching)
174
173
  * @param {string} [memoryInjection] — task-287: prebuilt memory block (index + prefs + project)
175
174
  * @returns {string}
176
175
  */
177
- #buildSystemPrompt(mode, memory, compactSummary, prompt, memoryInjection) {
176
+ #buildSystemPrompt(memory, compactSummary, prompt, memoryInjection) {
178
177
  // Get relevant skill content if SkillManager is wired
179
178
  let skillContent = '';
180
179
  if (this.#skillManager && prompt) {
181
- skillContent = this.#skillManager.getRelevantPromptContent(prompt, mode);
180
+ skillContent = this.#skillManager.getRelevantPromptContent(prompt);
182
181
  }
183
182
 
184
183
  // Get tool names from the appropriate source
@@ -188,7 +187,6 @@ export class Engine {
188
187
 
189
188
  return buildSystemPrompt({
190
189
  language: this.#config.language || 'en',
191
- mode,
192
190
  toolNames,
193
191
  memory,
194
192
  memoryInjection,
@@ -201,10 +199,9 @@ export class Engine {
201
199
  * Build the full tool context for Phase 5 tools.
202
200
  *
203
201
  * @param {AbortSignal} [signal]
204
- * @param {string} [mode]
205
202
  * @returns {object}
206
203
  */
207
- #buildToolContext(signal, mode) {
204
+ #buildToolContext(signal) {
208
205
  return {
209
206
  signal,
210
207
  yeaftDir: this.#yeaftDir,
@@ -215,7 +212,6 @@ export class Engine {
215
212
  conversationStore: this.#conversationStore,
216
213
  adapter: this.#adapter,
217
214
  config: this.#config,
218
- mode,
219
215
  };
220
216
  }
221
217
 
@@ -265,10 +261,9 @@ export class Engine {
265
261
  *
266
262
  * @param {string} userContent
267
263
  * @param {string} assistantContent
268
- * @param {string} mode
269
264
  * @param {object[]} [toolCalls]
270
265
  */
271
- #persistMessages(userContent, assistantContent, mode, toolCalls) {
266
+ #persistMessages(userContent, assistantContent, toolCalls) {
272
267
  if (!this.#conversationStore) return;
273
268
  if (this.#config._readOnly) return;
274
269
 
@@ -288,7 +283,6 @@ export class Engine {
288
283
  this.#conversationStore.append({
289
284
  role: 'user',
290
285
  content: userContent,
291
- mode,
292
286
  threadId,
293
287
  });
294
288
 
@@ -296,7 +290,6 @@ export class Engine {
296
290
  const assistantMsg = {
297
291
  role: 'assistant',
298
292
  content: assistantContent,
299
- mode,
300
293
  model: this.#config.model,
301
294
  threadId,
302
295
  };
@@ -354,14 +347,11 @@ export class Engine {
354
347
  *
355
348
  * @param {object} params
356
349
  * @param {string} params.prompt - The user prompt (required, non-empty).
357
- * @param {'dream'} [params.mode] - Optional mode flag. Since task-297 the only
358
- * value accepted / acted on is `'dream'` (memory maintenance system prompt).
359
- * Any other value is ignored and falls through to the unified system prompt.
360
350
  * @param {Array} [params.messages] - Prior conversation messages.
361
351
  * @param {AbortSignal} [params.signal] - Abort signal.
362
352
  * @yields {EngineEvent}
363
353
  */
364
- async *query({ prompt, mode, messages = [], signal }) {
354
+ async *query({ prompt, messages = [], signal }) {
365
355
  if (!prompt || typeof prompt !== 'string' || !prompt.trim()) {
366
356
  yield {
367
357
  type: 'error',
@@ -394,7 +384,7 @@ export class Engine {
394
384
  }
395
385
 
396
386
  const compactSummary = this.#getCompactSummary();
397
- const systemPrompt = this.#buildSystemPrompt(mode, undefined, compactSummary, prompt, memoryInjection);
387
+ const systemPrompt = this.#buildSystemPrompt(undefined, compactSummary, prompt, memoryInjection);
398
388
 
399
389
  // Build conversation: existing messages + new user message
400
390
  const conversationMessages = [
@@ -423,7 +413,6 @@ export class Engine {
423
413
 
424
414
  const turnId = this.#trace.startTurn({
425
415
  traceId: this.#traceId,
426
- mode,
427
416
  turnNumber,
428
417
  });
429
418
 
@@ -590,7 +579,6 @@ export class Engine {
590
579
  // but receives both configs — messages are persisted with primary model name
591
580
  const hookResult = await runStopHooks({
592
581
  yeaftDir: this.#yeaftDir,
593
- mode,
594
582
  conversationStore: this.#conversationStore,
595
583
  memoryStore: this.#memoryStore,
596
584
  adapter: this.#adapter,
@@ -608,7 +596,7 @@ export class Engine {
608
596
  }
609
597
  } else {
610
598
  // Legacy path (no yeaftDir → use old behavior)
611
- this.#persistMessages(prompt, fullResponseText, mode, assistantMsg.toolCalls);
599
+ this.#persistMessages(prompt, fullResponseText, assistantMsg.toolCalls);
612
600
 
613
601
  const consolidated = await this.#maybeConsolidate();
614
602
  if (consolidated && consolidated.archivedCount > 0) {
@@ -620,7 +608,7 @@ export class Engine {
620
608
  }
621
609
 
622
610
  // Execute tool calls and feed results back
623
- const toolCtx = this.#buildToolContext(signal, mode);
611
+ const toolCtx = this.#buildToolContext(signal);
624
612
 
625
613
  for (const tc of toolCalls) {
626
614
  const toolStartTime = Date.now();
@@ -242,7 +242,6 @@ export const toolUseCases = [
242
242
  suite: 'tools',
243
243
  description: 'Model should read a file then modify it (sequential tools)',
244
244
  prompt: 'Read src/index.js and add a health check endpoint at /health',
245
- mode: 'work',
246
245
  registryTools: allTools,
247
246
  criteria: [
248
247
  noError,
@@ -268,9 +267,8 @@ export const toolUseCases = [
268
267
  {
269
268
  id: 'tool-multi-bash-workflow',
270
269
  suite: 'tools',
271
- description: 'Model should run git status and npm test in work mode',
270
+ description: 'Model should run git status and npm test',
272
271
  prompt: 'Check the git status and run the tests',
273
- mode: 'work',
274
272
  registryTools: allTools,
275
273
  criteria: [
276
274
  noError,
@@ -290,25 +288,6 @@ export const toolUseCases = [
290
288
  ],
291
289
  },
292
290
 
293
- // ─── Mode Awareness ───────────────────────────────────
294
-
295
- {
296
- id: 'tool-mode-chat-no-write',
297
- suite: 'tools',
298
- description: 'In chat mode, write_file should not be available (work-only tool)',
299
- prompt: 'Write "hello" to a file called greeting.txt',
300
- mode: 'chat',
301
- registryTools: allTools,
302
- criteria: [
303
- noError,
304
- toolNotCalled('write_file', {
305
- weight: 10,
306
- id: 'no-write-in-chat',
307
- description: 'write_file is work-only and should not be called in chat mode',
308
- }),
309
- ],
310
- },
311
-
312
291
  // ─── Error Handling ───────────────────────────────────
313
292
 
314
293
  {
@@ -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
+ }