osborn 0.5.5 → 0.8.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/.claude/skills/playwright-browser/SKILL.md +15 -0
- package/.claude/skills/shadcn/SKILL.md +232 -0
- package/.claude/skills/shadcn/image.png +0 -0
- package/.dockerignore +13 -0
- package/Dockerfile +103 -0
- package/deploy.sh +70 -0
- package/dist/claude-auth.d.ts +60 -0
- package/dist/claude-auth.js +334 -0
- package/dist/claude-llm.d.ts +22 -1
- package/dist/claude-llm.js +392 -115
- package/dist/fast-brain.js +2 -2
- package/dist/index.js +227 -6
- package/dist/pipeline-direct-llm.js +10 -5
- package/dist/pipeline-fastbrain.js +13 -7
- package/dist/prompts.js +141 -67
- package/dist/recall-client.d.ts +33 -0
- package/dist/recall-client.js +101 -0
- package/dist/voice-io.d.ts +6 -2
- package/dist/voice-io.js +17 -4
- package/fly.toml +30 -0
- package/package.json +7 -5
package/dist/claude-llm.js
CHANGED
|
@@ -80,6 +80,49 @@ const RESEARCH_TOOLS = [
|
|
|
80
80
|
'Bash', 'WebSearch', 'WebFetch',
|
|
81
81
|
'LSP', 'Task', 'TodoWrite',
|
|
82
82
|
];
|
|
83
|
+
/**
|
|
84
|
+
* Pushable async iterable — allows pushing SDKUserMessages into a query's
|
|
85
|
+
* streaming input. The query subprocess stays alive between pushes (no JSONL replay).
|
|
86
|
+
*/
|
|
87
|
+
class MessageChannel {
|
|
88
|
+
#queue = [];
|
|
89
|
+
#waiting = null;
|
|
90
|
+
#done = false;
|
|
91
|
+
push(item) {
|
|
92
|
+
if (this.#done)
|
|
93
|
+
return;
|
|
94
|
+
if (this.#waiting) {
|
|
95
|
+
const resolve = this.#waiting;
|
|
96
|
+
this.#waiting = null;
|
|
97
|
+
resolve({ value: item, done: false });
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
this.#queue.push(item);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
close() {
|
|
104
|
+
this.#done = true;
|
|
105
|
+
if (this.#waiting) {
|
|
106
|
+
const resolve = this.#waiting;
|
|
107
|
+
this.#waiting = null;
|
|
108
|
+
resolve({ value: undefined, done: true });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
get closed() { return this.#done; }
|
|
112
|
+
[Symbol.asyncIterator]() {
|
|
113
|
+
return {
|
|
114
|
+
next: () => {
|
|
115
|
+
if (this.#queue.length > 0) {
|
|
116
|
+
return Promise.resolve({ value: this.#queue.shift(), done: false });
|
|
117
|
+
}
|
|
118
|
+
if (this.#done) {
|
|
119
|
+
return Promise.resolve({ value: undefined, done: true });
|
|
120
|
+
}
|
|
121
|
+
return new Promise(resolve => { this.#waiting = resolve; });
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
}
|
|
83
126
|
/**
|
|
84
127
|
* Claude LLM - Wraps Claude Agent SDK for LiveKit
|
|
85
128
|
* Research mode: reads anything, writes only to session workspace
|
|
@@ -96,7 +139,12 @@ export class ClaudeLLM extends llm.LLM {
|
|
|
96
139
|
#latestCheckpoint = null;
|
|
97
140
|
// Pending permission request (for voice approval flow)
|
|
98
141
|
#pendingPermission = null;
|
|
99
|
-
// Persistent session
|
|
142
|
+
// Persistent session — single query() with AsyncIterable<SDKUserMessage> input.
|
|
143
|
+
// Subprocess spawns once on first chat(), stays alive for all subsequent messages.
|
|
144
|
+
// No JSONL replay after the first cold start.
|
|
145
|
+
#persistentQuery = null;
|
|
146
|
+
#messageChannel = null;
|
|
147
|
+
#backgroundConsumerRunning = false;
|
|
100
148
|
// Active queries — multiple can be running (SDK queues them internally).
|
|
101
149
|
// We keep ALL references so interrupt() can stop whatever is currently executing.
|
|
102
150
|
#activeQueries = new Set();
|
|
@@ -225,7 +273,7 @@ export class ClaudeLLM extends llm.LLM {
|
|
|
225
273
|
return 'claude.agent-sdk';
|
|
226
274
|
}
|
|
227
275
|
get model() {
|
|
228
|
-
return this.#opts.model || 'claude-sonnet-4-6';
|
|
276
|
+
return this.#opts.model || 'claude-sonnet-4-6'; // Sonnet orchestrator with named sub-agents
|
|
229
277
|
}
|
|
230
278
|
get sessionId() {
|
|
231
279
|
return this.#sessionId;
|
|
@@ -247,9 +295,10 @@ export class ClaudeLLM extends llm.LLM {
|
|
|
247
295
|
* Clears pending permissions and resets conversation tracking
|
|
248
296
|
*/
|
|
249
297
|
resetForSessionSwitch() {
|
|
298
|
+
// Kill persistent session — new session needs fresh subprocess
|
|
299
|
+
this.closeSession();
|
|
250
300
|
// Clear any pending permission request from previous session
|
|
251
301
|
if (this.#pendingPermission) {
|
|
252
|
-
// Deny the pending permission to clean up
|
|
253
302
|
this.#pendingPermission.resolve({
|
|
254
303
|
behavior: 'deny',
|
|
255
304
|
message: 'Session switched - permission request cancelled',
|
|
@@ -346,11 +395,23 @@ export class ClaudeLLM extends llm.LLM {
|
|
|
346
395
|
* Returns true if interrupted, false if no active query.
|
|
347
396
|
*/
|
|
348
397
|
async interruptQuery() {
|
|
398
|
+
// Prefer persistent query's interrupt() — graceful Esc that keeps subprocess alive
|
|
399
|
+
if (this.#persistentQuery && typeof this.#persistentQuery.interrupt === 'function') {
|
|
400
|
+
try {
|
|
401
|
+
await this.#persistentQuery.interrupt();
|
|
402
|
+
console.log('🛑 Interrupted persistent session (Esc equivalent — subprocess stays alive)');
|
|
403
|
+
return true;
|
|
404
|
+
}
|
|
405
|
+
catch (err) {
|
|
406
|
+
console.error('⚠️ Persistent interrupt failed:', err?.message);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
// Fallback: interrupt any active one-shot queries (realtime mode research)
|
|
349
410
|
if (this.#activeQueries.size === 0)
|
|
350
411
|
return false;
|
|
412
|
+
const queriesToInterrupt = [...this.#activeQueries];
|
|
351
413
|
let interrupted = false;
|
|
352
|
-
|
|
353
|
-
for (const q of this.#activeQueries) {
|
|
414
|
+
for (const q of queriesToInterrupt) {
|
|
354
415
|
if (typeof q.interrupt === 'function') {
|
|
355
416
|
try {
|
|
356
417
|
await q.interrupt();
|
|
@@ -362,7 +423,7 @@ export class ClaudeLLM extends llm.LLM {
|
|
|
362
423
|
}
|
|
363
424
|
}
|
|
364
425
|
if (interrupted) {
|
|
365
|
-
console.log(`🛑 Interrupted ${
|
|
426
|
+
console.log(`🛑 Interrupted ${queriesToInterrupt.length} active query(s) (Esc equivalent)`);
|
|
366
427
|
}
|
|
367
428
|
return interrupted;
|
|
368
429
|
}
|
|
@@ -371,6 +432,9 @@ export class ClaudeLLM extends llm.LLM {
|
|
|
371
432
|
* Kills subprocesses. Next message will spawn new processes.
|
|
372
433
|
*/
|
|
373
434
|
abortQuery() {
|
|
435
|
+
// Kill persistent session first (if alive)
|
|
436
|
+
this.closeSession();
|
|
437
|
+
// Also kill any one-shot queries (realtime research)
|
|
374
438
|
for (const q of this.#activeQueries) {
|
|
375
439
|
try {
|
|
376
440
|
q.return?.();
|
|
@@ -390,7 +454,18 @@ export class ClaudeLLM extends llm.LLM {
|
|
|
390
454
|
console.log('⚠️ No checkpoint available for rewind');
|
|
391
455
|
return false;
|
|
392
456
|
}
|
|
393
|
-
//
|
|
457
|
+
// Prefer persistent query (has the full session context)
|
|
458
|
+
if (this.#persistentQuery && typeof this.#persistentQuery.rewindFiles === 'function') {
|
|
459
|
+
try {
|
|
460
|
+
await this.#persistentQuery.rewindFiles(id);
|
|
461
|
+
console.log(`🔄 Files rewound to checkpoint: ${id.substring(0, 8)}...`);
|
|
462
|
+
return true;
|
|
463
|
+
}
|
|
464
|
+
catch (err) {
|
|
465
|
+
console.error('⚠️ Rewind failed:', err?.message);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
// Fallback: try latest one-shot query
|
|
394
469
|
const queries = [...this.#activeQueries];
|
|
395
470
|
const latest = queries[queries.length - 1];
|
|
396
471
|
if (latest && typeof latest.rewindFiles === 'function') {
|
|
@@ -421,6 +496,159 @@ export class ClaudeLLM extends llm.LLM {
|
|
|
421
496
|
removeActiveQuery(q) {
|
|
422
497
|
this.#activeQueries.delete(q);
|
|
423
498
|
}
|
|
499
|
+
// ============================================================
|
|
500
|
+
// PERSISTENT SESSION — V1 query() with AsyncIterable<SDKUserMessage>
|
|
501
|
+
// Single subprocess per voice session. First chat() does JSONL cold
|
|
502
|
+
// start; subsequent chat() calls push messages to the existing
|
|
503
|
+
// subprocess via the MessageChannel — no JSONL replay.
|
|
504
|
+
// ============================================================
|
|
505
|
+
/** Whether a persistent session is alive and consuming messages */
|
|
506
|
+
hasSession() {
|
|
507
|
+
return this.#persistentQuery !== null && !this.#messageChannel?.closed;
|
|
508
|
+
}
|
|
509
|
+
/**
|
|
510
|
+
* Close the persistent session (kills subprocess).
|
|
511
|
+
* Call on disconnect, session switch, or recovery.
|
|
512
|
+
*/
|
|
513
|
+
closeSession() {
|
|
514
|
+
if (this.#messageChannel) {
|
|
515
|
+
this.#messageChannel.close();
|
|
516
|
+
}
|
|
517
|
+
if (this.#persistentQuery) {
|
|
518
|
+
try {
|
|
519
|
+
this.#persistentQuery.close();
|
|
520
|
+
}
|
|
521
|
+
catch { }
|
|
522
|
+
this.#activeQueries.delete(this.#persistentQuery);
|
|
523
|
+
}
|
|
524
|
+
this.#persistentQuery = null;
|
|
525
|
+
this.#messageChannel = null;
|
|
526
|
+
this.#backgroundConsumerRunning = false;
|
|
527
|
+
console.log('🔒 Persistent session closed');
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* Push a user message into the persistent session.
|
|
531
|
+
* If no session exists yet, creates one (cold start with JSONL replay).
|
|
532
|
+
* If a session exists, instantly delivers the message (no replay).
|
|
533
|
+
*
|
|
534
|
+
* @param userText - The user's message text
|
|
535
|
+
* @param sdkOptions - Full V1 Options (only used on first call to create the query)
|
|
536
|
+
* @param callbacks - Event callbacks for the background consumer
|
|
537
|
+
*/
|
|
538
|
+
pushMessage(userText, sdkOptions, callbacks) {
|
|
539
|
+
const userMessage = {
|
|
540
|
+
type: 'user',
|
|
541
|
+
message: { role: 'user', content: [{ type: 'text', text: userText }] },
|
|
542
|
+
parent_tool_use_id: null,
|
|
543
|
+
session_id: this.#sessionId || '',
|
|
544
|
+
};
|
|
545
|
+
if (this.#persistentQuery && this.#messageChannel && !this.#messageChannel.closed) {
|
|
546
|
+
// Fast path — push to existing subprocess (no cold start)
|
|
547
|
+
console.log('⚡ Persistent session: pushing message (no JSONL replay)');
|
|
548
|
+
this.#messageChannel.push(userMessage);
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
// Cold start — create channel, push first message, start query + background consumer
|
|
552
|
+
console.log('🔄 Persistent session: cold start (first message, JSONL replay)');
|
|
553
|
+
this.#messageChannel = new MessageChannel();
|
|
554
|
+
this.#messageChannel.push(userMessage);
|
|
555
|
+
this.#persistentQuery = query({ prompt: this.#messageChannel, options: sdkOptions });
|
|
556
|
+
this.#activeQueries.add(this.#persistentQuery);
|
|
557
|
+
this.#startBackgroundConsumer(callbacks);
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* Background consumer — runs for the lifetime of the persistent session.
|
|
561
|
+
* Consumes all SDKMessage events from the query and routes them to
|
|
562
|
+
* the event emitter (same events as the old per-query skipTTSQueue path).
|
|
563
|
+
*/
|
|
564
|
+
async #startBackgroundConsumer(callbacks) {
|
|
565
|
+
if (this.#backgroundConsumerRunning)
|
|
566
|
+
return;
|
|
567
|
+
this.#backgroundConsumerRunning = true;
|
|
568
|
+
const pq = this.#persistentQuery;
|
|
569
|
+
try {
|
|
570
|
+
for await (const message of pq) {
|
|
571
|
+
const msg = message;
|
|
572
|
+
// Session ID capture
|
|
573
|
+
if (msg.type === 'system' && msg.subtype === 'init') {
|
|
574
|
+
const mcpServers = msg.mcp_servers;
|
|
575
|
+
if (mcpServers && Array.isArray(mcpServers)) {
|
|
576
|
+
for (const s of mcpServers) {
|
|
577
|
+
const status = s.status === 'connected' ? '✅' : '❌';
|
|
578
|
+
console.log(`${status} MCP server ${s.name}: ${s.status}`);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
const newSessionId = msg.session_id;
|
|
582
|
+
if (newSessionId) {
|
|
583
|
+
callbacks.onSessionId(newSessionId);
|
|
584
|
+
const isNew = !this.#sessionId;
|
|
585
|
+
if (isNew)
|
|
586
|
+
console.log(`📋 New session: ${newSessionId}`);
|
|
587
|
+
this.#sessionId = newSessionId;
|
|
588
|
+
if (isNew && this.#opts.workingDirectory) {
|
|
589
|
+
saveSessionMetadata(this.#opts.workingDirectory, {
|
|
590
|
+
sessionId: newSessionId,
|
|
591
|
+
lastUpdated: new Date().toISOString(),
|
|
592
|
+
projectPath: this.#opts.workingDirectory,
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
const requestedResumeId = this.#opts.resumeSessionId;
|
|
596
|
+
if (requestedResumeId && newSessionId !== requestedResumeId) {
|
|
597
|
+
console.error(`❌ Session resume FAILED: Expected ${requestedResumeId.substring(0, 8)}..., got ${newSessionId.substring(0, 8)}...`);
|
|
598
|
+
callbacks.eventEmitter.emit('session_resume_failed', { requestedSessionId: requestedResumeId, actualSessionId: newSessionId });
|
|
599
|
+
}
|
|
600
|
+
else if (requestedResumeId && newSessionId === requestedResumeId) {
|
|
601
|
+
console.log(`✅ Session resumed successfully: ${newSessionId.substring(0, 8)}...`);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
// Checkpoint capture
|
|
606
|
+
if (msg.type === 'user' && msg.uuid) {
|
|
607
|
+
callbacks.onCheckpoint(msg.uuid);
|
|
608
|
+
}
|
|
609
|
+
// SDK request ID
|
|
610
|
+
if (msg.requestId) {
|
|
611
|
+
callbacks.eventEmitter.emit('query_request_id', { requestId: msg.requestId });
|
|
612
|
+
}
|
|
613
|
+
// Stream assistant text → tts_say events
|
|
614
|
+
if (msg.type === 'assistant' && msg.message?.content) {
|
|
615
|
+
for (const block of msg.message.content) {
|
|
616
|
+
if (block.type === 'text' && block.text) {
|
|
617
|
+
callbacks.eventEmitter.emit('assistant_text', { text: block.text });
|
|
618
|
+
const ttsChunk = stripMarkdownForTTS(block.text);
|
|
619
|
+
if (ttsChunk.trim()) {
|
|
620
|
+
console.log(`🔊 TTS say (${ttsChunk.length} chars): "${ttsChunk.substring(0, 60)}..."`);
|
|
621
|
+
callbacks.eventEmitter.emit('tts_say', { text: ttsChunk });
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
// Result — marks end of a turn (but we keep consuming for next turn)
|
|
627
|
+
if (msg.type === 'result') {
|
|
628
|
+
if (msg.result) {
|
|
629
|
+
callbacks.eventEmitter.emit('assistant_result', { text: msg.result });
|
|
630
|
+
}
|
|
631
|
+
console.log('✅ Claude turn complete (persistent session stays alive)');
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
catch (error) {
|
|
636
|
+
if (error?.message?.includes('aborted') || error?.message?.includes('AbortError')) {
|
|
637
|
+
console.log('🛑 Persistent session query aborted');
|
|
638
|
+
}
|
|
639
|
+
else {
|
|
640
|
+
console.error('❌ Persistent session error:', error);
|
|
641
|
+
callbacks.eventEmitter.emit('tts_say', { text: 'Sorry, I encountered an error.' });
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
finally {
|
|
645
|
+
this.#backgroundConsumerRunning = false;
|
|
646
|
+
this.#activeQueries.delete(pq);
|
|
647
|
+
this.#persistentQuery = null;
|
|
648
|
+
this.#messageChannel = null;
|
|
649
|
+
console.log('🔒 Persistent session background consumer exited');
|
|
650
|
+
}
|
|
651
|
+
}
|
|
424
652
|
chat({ chatCtx, toolCtx, connOptions = DEFAULT_API_CONNECT_OPTIONS, abortController, }) {
|
|
425
653
|
return new ClaudeLLMStream(this, {
|
|
426
654
|
chatCtx,
|
|
@@ -464,6 +692,7 @@ class ClaudeLLMStream extends llm.LLMStream {
|
|
|
464
692
|
#onCheckpoint;
|
|
465
693
|
#abortController;
|
|
466
694
|
#llmRef;
|
|
695
|
+
#approvedWriterToolUseIds = new Set();
|
|
467
696
|
constructor(llmInstance, { chatCtx, toolCtx, connOptions, opts, sessionId, onSessionId, eventEmitter, onCheckpoint, onPermissionRequest, abortController, }) {
|
|
468
697
|
super(llmInstance, { chatCtx, toolCtx, connOptions });
|
|
469
698
|
this.#llmRef = llmInstance;
|
|
@@ -521,7 +750,7 @@ class ClaudeLLMStream extends llm.LLMStream {
|
|
|
521
750
|
cwd: this.#opts.workingDirectory,
|
|
522
751
|
permissionMode: this.#opts.permissionMode,
|
|
523
752
|
allowedTools,
|
|
524
|
-
model: this.#opts.model || 'claude-sonnet-4-6',
|
|
753
|
+
model: this.#opts.model || 'claude-sonnet-4-6', // Sonnet orchestrator with named sub-agents (Haiku tested but ignored delegation rules)
|
|
525
754
|
enableFileCheckpointing: true,
|
|
526
755
|
extraArgs: { 'replay-user-messages': null },
|
|
527
756
|
...(this.#abortController && { abortController: this.#abortController }),
|
|
@@ -540,6 +769,12 @@ class ClaudeLLMStream extends llm.LLMStream {
|
|
|
540
769
|
// Auto-approve writes to session workspace (but block spec.md and library/ — fast brain manages those)
|
|
541
770
|
if (toolName === 'Write' || toolName === 'Edit') {
|
|
542
771
|
const filePath = String(input?.file_path || '');
|
|
772
|
+
const agentType = input?.agent_type || null;
|
|
773
|
+
const toolUseId = _options?.toolUseID;
|
|
774
|
+
const toolInput = input?.tool_input || {};
|
|
775
|
+
console.log('input,', input, 'input.file_path', filePath, 'agent_type', agentType);
|
|
776
|
+
console.log(`🔍 canUseTool: ${toolName} filePath="${filePath}" keys=${Object.keys(input || {}).join(',')}`);
|
|
777
|
+
console.log(`🔍 canUseTool _options keys=[${Object.keys(_options || {}).join(', ')}] title="${_options?.title || ''}" decisionReason="${_options?.decisionReason || ''}" blockedPath="${_options?.blockedPath || ''}"`);
|
|
543
778
|
if (filePath.includes('.osborn/sessions/') || filePath.includes('.osborn/research/')) {
|
|
544
779
|
// Block writes to spec.md and library/ — the fast brain manages these
|
|
545
780
|
const fileName = filePath.split('/').pop() || '';
|
|
@@ -550,6 +785,11 @@ class ClaudeLLMStream extends llm.LLMStream {
|
|
|
550
785
|
console.log(`✅ Auto-approved ${toolName} to workspace: ${filePath}`);
|
|
551
786
|
return { behavior: 'allow', updatedInput: input };
|
|
552
787
|
}
|
|
788
|
+
// if (toolUseId && this.#approvedWriterToolUseIds.has(toolUseId)) {
|
|
789
|
+
// this.#approvedWriterToolUseIds.delete(toolUseId)
|
|
790
|
+
// console.log(`✅ Writer pre-approved ${toolName}: ${filePath}`)
|
|
791
|
+
// return { behavior: 'allow', updatedInput: input }
|
|
792
|
+
// }
|
|
553
793
|
}
|
|
554
794
|
// Auto-approve AskUserQuestion — research agent should freely ask clarifying questions
|
|
555
795
|
if (toolName === 'AskUserQuestion') {
|
|
@@ -570,13 +810,24 @@ class ClaudeLLMStream extends llm.LLMStream {
|
|
|
570
810
|
hooks: [async (input) => {
|
|
571
811
|
const toolName = input?.tool_name || 'unknown';
|
|
572
812
|
const toolInput = input?.tool_input || {};
|
|
573
|
-
|
|
574
|
-
|
|
813
|
+
const agentType = input?.agent_type || null;
|
|
814
|
+
console.log(`🔍 PreToolUse: toolName=${toolName} agent_type=${agentType} agent_id=${input?.agent_id || 'none'} all_keys=[${Object.keys(input || {}).join(', ')}]`);
|
|
815
|
+
// Write/Edit/MultiEdit access control
|
|
816
|
+
if (toolName === 'Write' || toolName === 'Edit' || toolName === 'MultiEdit') {
|
|
817
|
+
// Writer sub-agent gets full write access everywhere
|
|
818
|
+
console.log('verifying agent_type', agentType);
|
|
819
|
+
// Writer agent: no longer auto-approved — falls through to canUseTool for permission dialog
|
|
820
|
+
if (agentType === 'writer') {
|
|
821
|
+
console.log(`✍️ Writer agent: deferring to canUseTool for permission`);
|
|
822
|
+
this.#eventEmitter.emit('tool_use', { name: toolName, input: toolInput });
|
|
823
|
+
return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'ask' } };
|
|
824
|
+
}
|
|
825
|
+
// All other agents (main, researcher, reasoner, etc.): workspace only
|
|
575
826
|
const filePath = String(toolInput.file_path || '');
|
|
576
827
|
if (filePath && !filePath.includes('.osborn/sessions/') && !filePath.includes('.osborn/research/')) {
|
|
577
|
-
console.log(`🚫 Research mode: blocked write to ${filePath}`);
|
|
828
|
+
console.log(`🚫 Research mode: blocked write to ${filePath} (agent_type: ${agentType ?? 'main'})`);
|
|
578
829
|
this.#eventEmitter.emit('tool_blocked', { name: toolName, reason: 'Research mode: writes restricted to session workspace' });
|
|
579
|
-
return {
|
|
830
|
+
return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny' }, reason: 'Research mode: write to .osborn/sessions/ only.' };
|
|
580
831
|
}
|
|
581
832
|
}
|
|
582
833
|
console.log(`🔧 Claude: ${toolName}`);
|
|
@@ -595,7 +846,127 @@ class ClaudeLLMStream extends llm.LLMStream {
|
|
|
595
846
|
return {};
|
|
596
847
|
}]
|
|
597
848
|
}]
|
|
598
|
-
}
|
|
849
|
+
},
|
|
850
|
+
// Named sub-agents — Haiku overseer delegates to these specialists.
|
|
851
|
+
// Each has a specific role, model, and tool set.
|
|
852
|
+
agents: {
|
|
853
|
+
researcher: {
|
|
854
|
+
description: [
|
|
855
|
+
'Information gathering agent (Sonnet). Use for: codebase exploration, web research,',
|
|
856
|
+
'finding patterns, reading multiple files, searching for examples.',
|
|
857
|
+
'Returns structured findings — does NOT make decisions or edit files.',
|
|
858
|
+
'Use this for ANY task that needs more than 2 tool calls to gather information.',
|
|
859
|
+
].join(' '),
|
|
860
|
+
tools: ['Read', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch', 'Task'],
|
|
861
|
+
model: 'sonnet',
|
|
862
|
+
prompt: [
|
|
863
|
+
'You are Osborn\'s research agent. Your job is information gathering — thorough, structured, factual.',
|
|
864
|
+
'',
|
|
865
|
+
'## Your role',
|
|
866
|
+
'Gather information the main agent needs to answer the user\'s question or make a decision.',
|
|
867
|
+
'You are a scout — go find things, read them carefully, and report back.',
|
|
868
|
+
'',
|
|
869
|
+
'## How to work',
|
|
870
|
+
'1. Understand what information is needed and why.',
|
|
871
|
+
'2. Search broadly first (Glob, Grep, WebSearch), then read deeply (Read specific files).',
|
|
872
|
+
'3. For large investigations, use the Task tool to run parallel searches.',
|
|
873
|
+
'4. Cap yourself at 5-8 tool calls unless the task clearly requires more.',
|
|
874
|
+
'',
|
|
875
|
+
'## What to return',
|
|
876
|
+
'Structured findings with specifics:',
|
|
877
|
+
'- File paths and line numbers where you found relevant code',
|
|
878
|
+
'- Exact values, configs, versions — not paraphrases',
|
|
879
|
+
'- Direct quotes from documentation or web sources',
|
|
880
|
+
'- What you looked for but did NOT find (negative results matter)',
|
|
881
|
+
'',
|
|
882
|
+
'## What NOT to do',
|
|
883
|
+
'- Do NOT make recommendations or decisions — just surface facts',
|
|
884
|
+
'- Do NOT edit or write any files',
|
|
885
|
+
'- Do NOT run destructive commands (no rm, no git push, no npm publish)',
|
|
886
|
+
'- If you need clarification, ask the main agent — it will relay to the user if needed',
|
|
887
|
+
].join('\n'),
|
|
888
|
+
},
|
|
889
|
+
reasoner: {
|
|
890
|
+
description: [
|
|
891
|
+
'Deep reasoning agent (Opus). Use for: architecture decisions, complex problem analysis,',
|
|
892
|
+
'tradeoff evaluation, generating implementation plans, understanding hard problems.',
|
|
893
|
+
'Slow but thorough — only use for genuinely complex problems that need careful thought.',
|
|
894
|
+
'Does NOT edit files — returns a clear plan for the writer agent to execute.',
|
|
895
|
+
].join(' '),
|
|
896
|
+
tools: ['Read', 'Glob', 'Grep', 'WebSearch', 'WebFetch'],
|
|
897
|
+
model: 'opus',
|
|
898
|
+
prompt: [
|
|
899
|
+
'You are Osborn\'s reasoning agent. Your job is deep analysis, architectural thinking, and decision-making.',
|
|
900
|
+
'',
|
|
901
|
+
'## Your role',
|
|
902
|
+
'Think hard about complex problems. Consider multiple approaches. Identify risks and edge cases.',
|
|
903
|
+
'Return a clear, opinionated recommendation with reasoning — not just a list of options.',
|
|
904
|
+
'',
|
|
905
|
+
'## How to work',
|
|
906
|
+
'1. Read and understand the full context before forming an opinion.',
|
|
907
|
+
'2. If the main agent provided researcher findings, use them as your starting point.',
|
|
908
|
+
'3. Consider at least 2-3 alternative approaches before recommending one.',
|
|
909
|
+
'4. Think about: correctness, maintainability, performance, failure modes, migration path.',
|
|
910
|
+
'5. Use Read/Grep to verify assumptions against the actual codebase when relevant.',
|
|
911
|
+
'',
|
|
912
|
+
'## What to return',
|
|
913
|
+
'- RECOMMENDATION: what to do (one clear answer, not "it depends")',
|
|
914
|
+
'- REASONING: why this approach wins over alternatives (2-3 sentences)',
|
|
915
|
+
'- PLAN: step-by-step implementation instructions specific enough for the writer agent',
|
|
916
|
+
'- RISKS: what could go wrong and how to mitigate',
|
|
917
|
+
'- If the problem is genuinely ambiguous, say what additional information would resolve it',
|
|
918
|
+
'',
|
|
919
|
+
'## What NOT to do',
|
|
920
|
+
'- Do NOT edit or write files — return a plan for the writer agent',
|
|
921
|
+
'- Do NOT give wishy-washy "both options are valid" non-answers — commit to a recommendation',
|
|
922
|
+
'- If you need more information, ask the main agent to delegate to the researcher',
|
|
923
|
+
].join('\n'),
|
|
924
|
+
},
|
|
925
|
+
writer: {
|
|
926
|
+
description: [
|
|
927
|
+
'Execution agent with file write/edit permissions (Sonnet).',
|
|
928
|
+
'Handles ALL file operations: code, config, docs, scripts, data files.',
|
|
929
|
+
'VERIFY-FIRST workflow: checks assumptions before making changes, runs tests after.',
|
|
930
|
+
'If anything is unclear, asks the main agent for clarification before touching files.',
|
|
931
|
+
].join(' '),
|
|
932
|
+
tools: ['Read', 'Write', 'Edit', 'MultiEdit', 'Bash', 'Glob', 'Grep', 'NotebookRead', 'NotebookEdit'],
|
|
933
|
+
model: 'sonnet',
|
|
934
|
+
prompt: [
|
|
935
|
+
'You are Osborn\'s writer agent. You execute file changes with a verify-first approach.',
|
|
936
|
+
'',
|
|
937
|
+
'## Your role',
|
|
938
|
+
'Handle ALL file operations — code, config, documentation, scripts, data files.',
|
|
939
|
+
'You are the only agent that writes. The main agent and reasoner produce plans; you execute them.',
|
|
940
|
+
'',
|
|
941
|
+
'## VERIFY-FIRST workflow (mandatory)',
|
|
942
|
+
'',
|
|
943
|
+
'### Step 1: Verify assumptions',
|
|
944
|
+
'1. Read the files you\'re about to modify. Confirm they match what the plan expects.',
|
|
945
|
+
'2. If the plan references specific code patterns, grep to confirm they exist.',
|
|
946
|
+
'3. If applicable, run the current test suite or build to confirm the starting state works.',
|
|
947
|
+
'4. If ANYTHING has drifted from the plan (file moved, code refactored, dependency changed):',
|
|
948
|
+
' STOP and report back to the main agent. Do NOT improvise.',
|
|
949
|
+
'',
|
|
950
|
+
'### Step 2: Clarify unknowns',
|
|
951
|
+
'1. If the plan is vague or ambiguous — ask the main agent a specific clarifying question.',
|
|
952
|
+
' Examples: "Which config format — YAML or JSON?", "New file or extend existing auth.ts?"',
|
|
953
|
+
'2. The main agent will answer from context or relay to the user.',
|
|
954
|
+
'3. Do NOT guess. One clear question is better than a wrong assumption.',
|
|
955
|
+
'4. Restate what you will do before doing it: which files, what changes, in what order.',
|
|
956
|
+
'',
|
|
957
|
+
'### Step 3: Execute changes',
|
|
958
|
+
'- Make ONLY the changes described in the plan.',
|
|
959
|
+
'- Do NOT refactor adjacent code, fix unrelated issues, add unrequested comments/docs.',
|
|
960
|
+
'- If you hit an unexpected issue, STOP and report to the main agent.',
|
|
961
|
+
'',
|
|
962
|
+
'### Step 4: Verify results',
|
|
963
|
+
'1. Run tests if available (npm test, pytest, cargo test, etc.).',
|
|
964
|
+
'2. Run the build if applicable (npm run build, tsc --noEmit, etc.).',
|
|
965
|
+
'3. If tests or build fail: attempt to fix the issue you introduced. Re-run.',
|
|
966
|
+
'4. Report: files changed, what changed in each, test results, any failures.',
|
|
967
|
+
].join('\n'),
|
|
968
|
+
},
|
|
969
|
+
},
|
|
599
970
|
};
|
|
600
971
|
// Run Claude Agent SDK query() and stream results
|
|
601
972
|
let hasOutput = false;
|
|
@@ -613,108 +984,14 @@ class ClaudeLLMStream extends llm.LLMStream {
|
|
|
613
984
|
// created by tts_say events get processed by the main loop immediately.
|
|
614
985
|
// The query continues in the background — text arrives via tts_say, tools via hooks.
|
|
615
986
|
if (this.#opts.skipTTSQueue) {
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
const activeQuery = query({ prompt: userText, options: sdkOptions });
|
|
625
|
-
self.#llmRef.setActiveQuery(activeQuery);
|
|
626
|
-
try {
|
|
627
|
-
for await (const message of activeQuery) {
|
|
628
|
-
// Abort check
|
|
629
|
-
if (bgAbortController?.signal.aborted)
|
|
630
|
-
break;
|
|
631
|
-
// Session ID capture (same as synchronous path)
|
|
632
|
-
if (message.type === 'system' && message.subtype === 'init') {
|
|
633
|
-
const mcpServers = message.mcp_servers;
|
|
634
|
-
if (mcpServers && Array.isArray(mcpServers)) {
|
|
635
|
-
for (const s of mcpServers) {
|
|
636
|
-
const status = s.status === 'connected' ? '✅' : '❌';
|
|
637
|
-
console.log(`${status} MCP server ${s.name}: ${s.status}`);
|
|
638
|
-
if (s.status !== 'connected') {
|
|
639
|
-
console.log(` 🔍 MCP error:`, JSON.stringify(s));
|
|
640
|
-
}
|
|
641
|
-
}
|
|
642
|
-
}
|
|
643
|
-
const newSessionId = message.session_id;
|
|
644
|
-
if (newSessionId) {
|
|
645
|
-
bgOnSessionId(newSessionId);
|
|
646
|
-
const isNewSession = !self.#sessionId;
|
|
647
|
-
if (isNewSession)
|
|
648
|
-
console.log(`📋 New session: ${newSessionId}`);
|
|
649
|
-
self.#sessionId = newSessionId;
|
|
650
|
-
if (isNewSession && bgOpts.workingDirectory) {
|
|
651
|
-
saveSessionMetadata(bgOpts.workingDirectory, {
|
|
652
|
-
sessionId: newSessionId,
|
|
653
|
-
lastUpdated: new Date().toISOString(),
|
|
654
|
-
projectPath: bgOpts.workingDirectory,
|
|
655
|
-
});
|
|
656
|
-
}
|
|
657
|
-
const requestedResumeId = bgOpts.resumeSessionId;
|
|
658
|
-
if (requestedResumeId && newSessionId !== requestedResumeId) {
|
|
659
|
-
console.error(`❌ Session resume FAILED: Expected ${requestedResumeId.substring(0, 8)}..., got ${newSessionId.substring(0, 8)}...`);
|
|
660
|
-
bgEventEmitter.emit('session_resume_failed', { requestedSessionId: requestedResumeId, actualSessionId: newSessionId });
|
|
661
|
-
}
|
|
662
|
-
else if (requestedResumeId && newSessionId === requestedResumeId) {
|
|
663
|
-
console.log(`✅ Session resumed successfully: ${newSessionId.substring(0, 8)}...`);
|
|
664
|
-
}
|
|
665
|
-
}
|
|
666
|
-
}
|
|
667
|
-
// Checkpoint capture
|
|
668
|
-
if (message.type === 'user' && message.uuid) {
|
|
669
|
-
bgOnCheckpoint(message.uuid);
|
|
670
|
-
}
|
|
671
|
-
// Stream text → tts_say events (the whole point of background mode)
|
|
672
|
-
if (message.type === 'assistant' && message.message?.content) {
|
|
673
|
-
const sdkRequestId = message.requestId;
|
|
674
|
-
if (sdkRequestId)
|
|
675
|
-
bgEventEmitter.emit('query_request_id', { requestId: sdkRequestId });
|
|
676
|
-
for (const block of message.message.content) {
|
|
677
|
-
if (block.type === 'text' && block.text) {
|
|
678
|
-
hasOutput = true;
|
|
679
|
-
bgEventEmitter.emit('assistant_text', { text: block.text });
|
|
680
|
-
const ttsChunk = stripMarkdownForTTS(block.text);
|
|
681
|
-
if (ttsChunk.trim()) {
|
|
682
|
-
console.log(`🔊 TTS say (${ttsChunk.length} chars): "${ttsChunk.substring(0, 60)}..."`);
|
|
683
|
-
bgEventEmitter.emit('tts_say', { text: ttsChunk });
|
|
684
|
-
}
|
|
685
|
-
}
|
|
686
|
-
}
|
|
687
|
-
}
|
|
688
|
-
// Final result
|
|
689
|
-
if (message.type === 'result' && message.result) {
|
|
690
|
-
bgEventEmitter.emit('assistant_result', { text: message.result });
|
|
691
|
-
if (!hasOutput) {
|
|
692
|
-
hasOutput = true;
|
|
693
|
-
const ttsText = stripMarkdownForTTS(message.result);
|
|
694
|
-
if (ttsText.trim()) {
|
|
695
|
-
console.log(`🔊 TTS say result (${ttsText.length} chars): "${ttsText.substring(0, 60)}..."`);
|
|
696
|
-
bgEventEmitter.emit('tts_say', { text: ttsText });
|
|
697
|
-
}
|
|
698
|
-
}
|
|
699
|
-
}
|
|
700
|
-
}
|
|
701
|
-
if (!hasOutput) {
|
|
702
|
-
bgEventEmitter.emit('tts_say', { text: 'Done.' });
|
|
703
|
-
}
|
|
704
|
-
console.log('✅ Claude response complete (background)');
|
|
705
|
-
}
|
|
706
|
-
catch (error) {
|
|
707
|
-
if (bgAbortController?.signal.aborted) {
|
|
708
|
-
console.log('🛑 Claude Agent SDK query aborted (background)');
|
|
709
|
-
return;
|
|
710
|
-
}
|
|
711
|
-
console.error('❌ Claude Agent SDK error (background):', error);
|
|
712
|
-
bgEventEmitter.emit('tts_say', { text: 'Sorry, I encountered an error.' });
|
|
713
|
-
}
|
|
714
|
-
finally {
|
|
715
|
-
self.#llmRef.removeActiveQuery(activeQuery);
|
|
716
|
-
}
|
|
717
|
-
})();
|
|
987
|
+
// PERSISTENT SESSION: Push message to existing subprocess (no JSONL replay).
|
|
988
|
+
// First call creates the query (cold start). Subsequent calls are instant.
|
|
989
|
+
// The background consumer in ClaudeLLM handles all message routing (TTS, tools, etc.)
|
|
990
|
+
this.#llmRef.pushMessage(userText, sdkOptions, {
|
|
991
|
+
onSessionId: this.#onSessionId,
|
|
992
|
+
onCheckpoint: this.#onCheckpoint,
|
|
993
|
+
eventEmitter: this.#eventEmitter,
|
|
994
|
+
});
|
|
718
995
|
// Return immediately — queue closes, pipeline completes, say() handles play
|
|
719
996
|
console.log('🚀 Direct mode: Claude query running in background, pipeline released');
|
|
720
997
|
return;
|
package/dist/fast-brain.js
CHANGED
|
@@ -665,8 +665,8 @@ async function askViaAgentSdk(question, workspace, researchContext, sessionId, w
|
|
|
665
665
|
if (fastBrainSessionId) {
|
|
666
666
|
options.resume = fastBrainSessionId;
|
|
667
667
|
}
|
|
668
|
-
// Run with
|
|
669
|
-
const TIMEOUT_MS =
|
|
668
|
+
// Run with 30s timeout — allows time for large JSONL replay on session resume
|
|
669
|
+
const TIMEOUT_MS = 30000;
|
|
670
670
|
let timeoutHandle;
|
|
671
671
|
const timeoutPromise = new Promise((_, reject) => {
|
|
672
672
|
timeoutHandle = setTimeout(() => reject(new Error('fast-brain-timeout')), TIMEOUT_MS);
|