arbiter-ai 1.0.0

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 (51) hide show
  1. package/README.md +41 -0
  2. package/assets/jerom_16x16.png +0 -0
  3. package/dist/arbiter.d.ts +43 -0
  4. package/dist/arbiter.js +486 -0
  5. package/dist/context-analyzer.d.ts +15 -0
  6. package/dist/context-analyzer.js +603 -0
  7. package/dist/index.d.ts +2 -0
  8. package/dist/index.js +165 -0
  9. package/dist/orchestrator.d.ts +31 -0
  10. package/dist/orchestrator.js +227 -0
  11. package/dist/router.d.ts +187 -0
  12. package/dist/router.js +1135 -0
  13. package/dist/router.test.d.ts +15 -0
  14. package/dist/router.test.js +95 -0
  15. package/dist/session-persistence.d.ts +9 -0
  16. package/dist/session-persistence.js +63 -0
  17. package/dist/session-persistence.test.d.ts +1 -0
  18. package/dist/session-persistence.test.js +165 -0
  19. package/dist/sound.d.ts +31 -0
  20. package/dist/sound.js +50 -0
  21. package/dist/state.d.ts +72 -0
  22. package/dist/state.js +107 -0
  23. package/dist/state.test.d.ts +1 -0
  24. package/dist/state.test.js +194 -0
  25. package/dist/test-headless.d.ts +1 -0
  26. package/dist/test-headless.js +155 -0
  27. package/dist/tui/index.d.ts +14 -0
  28. package/dist/tui/index.js +17 -0
  29. package/dist/tui/layout.d.ts +30 -0
  30. package/dist/tui/layout.js +200 -0
  31. package/dist/tui/render.d.ts +57 -0
  32. package/dist/tui/render.js +266 -0
  33. package/dist/tui/scene.d.ts +64 -0
  34. package/dist/tui/scene.js +366 -0
  35. package/dist/tui/screens/CharacterSelect-termkit.d.ts +18 -0
  36. package/dist/tui/screens/CharacterSelect-termkit.js +216 -0
  37. package/dist/tui/screens/ForestIntro-termkit.d.ts +15 -0
  38. package/dist/tui/screens/ForestIntro-termkit.js +856 -0
  39. package/dist/tui/screens/GitignoreCheck-termkit.d.ts +14 -0
  40. package/dist/tui/screens/GitignoreCheck-termkit.js +185 -0
  41. package/dist/tui/screens/TitleScreen-termkit.d.ts +14 -0
  42. package/dist/tui/screens/TitleScreen-termkit.js +132 -0
  43. package/dist/tui/screens/index.d.ts +9 -0
  44. package/dist/tui/screens/index.js +10 -0
  45. package/dist/tui/tileset.d.ts +97 -0
  46. package/dist/tui/tileset.js +237 -0
  47. package/dist/tui/tui-termkit.d.ts +34 -0
  48. package/dist/tui/tui-termkit.js +2602 -0
  49. package/dist/tui/types.d.ts +41 -0
  50. package/dist/tui/types.js +4 -0
  51. package/package.json +71 -0
package/dist/router.js ADDED
@@ -0,0 +1,1135 @@
1
+ // Message Router - Core component managing sessions and routing messages
2
+ // Handles Arbiter and Orchestrator session lifecycle and message routing
3
+ import { query } from '@anthropic-ai/claude-agent-sdk';
4
+ import { z } from 'zod';
5
+ import { zodToJsonSchema } from 'zod-to-json-schema';
6
+ import { saveSession } from './session-persistence.js';
7
+ // Helper for async delays
8
+ function sleep(ms) {
9
+ return new Promise((resolve) => setTimeout(resolve, ms));
10
+ }
11
+ // Retry constants for crash recovery
12
+ const MAX_RETRIES = 3;
13
+ const RETRY_DELAYS = [1000, 2000, 4000]; // 1s, 2s, 4s exponential backoff
14
+ // Arbiter's allowed tools: MCP tools + read-only exploration
15
+ const ARBITER_ALLOWED_TOOLS = [
16
+ 'mcp__arbiter-tools__spawn_orchestrator',
17
+ 'mcp__arbiter-tools__disconnect_orchestrators',
18
+ 'Read',
19
+ 'Glob',
20
+ 'Grep',
21
+ 'WebSearch',
22
+ 'WebFetch',
23
+ 'Task', // For Explore subagent only
24
+ ];
25
+ // Orchestrator's allowed tools: full tool access for work
26
+ const ORCHESTRATOR_ALLOWED_TOOLS = [
27
+ 'Read',
28
+ 'Write',
29
+ 'Edit',
30
+ 'Bash',
31
+ 'Glob',
32
+ 'Grep',
33
+ 'Task',
34
+ 'WebSearch',
35
+ 'WebFetch',
36
+ ];
37
+ import { addMessage, clearCurrentOrchestrator, setCurrentOrchestrator, setMode, toRoman, updateArbiterContext, updateOrchestratorContext, updateOrchestratorTool, } from './state.js';
38
+ /**
39
+ * Schema for Orchestrator structured output
40
+ * Simple routing decision: does this message expect a response?
41
+ */
42
+ const OrchestratorOutputSchema = z.object({
43
+ expects_response: z
44
+ .boolean()
45
+ .describe('True if you need input from the Arbiter (questions, introductions, handoffs). False for status updates during heads-down work.'),
46
+ message: z.string().describe('The message content'),
47
+ });
48
+ // Convert to JSON Schema for SDK
49
+ const orchestratorOutputJsonSchema = zodToJsonSchema(OrchestratorOutputSchema, {
50
+ $refStrategy: 'none',
51
+ });
52
+ import { ARBITER_SYSTEM_PROMPT, createArbiterHooks, createArbiterMcpServer, } from './arbiter.js';
53
+ import { createOrchestratorHooks, ORCHESTRATOR_SYSTEM_PROMPT, } from './orchestrator.js';
54
+ // Maximum context window size (200K tokens)
55
+ const MAX_CONTEXT_TOKENS = 200000;
56
+ // Context polling interval (1 minute)
57
+ const CONTEXT_POLL_INTERVAL_MS = 60_000;
58
+ /**
59
+ * Poll context usage by forking a session and running /context
60
+ * Uses forkSession: true to avoid polluting the main conversation
61
+ * Note: This does clutter resume history - no workaround found yet
62
+ *
63
+ * @param sessionId - The session ID to fork and check
64
+ * @returns Context percentage (0-100) or null if polling failed
65
+ */
66
+ async function pollContextForSession(sessionId) {
67
+ try {
68
+ const q = query({
69
+ prompt: '/context',
70
+ options: {
71
+ resume: sessionId,
72
+ forkSession: true, // Fork to avoid polluting main session
73
+ permissionMode: 'bypassPermissions',
74
+ },
75
+ });
76
+ let percent = null;
77
+ for await (const msg of q) {
78
+ // /context output comes through as user message with the token info
79
+ if (msg.type === 'user') {
80
+ const content = msg.message?.content;
81
+ if (typeof content === 'string') {
82
+ // Match: **Tokens:** 18.4k / 200.0k (9%)
83
+ const match = content.match(/\*\*Tokens:\*\*\s*([0-9.]+)k\s*\/\s*200\.?0?k\s*\((\d+)%\)/i);
84
+ if (match) {
85
+ percent = parseInt(match[2], 10);
86
+ }
87
+ }
88
+ }
89
+ }
90
+ return percent;
91
+ }
92
+ catch (_error) {
93
+ // Silently fail - context polling is best-effort
94
+ return null;
95
+ }
96
+ }
97
+ /**
98
+ * Formats an SDK message for debug logging
99
+ * Returns a human-readable string representation of the message
100
+ */
101
+ function formatSdkMessage(message) {
102
+ switch (message.type) {
103
+ case 'system': {
104
+ const sysMsg = message;
105
+ if (sysMsg.subtype === 'init') {
106
+ return `session_id=${sysMsg.session_id}`;
107
+ }
108
+ return `subtype=${sysMsg.subtype}`;
109
+ }
110
+ case 'assistant': {
111
+ const assistantMsg = message;
112
+ const content = assistantMsg.message.content;
113
+ const parts = [];
114
+ if (typeof content === 'string') {
115
+ parts.push(`text: "${truncate(content, 100)}"`);
116
+ }
117
+ else if (Array.isArray(content)) {
118
+ for (const block of content) {
119
+ if (block.type === 'text') {
120
+ const textBlock = block;
121
+ parts.push(`text: "${truncate(textBlock.text, 100)}"`);
122
+ }
123
+ else if (block.type === 'tool_use') {
124
+ const toolBlock = block;
125
+ const inputStr = JSON.stringify(toolBlock.input);
126
+ parts.push(`tool_use: ${toolBlock.name}(${truncate(inputStr, 80)})`);
127
+ }
128
+ else if (block.type === 'tool_result') {
129
+ const resultBlock = block;
130
+ const contentStr = typeof resultBlock.content === 'string'
131
+ ? resultBlock.content
132
+ : JSON.stringify(resultBlock.content);
133
+ parts.push(`tool_result: ${truncate(contentStr, 80)}`);
134
+ }
135
+ else {
136
+ parts.push(`${block.type}: ...`);
137
+ }
138
+ }
139
+ }
140
+ return parts.join(' | ') || '(empty)';
141
+ }
142
+ case 'user': {
143
+ const userMsg = message;
144
+ const content = userMsg.message?.content;
145
+ if (typeof content === 'string') {
146
+ return `"${truncate(content, 100)}"`;
147
+ }
148
+ else if (Array.isArray(content)) {
149
+ const types = content.map((b) => b.type).join(', ');
150
+ return `[${types}]`;
151
+ }
152
+ return '(user message)';
153
+ }
154
+ case 'result': {
155
+ const resultMsg = message;
156
+ if (resultMsg.subtype === 'success') {
157
+ const usage = resultMsg.usage;
158
+ const total = (usage.input_tokens || 0) +
159
+ (usage.cache_read_input_tokens || 0) +
160
+ (usage.cache_creation_input_tokens || 0);
161
+ const pct = ((total / MAX_CONTEXT_TOKENS) * 100).toFixed(1);
162
+ return `success - tokens: ${total} (${pct}% context)`;
163
+ }
164
+ else {
165
+ return `${resultMsg.subtype}`;
166
+ }
167
+ }
168
+ default:
169
+ return `(${message.type})`;
170
+ }
171
+ }
172
+ /**
173
+ * Truncates a string to a maximum length, adding ellipsis if needed
174
+ */
175
+ function truncate(str, maxLength) {
176
+ // Remove newlines for cleaner display
177
+ const clean = str.replace(/\n/g, '\\n');
178
+ if (clean.length <= maxLength)
179
+ return clean;
180
+ return `${clean.substring(0, maxLength - 3)}...`;
181
+ }
182
+ /**
183
+ * Format queued messages and trigger message for the Arbiter
184
+ * Uses «» delimiters with explicit labels
185
+ *
186
+ * @param queue - Array of queued messages (status updates from expects_response: false)
187
+ * @param triggerMessage - The message that triggered the flush (expects_response: true)
188
+ * @param triggerType - 'input' for questions, 'handoff' for completion, 'human' for interjection
189
+ * @param orchNumber - The orchestrator's number (for labeling)
190
+ */
191
+ function formatQueueForArbiter(queue, triggerMessage, triggerType, orchNumber) {
192
+ const orchLabel = `Orchestrator ${toRoman(orchNumber)}`;
193
+ const parts = [];
194
+ // Add work log section if there are queued messages
195
+ if (queue.length > 0) {
196
+ parts.push(`«${orchLabel} - Work Log (no response needed)»`);
197
+ for (const msg of queue) {
198
+ parts.push(`• ${msg}`);
199
+ }
200
+ parts.push(''); // Empty line separator
201
+ }
202
+ // Add the trigger section based on type
203
+ switch (triggerType) {
204
+ case 'input':
205
+ parts.push(`«${orchLabel} - Awaiting Input»`);
206
+ break;
207
+ case 'handoff':
208
+ parts.push(`«${orchLabel} - Handoff»`);
209
+ break;
210
+ case 'human':
211
+ parts.push(`«Human Interjection»`);
212
+ break;
213
+ }
214
+ parts.push(triggerMessage);
215
+ return parts.join('\n');
216
+ }
217
+ /**
218
+ * Format a timeout message for the Arbiter
219
+ */
220
+ function formatTimeoutForArbiter(queue, orchNumber, idleMinutes) {
221
+ const orchLabel = `Orchestrator ${toRoman(orchNumber)}`;
222
+ const parts = [];
223
+ // Add work log if there are queued messages
224
+ if (queue.length > 0) {
225
+ parts.push(`«${orchLabel} - Work Log (no response needed)»`);
226
+ for (const msg of queue) {
227
+ parts.push(`• ${msg}`);
228
+ }
229
+ parts.push('');
230
+ }
231
+ // Add timeout notice
232
+ parts.push(`«${orchLabel} - TIMEOUT»`);
233
+ parts.push(`No activity for ${idleMinutes} minutes. Session terminated.`);
234
+ parts.push(`The Orchestrator may have encountered an error or become stuck.`);
235
+ return parts.join('\n');
236
+ }
237
+ /**
238
+ * Router class - Core component managing sessions and routing messages
239
+ *
240
+ * The router manages the Arbiter and Orchestrator sessions, routing messages
241
+ * between them based on the current mode. It also tracks tool usage and
242
+ * context percentages for display in the TUI.
243
+ */
244
+ export class Router {
245
+ state;
246
+ callbacks;
247
+ // Session state
248
+ arbiterQuery = null;
249
+ // Orchestrator session - bundles all orchestrator-related state
250
+ currentOrchestratorSession = null;
251
+ // Track orchestrator count for numbering (I, II, III...)
252
+ orchestratorCount = 0;
253
+ // Track Arbiter tool calls
254
+ arbiterToolCallCount = 0;
255
+ // Pending orchestrator spawn flag
256
+ pendingOrchestratorSpawn = false;
257
+ pendingOrchestratorNumber = 0;
258
+ // Abort controllers for graceful shutdown
259
+ arbiterAbortController = null;
260
+ // Watchdog timer for orchestrator inactivity detection
261
+ watchdogInterval = null;
262
+ // Store MCP server for Arbiter session resumption
263
+ arbiterMcpServer = null;
264
+ // Store Arbiter hooks for session resumption
265
+ arbiterHooks = null;
266
+ // Context polling timer - polls /context once per minute via session forking
267
+ contextPollInterval = null;
268
+ // Track crash recovery attempts for TUI display
269
+ crashCount = 0;
270
+ constructor(state, callbacks) {
271
+ this.state = state;
272
+ this.callbacks = callbacks;
273
+ }
274
+ /**
275
+ * Start the router - initializes the Arbiter session
276
+ */
277
+ async start() {
278
+ await this.startArbiterSession();
279
+ this.startContextPolling();
280
+ }
281
+ /**
282
+ * Start the context polling timer
283
+ * Polls context for both Arbiter and Orchestrator (if active) once per minute
284
+ */
285
+ startContextPolling() {
286
+ // Clear any existing interval
287
+ if (this.contextPollInterval) {
288
+ clearInterval(this.contextPollInterval);
289
+ }
290
+ // Poll immediately on start, then every minute
291
+ this.pollAllContexts();
292
+ this.contextPollInterval = setInterval(() => {
293
+ this.pollAllContexts();
294
+ }, CONTEXT_POLL_INTERVAL_MS);
295
+ }
296
+ /**
297
+ * Poll context for all active sessions
298
+ * Forks sessions and runs /context to get accurate values
299
+ */
300
+ async pollAllContexts() {
301
+ // Poll Arbiter context
302
+ if (this.state.arbiterSessionId) {
303
+ const arbiterPercent = await pollContextForSession(this.state.arbiterSessionId);
304
+ if (arbiterPercent !== null) {
305
+ updateArbiterContext(this.state, arbiterPercent);
306
+ this.callbacks.onDebugLog?.({
307
+ type: 'system',
308
+ text: `Context poll: Arbiter at ${arbiterPercent}%`,
309
+ agent: 'arbiter',
310
+ });
311
+ }
312
+ }
313
+ // Poll Orchestrator context if active
314
+ let orchPercent = null;
315
+ if (this.currentOrchestratorSession?.sessionId) {
316
+ orchPercent = await pollContextForSession(this.currentOrchestratorSession.sessionId);
317
+ if (orchPercent !== null) {
318
+ updateOrchestratorContext(this.state, orchPercent);
319
+ this.callbacks.onDebugLog?.({
320
+ type: 'system',
321
+ text: `Context poll: Orchestrator at ${orchPercent}%`,
322
+ agent: 'orchestrator',
323
+ });
324
+ }
325
+ }
326
+ // Notify TUI with updated values from state
327
+ this.callbacks.onContextUpdate(this.state.arbiterContextPercent, this.state.currentOrchestrator?.contextPercent ?? null);
328
+ }
329
+ /**
330
+ * Resume from a previously saved session
331
+ */
332
+ async resumeFromSavedSession(saved) {
333
+ // Set arbiter session ID so startArbiterSession uses resume
334
+ this.state.arbiterSessionId = saved.arbiterSessionId;
335
+ // Start arbiter (it will use the session ID for resume)
336
+ await this.startArbiterSession();
337
+ // If there was an active orchestrator, resume it too
338
+ if (saved.orchestratorSessionId && saved.orchestratorNumber) {
339
+ await this.resumeOrchestratorSession(saved.orchestratorSessionId, saved.orchestratorNumber);
340
+ }
341
+ // Start context polling
342
+ this.startContextPolling();
343
+ }
344
+ /**
345
+ * Send a human message to the system
346
+ * Routes based on current mode:
347
+ * - human_to_arbiter: Send directly to Arbiter
348
+ * - arbiter_to_orchestrator: Flush queue with human interjection framing
349
+ */
350
+ async sendHumanMessage(text) {
351
+ // Log the human message and notify TUI immediately
352
+ addMessage(this.state, 'human', text);
353
+ this.callbacks.onHumanMessage(text);
354
+ if (this.state.mode === 'arbiter_to_orchestrator' && this.currentOrchestratorSession) {
355
+ const session = this.currentOrchestratorSession;
356
+ // Human interjection during orchestrator work - flush queue with context
357
+ const formattedMessage = formatQueueForArbiter(session.queue, text, 'human', session.number);
358
+ // Log the flush for debugging
359
+ this.callbacks.onDebugLog?.({
360
+ type: 'system',
361
+ text: `Human interjection - flushing ${session.queue.length} queued messages`,
362
+ details: { queueLength: session.queue.length },
363
+ });
364
+ // Clear the queue
365
+ session.queue = [];
366
+ // Send formatted message to Arbiter
367
+ await this.sendToArbiter(formattedMessage);
368
+ }
369
+ else {
370
+ // Direct to Arbiter (no orchestrator active or in human_to_arbiter mode)
371
+ await this.sendToArbiter(text);
372
+ }
373
+ }
374
+ /**
375
+ * Clean shutdown of all sessions
376
+ */
377
+ async stop() {
378
+ // Stop context polling timer
379
+ if (this.contextPollInterval) {
380
+ clearInterval(this.contextPollInterval);
381
+ this.contextPollInterval = null;
382
+ }
383
+ // Stop watchdog timer
384
+ this.stopWatchdog();
385
+ // Abort any running queries
386
+ if (this.arbiterAbortController) {
387
+ this.arbiterAbortController.abort();
388
+ this.arbiterAbortController = null;
389
+ }
390
+ // Clean up orchestrator using the unified method
391
+ this.cleanupOrchestrator();
392
+ this.arbiterQuery = null;
393
+ }
394
+ /**
395
+ * Clean up the current orchestrator session
396
+ * Called when: spawning new orchestrator, disconnect, timeout, shutdown
397
+ */
398
+ cleanupOrchestrator() {
399
+ // Stop watchdog timer
400
+ this.stopWatchdog();
401
+ if (!this.currentOrchestratorSession)
402
+ return;
403
+ const session = this.currentOrchestratorSession;
404
+ const orchLabel = `Orchestrator ${toRoman(session.number)}`;
405
+ // 1. Log any orphaned queue messages (for debugging)
406
+ if (session.queue.length > 0) {
407
+ this.callbacks.onDebugLog?.({
408
+ type: 'system',
409
+ text: `${orchLabel} released with ${session.queue.length} undelivered messages`,
410
+ details: { queuedMessages: session.queue },
411
+ });
412
+ }
413
+ // 2. Abort the SDK session
414
+ session.abortController.abort();
415
+ // 3. Clear the working indicator (will be added later, safe to call now)
416
+ // this.callbacks.onWorkingIndicator?.('orchestrator', null);
417
+ // 4. Null out the session
418
+ this.currentOrchestratorSession = null;
419
+ // 5. Update shared state for TUI
420
+ clearCurrentOrchestrator(this.state);
421
+ // 6. Reset mode
422
+ setMode(this.state, 'human_to_arbiter');
423
+ this.callbacks.onModeChange('human_to_arbiter');
424
+ // 7. Update context display (no orchestrator)
425
+ this.callbacks.onContextUpdate(this.state.arbiterContextPercent, null);
426
+ // 8. Notify TUI about orchestrator disconnect (for tile scene)
427
+ this.callbacks.onOrchestratorDisconnect?.();
428
+ }
429
+ /**
430
+ * Start the watchdog timer for orchestrator inactivity detection
431
+ */
432
+ startWatchdog() {
433
+ // Clear any existing watchdog
434
+ this.stopWatchdog();
435
+ // Check every 30 seconds
436
+ this.watchdogInterval = setInterval(() => {
437
+ if (!this.currentOrchestratorSession)
438
+ return;
439
+ const idleMs = Date.now() - this.currentOrchestratorSession.lastActivityTime;
440
+ const idleMinutes = Math.floor(idleMs / 60000);
441
+ // 10 minute timeout
442
+ if (idleMinutes >= 10) {
443
+ this.handleOrchestratorTimeout(idleMinutes);
444
+ }
445
+ }, 30000);
446
+ }
447
+ /**
448
+ * Stop the watchdog timer
449
+ */
450
+ stopWatchdog() {
451
+ if (this.watchdogInterval) {
452
+ clearInterval(this.watchdogInterval);
453
+ this.watchdogInterval = null;
454
+ }
455
+ }
456
+ /**
457
+ * Handle orchestrator timeout - notify Arbiter and cleanup
458
+ */
459
+ async handleOrchestratorTimeout(idleMinutes) {
460
+ if (!this.currentOrchestratorSession)
461
+ return;
462
+ const session = this.currentOrchestratorSession;
463
+ // Log the timeout
464
+ this.callbacks.onDebugLog?.({
465
+ type: 'system',
466
+ text: `Orchestrator ${toRoman(session.number)} timed out after ${idleMinutes} minutes of inactivity`,
467
+ });
468
+ // Format timeout message for Arbiter
469
+ const timeoutMessage = formatTimeoutForArbiter(session.queue, session.number, idleMinutes);
470
+ // Cleanup the orchestrator (this also clears the queue)
471
+ this.cleanupOrchestrator();
472
+ // Stop the watchdog
473
+ this.stopWatchdog();
474
+ // Notify Arbiter about the timeout
475
+ await this.sendToArbiter(timeoutMessage);
476
+ }
477
+ // ============================================
478
+ // Private helper methods
479
+ // ============================================
480
+ /**
481
+ * Creates options for Arbiter queries
482
+ * Centralizes all Arbiter-specific options to avoid duplication
483
+ */
484
+ createArbiterOptions(resumeSessionId) {
485
+ return {
486
+ systemPrompt: ARBITER_SYSTEM_PROMPT,
487
+ mcpServers: this.arbiterMcpServer ? { 'arbiter-tools': this.arbiterMcpServer } : undefined,
488
+ hooks: this.arbiterHooks ?? undefined,
489
+ abortController: this.arbiterAbortController ?? new AbortController(),
490
+ permissionMode: 'bypassPermissions',
491
+ allowedTools: [...ARBITER_ALLOWED_TOOLS],
492
+ ...(resumeSessionId ? { resume: resumeSessionId } : {}),
493
+ };
494
+ }
495
+ /**
496
+ * Creates options for Orchestrator queries
497
+ * Centralizes all Orchestrator-specific options to avoid duplication
498
+ * @param hooks - Hooks object (from session or newly created)
499
+ * @param abortController - AbortController (from session or newly created)
500
+ * @param resumeSessionId - Optional session ID for resuming
501
+ */
502
+ createOrchestratorOptions(hooks, abortController, resumeSessionId) {
503
+ return {
504
+ systemPrompt: ORCHESTRATOR_SYSTEM_PROMPT,
505
+ hooks,
506
+ abortController,
507
+ permissionMode: 'bypassPermissions',
508
+ allowedTools: [...ORCHESTRATOR_ALLOWED_TOOLS],
509
+ outputFormat: {
510
+ type: 'json_schema',
511
+ schema: orchestratorOutputJsonSchema,
512
+ },
513
+ ...(resumeSessionId ? { resume: resumeSessionId } : {}),
514
+ };
515
+ }
516
+ /**
517
+ * Creates and starts the Arbiter session with MCP tools
518
+ */
519
+ async startArbiterSession() {
520
+ // Notify that we're waiting for Arbiter
521
+ this.callbacks.onWaitingStart?.('arbiter');
522
+ // Create abort controller for this session
523
+ this.arbiterAbortController = new AbortController();
524
+ // Create callbacks for MCP tools
525
+ const arbiterCallbacks = {
526
+ onSpawnOrchestrator: (orchestratorNumber) => {
527
+ // Store the number to spawn after current processing
528
+ this.pendingOrchestratorSpawn = true;
529
+ this.pendingOrchestratorNumber = orchestratorNumber;
530
+ },
531
+ onDisconnectOrchestrators: () => {
532
+ this.cleanupOrchestrator();
533
+ },
534
+ };
535
+ // Create callbacks for hooks (tool tracking)
536
+ const arbiterHooksCallbacks = {
537
+ onToolUse: (tool) => {
538
+ this.arbiterToolCallCount++;
539
+ this.callbacks.onToolUse(tool, this.arbiterToolCallCount);
540
+ // Log tool use to debug
541
+ this.callbacks.onDebugLog?.({
542
+ type: 'tool',
543
+ agent: 'arbiter',
544
+ speaker: 'Arbiter',
545
+ text: tool,
546
+ details: { tool, count: this.arbiterToolCallCount },
547
+ });
548
+ },
549
+ };
550
+ // Create MCP server with Arbiter tools
551
+ const mcpServer = createArbiterMcpServer(arbiterCallbacks, () => this.orchestratorCount);
552
+ this.arbiterMcpServer = mcpServer;
553
+ // Create hooks for tool tracking
554
+ const hooks = createArbiterHooks(arbiterHooksCallbacks);
555
+ this.arbiterHooks = hooks;
556
+ // Create options using helper
557
+ const options = this.createArbiterOptions(this.state.arbiterSessionId ?? undefined);
558
+ // Note: The Arbiter session runs continuously.
559
+ // We'll send messages to it and process responses in a loop.
560
+ // Create initial prompt - reference requirements file if provided
561
+ const initialPrompt = this.state.requirementsPath
562
+ ? `@${this.state.requirementsPath}
563
+
564
+ A Scroll of Requirements has been presented.
565
+
566
+ Your task now is to achieve COMPLETE UNDERSTANDING before any work begins. This is the most critical phase. Follow your system prompt's Phase 1 protocol rigorously:
567
+
568
+ 1. **STUDY THE SCROLL** - Read every word. Understand the intent, not just the surface requirements.
569
+
570
+ 2. **INVESTIGATE THE CODEBASE** - Use your read tools extensively. Understand the current state, architecture, patterns, and constraints. See what exists. See what's missing.
571
+
572
+ 3. **IDENTIFY GAPS AND AMBIGUITIES** - What's unclear? What assumptions are being made? What edge cases aren't addressed? What could go wrong?
573
+
574
+ 4. **ASK CLARIFYING QUESTIONS** - Do not proceed with partial understanding. Ask everything you need to know. Resolve ALL ambiguity NOW, before any Orchestrator is summoned.
575
+
576
+ 5. **STATE BACK YOUR FULL UNDERSTANDING** - Once you've investigated and asked your questions, articulate back to me: What exactly will be built? What approach will be taken? What are the success criteria? What are the risks?
577
+
578
+ Only when we have achieved 100% alignment on vision, scope, and approach - only when you could explain this task to an Orchestrator with complete confidence - only then do we proceed.
579
+
580
+ Take your time. This phase determines everything that follows.`
581
+ : 'Speak, mortal.';
582
+ this.arbiterQuery = query({
583
+ prompt: initialPrompt,
584
+ options,
585
+ });
586
+ // Process the initial response
587
+ await this.processArbiterMessages(this.arbiterQuery);
588
+ }
589
+ /**
590
+ * Creates and starts an Orchestrator session
591
+ */
592
+ async startOrchestratorSession(number) {
593
+ // Clean up any existing orchestrator before spawning new one (hard abort)
594
+ this.cleanupOrchestrator();
595
+ // Notify that we're waiting for Orchestrator
596
+ this.callbacks.onWaitingStart?.('orchestrator');
597
+ // Increment orchestrator count
598
+ this.orchestratorCount = number;
599
+ // Generate unique ID for this orchestrator
600
+ const orchId = `orch-${Date.now()}`;
601
+ // Create abort controller for this session
602
+ const abortController = new AbortController();
603
+ // Create callbacks for hooks
604
+ const orchestratorCallbacks = {
605
+ onContextUpdate: (_sessionId, _percent) => {
606
+ // Context is now tracked via periodic polling (pollAllContexts)
607
+ // This callback exists for hook compatibility but is unused
608
+ },
609
+ onToolUse: (tool) => {
610
+ // Increment tool count on the session
611
+ if (this.currentOrchestratorSession) {
612
+ this.currentOrchestratorSession.toolCallCount++;
613
+ const newCount = this.currentOrchestratorSession.toolCallCount;
614
+ // Update state and notify callback
615
+ updateOrchestratorTool(this.state, tool, newCount);
616
+ this.callbacks.onToolUse(tool, newCount);
617
+ // Log tool use to debug (logbook) with orchestrator context
618
+ const conjuringLabel = `Conjuring ${toRoman(number)}`;
619
+ this.callbacks.onDebugLog?.({
620
+ type: 'tool',
621
+ speaker: conjuringLabel,
622
+ text: `[Tool] ${tool}`,
623
+ details: { tool, count: newCount },
624
+ });
625
+ }
626
+ },
627
+ };
628
+ // Create hooks for tool use tracking
629
+ // Context is now tracked via polling, not hooks
630
+ const hooks = createOrchestratorHooks(orchestratorCallbacks,
631
+ // Context percent getter - returns state value (updated by polling)
632
+ (_sessionId) => this.state.currentOrchestrator?.contextPercent || 0);
633
+ // Create options using helper (no resume for initial session)
634
+ const options = this.createOrchestratorOptions(hooks, abortController);
635
+ // Create the orchestrator query
636
+ const orchestratorQuery = query({
637
+ prompt: 'Introduce yourself and await instructions from the Arbiter.',
638
+ options,
639
+ });
640
+ // Create the full OrchestratorSession object
641
+ this.currentOrchestratorSession = {
642
+ id: orchId,
643
+ number,
644
+ sessionId: '', // Will be set when we get the init message
645
+ query: orchestratorQuery,
646
+ abortController,
647
+ toolCallCount: 0,
648
+ queue: [],
649
+ lastActivityTime: Date.now(),
650
+ hooks,
651
+ };
652
+ // Set up TUI-facing orchestrator state before processing
653
+ // We'll update the session ID when we get the init message
654
+ setCurrentOrchestrator(this.state, {
655
+ id: orchId,
656
+ sessionId: '', // Will be set when we get the init message
657
+ number,
658
+ });
659
+ // Switch mode
660
+ setMode(this.state, 'arbiter_to_orchestrator');
661
+ this.callbacks.onModeChange('arbiter_to_orchestrator');
662
+ // Notify about orchestrator spawn (for tile scene demon spawning)
663
+ this.callbacks.onOrchestratorSpawn?.(number);
664
+ // Update context display to show orchestrator (initially at 0%)
665
+ this.callbacks.onContextUpdate(this.state.arbiterContextPercent, this.state.currentOrchestrator?.contextPercent ?? null);
666
+ // Start watchdog timer
667
+ this.startWatchdog();
668
+ // Process orchestrator messages
669
+ await this.processOrchestratorMessages(this.currentOrchestratorSession.query);
670
+ }
671
+ /**
672
+ * Resume an existing Orchestrator session
673
+ * Similar to startOrchestratorSession but uses resume option and skips introduction
674
+ */
675
+ async resumeOrchestratorSession(sessionId, number) {
676
+ // Clean up any existing orchestrator before resuming
677
+ this.cleanupOrchestrator();
678
+ // Notify that we're waiting for Orchestrator
679
+ this.callbacks.onWaitingStart?.('orchestrator');
680
+ // Restore orchestrator count
681
+ this.orchestratorCount = number;
682
+ // Generate unique ID for this orchestrator
683
+ const orchId = `orch-${Date.now()}`;
684
+ // Create abort controller for this session
685
+ const abortController = new AbortController();
686
+ // Create callbacks for hooks
687
+ const orchestratorCallbacks = {
688
+ onContextUpdate: (_sessionId, _percent) => {
689
+ // Context is now tracked via periodic polling (pollAllContexts)
690
+ // This callback exists for hook compatibility but is unused
691
+ },
692
+ onToolUse: (tool) => {
693
+ // Increment tool count on the session
694
+ if (this.currentOrchestratorSession) {
695
+ this.currentOrchestratorSession.toolCallCount++;
696
+ const newCount = this.currentOrchestratorSession.toolCallCount;
697
+ // Update state and notify callback
698
+ updateOrchestratorTool(this.state, tool, newCount);
699
+ this.callbacks.onToolUse(tool, newCount);
700
+ // Log tool use to debug (logbook) with orchestrator context
701
+ const conjuringLabel = `Conjuring ${toRoman(number)}`;
702
+ this.callbacks.onDebugLog?.({
703
+ type: 'tool',
704
+ speaker: conjuringLabel,
705
+ text: `[Tool] ${tool}`,
706
+ details: { tool, count: newCount },
707
+ });
708
+ }
709
+ },
710
+ };
711
+ // Create hooks for tool use tracking
712
+ // Context is now tracked via polling, not hooks
713
+ const hooks = createOrchestratorHooks(orchestratorCallbacks,
714
+ // Context percent getter - returns state value (updated by polling)
715
+ (_sessionId) => this.state.currentOrchestrator?.contextPercent || 0);
716
+ // Create options using helper with resume
717
+ const options = this.createOrchestratorOptions(hooks, abortController, sessionId);
718
+ // Create the orchestrator query with a continuation prompt (not introduction)
719
+ const orchestratorQuery = query({
720
+ prompt: '[System: Session resumed. Continue where you left off.]',
721
+ options,
722
+ });
723
+ // Create the full OrchestratorSession object
724
+ // Note: sessionId is already known from the saved session
725
+ this.currentOrchestratorSession = {
726
+ id: orchId,
727
+ number,
728
+ sessionId: sessionId, // Already known, don't set to empty string
729
+ query: orchestratorQuery,
730
+ abortController,
731
+ toolCallCount: 0,
732
+ queue: [],
733
+ lastActivityTime: Date.now(),
734
+ hooks,
735
+ };
736
+ // Set up TUI-facing orchestrator state
737
+ setCurrentOrchestrator(this.state, {
738
+ id: orchId,
739
+ sessionId: sessionId,
740
+ number,
741
+ });
742
+ // Switch mode
743
+ setMode(this.state, 'arbiter_to_orchestrator');
744
+ this.callbacks.onModeChange('arbiter_to_orchestrator');
745
+ // Notify about orchestrator spawn (for tile scene demon spawning)
746
+ this.callbacks.onOrchestratorSpawn?.(number);
747
+ // Update context display to show orchestrator (initially at 0%)
748
+ this.callbacks.onContextUpdate(this.state.arbiterContextPercent, this.state.currentOrchestrator?.contextPercent ?? null);
749
+ // Start watchdog timer
750
+ this.startWatchdog();
751
+ // Process orchestrator messages
752
+ await this.processOrchestratorMessages(this.currentOrchestratorSession.query);
753
+ }
754
+ /**
755
+ * Send a message to the Arbiter
756
+ */
757
+ async sendToArbiter(text) {
758
+ if (!this.arbiterQuery) {
759
+ console.error('Arbiter session not started');
760
+ return;
761
+ }
762
+ // Notify that we're waiting for Arbiter
763
+ this.callbacks.onWaitingStart?.('arbiter');
764
+ // Create a new query to continue the conversation
765
+ const options = this.createArbiterOptions(this.state.arbiterSessionId ?? undefined);
766
+ this.arbiterQuery = query({
767
+ prompt: text,
768
+ options,
769
+ });
770
+ await this.processArbiterMessages(this.arbiterQuery);
771
+ }
772
+ /**
773
+ * Send a message to the current Orchestrator
774
+ */
775
+ async sendToOrchestrator(text) {
776
+ if (!this.currentOrchestratorSession) {
777
+ console.error('No active orchestrator session');
778
+ return;
779
+ }
780
+ // Notify that we're waiting for Orchestrator
781
+ this.callbacks.onWaitingStart?.('orchestrator');
782
+ // Create a new query to continue the conversation
783
+ const options = this.createOrchestratorOptions(this.currentOrchestratorSession.hooks, this.currentOrchestratorSession.abortController, this.currentOrchestratorSession.sessionId);
784
+ const newQuery = query({
785
+ prompt: text,
786
+ options,
787
+ });
788
+ // Update the session's query
789
+ this.currentOrchestratorSession.query = newQuery;
790
+ await this.processOrchestratorMessages(newQuery);
791
+ }
792
+ /**
793
+ * Handle Arbiter output based on mode
794
+ * In arbiter_to_orchestrator mode, forward to Orchestrator
795
+ * In human_to_arbiter mode, display to human
796
+ */
797
+ async handleArbiterOutput(text) {
798
+ // Log the message (always, for history/debug)
799
+ addMessage(this.state, 'arbiter', text);
800
+ // Log to debug (logbook)
801
+ this.callbacks.onDebugLog?.({
802
+ type: 'message',
803
+ speaker: 'arbiter',
804
+ text,
805
+ });
806
+ this.callbacks.onArbiterMessage(text);
807
+ // If we're in orchestrator mode, forward to the orchestrator
808
+ if (this.state.mode === 'arbiter_to_orchestrator' && this.state.currentOrchestrator) {
809
+ await this.sendToOrchestrator(text);
810
+ }
811
+ }
812
+ /**
813
+ * Handle Orchestrator output - route based on expects_response field
814
+ * expects_response: true → forward to Arbiter (questions, introductions, handoffs)
815
+ * expects_response: false → queue for later (status updates during work)
816
+ */
817
+ async handleOrchestratorOutput(output) {
818
+ if (!this.currentOrchestratorSession) {
819
+ console.error('No active orchestrator for output');
820
+ return;
821
+ }
822
+ const session = this.currentOrchestratorSession;
823
+ const orchNumber = session.number;
824
+ const orchLabel = `Orchestrator ${toRoman(orchNumber)}`;
825
+ const conjuringLabel = `Conjuring ${toRoman(orchNumber)}`;
826
+ const { expects_response, message } = output;
827
+ // Log the message
828
+ addMessage(this.state, orchLabel, message);
829
+ // Log to debug (logbook)
830
+ this.callbacks.onDebugLog?.({
831
+ type: 'message',
832
+ speaker: conjuringLabel,
833
+ text: message,
834
+ details: { expects_response },
835
+ });
836
+ // Notify callback for TUI display
837
+ this.callbacks.onOrchestratorMessage(orchNumber, message);
838
+ // Update activity timestamp for watchdog
839
+ session.lastActivityTime = Date.now();
840
+ if (expects_response) {
841
+ // Forward to Arbiter - determine if this looks like a handoff
842
+ const isHandoff = /^HANDOFF\b/i.test(message.trim());
843
+ const triggerType = isHandoff ? 'handoff' : 'input';
844
+ // Format the queue + message for Arbiter
845
+ const formattedMessage = formatQueueForArbiter(session.queue, message, triggerType, orchNumber);
846
+ // Log the flush for debugging
847
+ this.callbacks.onDebugLog?.({
848
+ type: 'system',
849
+ text: `Forwarding to Arbiter (${triggerType}) with ${session.queue.length} queued messages`,
850
+ details: { queueLength: session.queue.length, triggerType, expects_response },
851
+ });
852
+ // Clear the queue
853
+ session.queue = [];
854
+ // Send formatted message to Arbiter
855
+ await this.sendToArbiter(formattedMessage);
856
+ }
857
+ else {
858
+ // Queue the message for later
859
+ session.queue.push(message);
860
+ // Log the queue action for debugging
861
+ this.callbacks.onDebugLog?.({
862
+ type: 'system',
863
+ text: `Queued message (${session.queue.length} total)`,
864
+ details: { expects_response },
865
+ });
866
+ }
867
+ }
868
+ /**
869
+ * Process messages from the Arbiter session with retry logic for crash recovery
870
+ */
871
+ async processArbiterMessages(generator) {
872
+ let retries = 0;
873
+ let currentGenerator = generator;
874
+ try {
875
+ while (true) {
876
+ try {
877
+ for await (const message of currentGenerator) {
878
+ // Reset retries on each successful message
879
+ retries = 0;
880
+ await this.handleArbiterMessage(message);
881
+ }
882
+ // Successfully finished processing
883
+ break;
884
+ }
885
+ catch (error) {
886
+ if (error?.name === 'AbortError') {
887
+ // Silently ignore - this is expected during shutdown
888
+ return;
889
+ }
890
+ // Increment crash count and notify TUI
891
+ this.crashCount++;
892
+ this.callbacks.onCrashCountUpdate?.(this.crashCount);
893
+ // Log the error
894
+ this.callbacks.onDebugLog?.({
895
+ type: 'system',
896
+ text: `Arbiter crash #${this.crashCount}, retry ${retries + 1}/${MAX_RETRIES}`,
897
+ details: { error: error?.message || String(error) },
898
+ });
899
+ // Check if we've exceeded max retries
900
+ if (retries >= MAX_RETRIES) {
901
+ throw error;
902
+ }
903
+ // Wait before retrying with exponential backoff
904
+ await sleep(RETRY_DELAYS[retries]);
905
+ retries++;
906
+ // Create a new resume query
907
+ const options = this.createArbiterOptions(this.state.arbiterSessionId ?? undefined);
908
+ currentGenerator = query({
909
+ prompt: '[System: Session resumed after error. Continue where you left off.]',
910
+ options,
911
+ });
912
+ this.arbiterQuery = currentGenerator;
913
+ }
914
+ }
915
+ // Stop waiting animation after Arbiter response is complete
916
+ this.callbacks.onWaitingStop?.();
917
+ // Check if we need to spawn an orchestrator
918
+ if (this.pendingOrchestratorSpawn) {
919
+ const number = this.pendingOrchestratorNumber;
920
+ this.pendingOrchestratorSpawn = false;
921
+ this.pendingOrchestratorNumber = 0;
922
+ await this.startOrchestratorSession(number);
923
+ }
924
+ }
925
+ catch (error) {
926
+ console.error('Error processing Arbiter messages:', error);
927
+ throw error;
928
+ }
929
+ }
930
+ /**
931
+ * Handle a single message from the Arbiter
932
+ */
933
+ async handleArbiterMessage(message) {
934
+ // Log ALL raw SDK messages for debug
935
+ this.callbacks.onDebugLog?.({
936
+ type: 'sdk',
937
+ agent: 'arbiter',
938
+ messageType: message.type,
939
+ sessionId: this.state.arbiterSessionId ?? undefined,
940
+ text: formatSdkMessage(message),
941
+ details: message,
942
+ });
943
+ switch (message.type) {
944
+ case 'system':
945
+ if (message.subtype === 'init') {
946
+ // Capture session ID
947
+ this.state.arbiterSessionId = message.session_id;
948
+ // Save session for crash recovery
949
+ saveSession(message.session_id, this.currentOrchestratorSession?.sessionId ?? null, this.currentOrchestratorSession?.number ?? null);
950
+ }
951
+ break;
952
+ case 'assistant': {
953
+ // Extract text content from the assistant message
954
+ const assistantMessage = message;
955
+ // Track tool use from Arbiter (MCP tools like spawn_orchestrator)
956
+ this.trackToolUseFromAssistant(assistantMessage, 'arbiter');
957
+ const textContent = this.extractTextFromAssistantMessage(assistantMessage);
958
+ if (textContent) {
959
+ await this.handleArbiterOutput(textContent);
960
+ }
961
+ break;
962
+ }
963
+ case 'result':
964
+ // Result messages logged for debugging
965
+ // Context is tracked via periodic polling, not per-message
966
+ break;
967
+ }
968
+ }
969
+ /**
970
+ * Process messages from an Orchestrator session with retry logic for crash recovery
971
+ */
972
+ async processOrchestratorMessages(generator) {
973
+ let retries = 0;
974
+ let currentGenerator = generator;
975
+ while (true) {
976
+ try {
977
+ for await (const message of currentGenerator) {
978
+ // Reset retries on each successful message
979
+ retries = 0;
980
+ // Update activity time on ANY SDK message (including subagent results)
981
+ // This prevents false timeouts when orchestrator delegates to Task subagents
982
+ if (this.currentOrchestratorSession) {
983
+ this.currentOrchestratorSession.lastActivityTime = Date.now();
984
+ }
985
+ await this.handleOrchestratorMessage(message);
986
+ }
987
+ // Successfully finished processing
988
+ break;
989
+ }
990
+ catch (error) {
991
+ if (error?.name === 'AbortError') {
992
+ // Silently ignore - this is expected during shutdown
993
+ return;
994
+ }
995
+ // Increment crash count and notify TUI
996
+ this.crashCount++;
997
+ this.callbacks.onCrashCountUpdate?.(this.crashCount);
998
+ // Log the error
999
+ this.callbacks.onDebugLog?.({
1000
+ type: 'system',
1001
+ text: `Orchestrator crash #${this.crashCount}, retry ${retries + 1}/${MAX_RETRIES}`,
1002
+ details: { error: error?.message || String(error) },
1003
+ });
1004
+ // Check if we've exceeded max retries
1005
+ if (retries >= MAX_RETRIES) {
1006
+ // Orchestrator can't be resumed - cleanup and return (don't crash the whole app)
1007
+ console.error('Orchestrator exceeded max retries, cleaning up:', error);
1008
+ this.cleanupOrchestrator();
1009
+ return;
1010
+ }
1011
+ // Make sure we still have an active session
1012
+ if (!this.currentOrchestratorSession) {
1013
+ return;
1014
+ }
1015
+ // Wait before retrying with exponential backoff
1016
+ await sleep(RETRY_DELAYS[retries]);
1017
+ retries++;
1018
+ // Create a new resume query
1019
+ const options = this.createOrchestratorOptions(this.currentOrchestratorSession.hooks, this.currentOrchestratorSession.abortController, this.currentOrchestratorSession.sessionId);
1020
+ currentGenerator = query({
1021
+ prompt: '[System: Session resumed after error. Continue where you left off.]',
1022
+ options,
1023
+ });
1024
+ this.currentOrchestratorSession.query = currentGenerator;
1025
+ }
1026
+ }
1027
+ // Stop waiting animation after Orchestrator response is complete
1028
+ this.callbacks.onWaitingStop?.();
1029
+ }
1030
+ /**
1031
+ * Handle a single message from an Orchestrator
1032
+ */
1033
+ async handleOrchestratorMessage(message) {
1034
+ // Log ALL raw SDK messages for debug
1035
+ const orchSessionId = this.currentOrchestratorSession?.sessionId;
1036
+ this.callbacks.onDebugLog?.({
1037
+ type: 'sdk',
1038
+ agent: 'orchestrator',
1039
+ messageType: message.type,
1040
+ sessionId: orchSessionId ?? undefined,
1041
+ text: formatSdkMessage(message),
1042
+ details: message,
1043
+ });
1044
+ switch (message.type) {
1045
+ case 'system':
1046
+ if (message.subtype === 'init') {
1047
+ // Update orchestrator session ID on both the session and TUI state
1048
+ if (this.currentOrchestratorSession) {
1049
+ this.currentOrchestratorSession.sessionId = message.session_id;
1050
+ }
1051
+ if (this.state.currentOrchestrator) {
1052
+ this.state.currentOrchestrator.sessionId = message.session_id;
1053
+ }
1054
+ // Save session for crash recovery
1055
+ saveSession(this.state.arbiterSessionId, message.session_id, this.currentOrchestratorSession?.number ?? null);
1056
+ }
1057
+ break;
1058
+ case 'assistant':
1059
+ // Note: Context is tracked via periodic polling, not per-message
1060
+ // We don't extract text from assistant messages - output comes from structured_output
1061
+ break;
1062
+ case 'result': {
1063
+ // Handle structured output from successful result messages
1064
+ const resultMessage = message;
1065
+ if (resultMessage.subtype === 'success') {
1066
+ const structuredOutput = resultMessage.structured_output;
1067
+ if (structuredOutput) {
1068
+ // Parse and validate the structured output
1069
+ const parsed = OrchestratorOutputSchema.safeParse(structuredOutput);
1070
+ if (parsed.success) {
1071
+ await this.handleOrchestratorOutput(parsed.data);
1072
+ }
1073
+ else {
1074
+ // Log parsing error but don't crash
1075
+ this.callbacks.onDebugLog?.({
1076
+ type: 'system',
1077
+ text: `Failed to parse orchestrator output: ${parsed.error.message}`,
1078
+ details: { structuredOutput, error: parsed.error },
1079
+ });
1080
+ }
1081
+ }
1082
+ }
1083
+ break;
1084
+ }
1085
+ }
1086
+ }
1087
+ /**
1088
+ * Extract text content from an assistant message
1089
+ * The message.message.content can be a string or an array of content blocks
1090
+ */
1091
+ extractTextFromAssistantMessage(message) {
1092
+ const content = message.message.content;
1093
+ // Handle string content
1094
+ if (typeof content === 'string') {
1095
+ return content;
1096
+ }
1097
+ // Handle array of content blocks
1098
+ if (Array.isArray(content)) {
1099
+ const textParts = [];
1100
+ for (const block of content) {
1101
+ if (block.type === 'text') {
1102
+ textParts.push(block.text);
1103
+ }
1104
+ // We ignore tool_use blocks here as they're handled separately
1105
+ }
1106
+ return textParts.length > 0 ? textParts.join('\n') : null;
1107
+ }
1108
+ return null;
1109
+ }
1110
+ /**
1111
+ * Track tool_use blocks from an assistant message
1112
+ * Used for both Arbiter and Orchestrator tool tracking
1113
+ */
1114
+ trackToolUseFromAssistant(message, agent) {
1115
+ const content = message.message.content;
1116
+ if (!Array.isArray(content))
1117
+ return;
1118
+ for (const block of content) {
1119
+ if (block.type === 'tool_use') {
1120
+ const toolBlock = block;
1121
+ const toolName = toolBlock.name;
1122
+ // Log tool use for this agent
1123
+ const speaker = agent === 'arbiter'
1124
+ ? 'Arbiter'
1125
+ : `Conjuring ${toRoman(this.state.currentOrchestrator?.number ?? 1)}`;
1126
+ this.callbacks.onDebugLog?.({
1127
+ type: 'tool',
1128
+ speaker,
1129
+ text: `[Tool] ${toolName}`,
1130
+ details: { tool: toolName, input: toolBlock.input },
1131
+ });
1132
+ }
1133
+ }
1134
+ }
1135
+ }