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.
- package/README.md +41 -0
- package/assets/jerom_16x16.png +0 -0
- package/dist/arbiter.d.ts +43 -0
- package/dist/arbiter.js +486 -0
- package/dist/context-analyzer.d.ts +15 -0
- package/dist/context-analyzer.js +603 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +165 -0
- package/dist/orchestrator.d.ts +31 -0
- package/dist/orchestrator.js +227 -0
- package/dist/router.d.ts +187 -0
- package/dist/router.js +1135 -0
- package/dist/router.test.d.ts +15 -0
- package/dist/router.test.js +95 -0
- package/dist/session-persistence.d.ts +9 -0
- package/dist/session-persistence.js +63 -0
- package/dist/session-persistence.test.d.ts +1 -0
- package/dist/session-persistence.test.js +165 -0
- package/dist/sound.d.ts +31 -0
- package/dist/sound.js +50 -0
- package/dist/state.d.ts +72 -0
- package/dist/state.js +107 -0
- package/dist/state.test.d.ts +1 -0
- package/dist/state.test.js +194 -0
- package/dist/test-headless.d.ts +1 -0
- package/dist/test-headless.js +155 -0
- package/dist/tui/index.d.ts +14 -0
- package/dist/tui/index.js +17 -0
- package/dist/tui/layout.d.ts +30 -0
- package/dist/tui/layout.js +200 -0
- package/dist/tui/render.d.ts +57 -0
- package/dist/tui/render.js +266 -0
- package/dist/tui/scene.d.ts +64 -0
- package/dist/tui/scene.js +366 -0
- package/dist/tui/screens/CharacterSelect-termkit.d.ts +18 -0
- package/dist/tui/screens/CharacterSelect-termkit.js +216 -0
- package/dist/tui/screens/ForestIntro-termkit.d.ts +15 -0
- package/dist/tui/screens/ForestIntro-termkit.js +856 -0
- package/dist/tui/screens/GitignoreCheck-termkit.d.ts +14 -0
- package/dist/tui/screens/GitignoreCheck-termkit.js +185 -0
- package/dist/tui/screens/TitleScreen-termkit.d.ts +14 -0
- package/dist/tui/screens/TitleScreen-termkit.js +132 -0
- package/dist/tui/screens/index.d.ts +9 -0
- package/dist/tui/screens/index.js +10 -0
- package/dist/tui/tileset.d.ts +97 -0
- package/dist/tui/tileset.js +237 -0
- package/dist/tui/tui-termkit.d.ts +34 -0
- package/dist/tui/tui-termkit.js +2602 -0
- package/dist/tui/types.d.ts +41 -0
- package/dist/tui/types.js +4 -0
- 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
|
+
}
|