osborn 0.9.141 → 0.9.142

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.
@@ -10,6 +10,7 @@ import { llm, shortuuid, DEFAULT_API_CONNECT_OPTIONS } from '@livekit/agents';
10
10
  import { query } from '@anthropic-ai/claude-agent-sdk';
11
11
  import { EventEmitter } from 'events';
12
12
  import { saveSessionMetadata, getSessionWorkspace } from './config.js';
13
+ import { statusManager } from './status-manager.js';
13
14
  import { getResearchSystemPrompt, getDirectModeResearchPrompt } from './prompts.js';
14
15
  import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
15
16
  import { join, dirname } from 'node:path';
@@ -505,6 +506,9 @@ export class ClaudeLLM extends llm.LLM {
505
506
  // Active queries — multiple can be running (SDK queues them internally).
506
507
  // We keep ALL references so interrupt() can stop whatever is currently executing.
507
508
  #activeQueries = new Set();
509
+ // Dispatcher v1 — maps tool_use_id → subagent_type for Task tool_use blocks
510
+ // so that when task_summary fires we know which agent type just finished.
511
+ #dispatchAgentTypes = new Map();
508
512
  constructor(opts = {}) {
509
513
  super();
510
514
  // Session resume/continue options
@@ -1039,6 +1043,29 @@ export class ClaudeLLM extends llm.LLM {
1039
1043
  callbacks.eventEmitter.emit('tts_say', { text: ttsChunk });
1040
1044
  }
1041
1045
  }
1046
+ // Dispatcher v1 — record Task tool_use blocks so we can correlate
1047
+ // the matching task_summary message to the subagent type.
1048
+ if (block.type === 'tool_use' && block.name === 'Task') {
1049
+ console.log('[DISPATCH-PROBE] task tool_use', JSON.stringify({ id: block.id, subagent_type: block.input?.subagent_type }));
1050
+ this.#dispatchAgentTypes.set(block.id, block.input?.subagent_type);
1051
+ statusManager.upsertDispatch(block.id, {
1052
+ owner: 'orchestrator',
1053
+ subagentType: block.input?.subagent_type,
1054
+ dispatchState: 'running',
1055
+ });
1056
+ }
1057
+ }
1058
+ }
1059
+ // Dispatcher v1 — catch sub-agent completion signals
1060
+ if (msg.type === 'system' && msg.subtype === 'task_summary') {
1061
+ console.log('[DISPATCH-PROBE] task_summary raw:', JSON.stringify(msg).slice(0, 500));
1062
+ const tuid = msg.tool_use_id ?? msg.toolUseId;
1063
+ const output = msg.summary ?? msg.result ?? msg.output ?? '';
1064
+ const subType = tuid ? this.#dispatchAgentTypes.get(tuid) : undefined;
1065
+ statusManager.upsertDispatch(tuid ?? `ts-${Date.now()}`, { subagentType: subType, dispatchState: 'completed', artifact: String(output) });
1066
+ console.log(`[DISPATCH] completed type=${subType ?? '?'} tuid=${(tuid ?? '?').slice(0, 8)} len=${String(output).length}`);
1067
+ if (subType === 'writer' && output) {
1068
+ this.#spawnReviewer(tuid, String(output), callbacks.eventEmitter);
1042
1069
  }
1043
1070
  }
1044
1071
  // Result — marks end of a turn (but we keep consuming for next turn)
@@ -1067,6 +1094,56 @@ export class ClaudeLLM extends llm.LLM {
1067
1094
  console.log('🔒 Persistent session background consumer exited');
1068
1095
  }
1069
1096
  }
1097
+ /**
1098
+ * Dispatcher v1 — auto-spawn a reviewer after a writer sub-agent completes.
1099
+ * Runs a one-shot query() with the reviewer agent and emits dispatch_rejected
1100
+ * if the verdict is REJECT. A reviewer failure must never crash the consumer.
1101
+ */
1102
+ async #spawnReviewer(tuid, writerOutput, emitter) {
1103
+ try {
1104
+ const prompt = [
1105
+ 'Use the reviewer sub-agent to review this writer output for correctness/spec-adherence/obvious bugs.',
1106
+ 'End your reply with exactly `VERDICT: ACCEPT` or `VERDICT: REJECT`.',
1107
+ '',
1108
+ '<writer_output>',
1109
+ writerOutput.slice(0, 8000),
1110
+ '</writer_output>',
1111
+ ].join('\n');
1112
+ const reviewerOptions = {
1113
+ cwd: this.#opts.workingDirectory,
1114
+ permissionMode: 'default',
1115
+ agents: NAMED_AGENTS,
1116
+ };
1117
+ console.log(`[DISPATCH] spawning reviewer for tuid=${tuid.slice(0, 8)}`);
1118
+ const reviewerQuery = query({ prompt, options: reviewerOptions });
1119
+ this.#activeQueries.add(reviewerQuery);
1120
+ let reviewerText = '';
1121
+ try {
1122
+ for await (const msg of reviewerQuery) {
1123
+ const m = msg;
1124
+ if (m.type === 'result' && m.result) {
1125
+ reviewerText = String(m.result);
1126
+ }
1127
+ }
1128
+ }
1129
+ finally {
1130
+ this.#activeQueries.delete(reviewerQuery);
1131
+ }
1132
+ const verdictMatch = reviewerText.match(/VERDICT:\s*(ACCEPT|REJECT)/i);
1133
+ const verdict = verdictMatch ? verdictMatch[1].toUpperCase() : null;
1134
+ if (verdict === 'REJECT') {
1135
+ console.log(`[DISPATCH] review REJECT for tuid=${tuid.slice(0, 8)}`);
1136
+ statusManager.upsertDispatch(tuid, { dispatchState: 'rejected', artifact: reviewerText });
1137
+ emitter.emit('dispatch_rejected', { tuid, verdict: 'REJECT', review: reviewerText });
1138
+ }
1139
+ else {
1140
+ console.log(`[DISPATCH] review ACCEPT for tuid=${tuid.slice(0, 8)}`);
1141
+ }
1142
+ }
1143
+ catch (err) {
1144
+ console.error('[DISPATCH] reviewer spawn failed (non-fatal):', err);
1145
+ }
1146
+ }
1070
1147
  chat({ chatCtx, toolCtx, connOptions = DEFAULT_API_CONNECT_OPTIONS, abortController, }) {
1071
1148
  return new ClaudeLLMStream(this, {
1072
1149
  chatCtx,
package/dist/index.js CHANGED
@@ -3086,6 +3086,16 @@ async function main() {
3086
3086
  checkpointId: data.checkpointId,
3087
3087
  });
3088
3088
  });
3089
+ // Dispatcher v1 — writer reviewer rejected verdict → frontend
3090
+ directLLM.events.on('dispatch_rejected', (data) => {
3091
+ console.log(`[DISPATCH] dispatch_rejected tuid=${(data.tuid ?? '').slice(0, 8)} sending to frontend`);
3092
+ sendToFrontend({
3093
+ type: 'task_review',
3094
+ tuid: data.tuid,
3095
+ verdict: data.verdict,
3096
+ review: data.review,
3097
+ });
3098
+ });
3089
3099
  // Create the Agent with instructions, STT, LLM, TTS
3090
3100
  // VAD (Silero ONNX) removed — caused 2-5s inference lag on CPU, making interruption detection worse
3091
3101
  // Turn detection is server-side (Deepgram endpointing), interruptions handled by STT
@@ -18,6 +18,10 @@ export interface TaskStatus {
18
18
  progress?: number;
19
19
  progressUpdates: string[];
20
20
  lastUpdate?: string;
21
+ owner?: string;
22
+ subagentType?: string;
23
+ dispatchState?: 'pending' | 'running' | 'completed' | 'rejected' | 'failed';
24
+ artifact?: string;
21
25
  }
22
26
  export interface StatusUpdate {
23
27
  hasUpdates: boolean;
@@ -86,5 +90,16 @@ export declare class StatusManager {
86
90
  * Get context summary for the brain
87
91
  */
88
92
  getContextSummary(): string;
93
+ /**
94
+ * Dispatcher v1 — create or update a dispatch entry keyed by tool_use_id.
95
+ * Creates a minimal TaskStatus shell if the id doesn't yet exist so callers
96
+ * can upsert without a prior registerTask() call.
97
+ */
98
+ upsertDispatch(id: string, fields: {
99
+ owner?: string;
100
+ subagentType?: string;
101
+ dispatchState?: TaskStatus['dispatchState'];
102
+ artifact?: string;
103
+ }): void;
89
104
  }
90
105
  export declare const statusManager: StatusManager;
@@ -182,6 +182,48 @@ export class StatusManager {
182
182
  getContextSummary() {
183
183
  return this.conversationContext.slice(-5).join(' | ');
184
184
  }
185
+ /**
186
+ * Dispatcher v1 — create or update a dispatch entry keyed by tool_use_id.
187
+ * Creates a minimal TaskStatus shell if the id doesn't yet exist so callers
188
+ * can upsert without a prior registerTask() call.
189
+ */
190
+ upsertDispatch(id, fields) {
191
+ if (!this.tasks.has(id)) {
192
+ this.tasks.set(id, {
193
+ id,
194
+ type: 'execute',
195
+ // Use an empty query so narration never speaks a bogus "dispatch:<id>" string.
196
+ // Dispatch progress is tracked exclusively via dispatchState; status starts as
197
+ // 'pending' (not 'running') so getStatusUpdate() runningTasks narration is skipped.
198
+ query: '',
199
+ status: 'pending',
200
+ startedAt: Date.now(),
201
+ progressUpdates: [],
202
+ });
203
+ }
204
+ const task = this.tasks.get(id);
205
+ if (fields.owner !== undefined)
206
+ task.owner = fields.owner;
207
+ if (fields.subagentType !== undefined)
208
+ task.subagentType = fields.subagentType;
209
+ if (fields.dispatchState !== undefined)
210
+ task.dispatchState = fields.dispatchState;
211
+ if (fields.artifact !== undefined)
212
+ task.artifact = fields.artifact;
213
+ // Mirror dispatchState into the base status field so existing getStatusUpdate() callers see it.
214
+ // 'rejected' is also terminal — map it to 'failed' so the GC and readers treat it as finished.
215
+ if (fields.dispatchState === 'completed')
216
+ task.status = 'completed';
217
+ if (fields.dispatchState === 'failed' || fields.dispatchState === 'rejected')
218
+ task.status = 'failed';
219
+ // Fix: set completedAt when entering any terminal state so the task is GC-eligible via
220
+ // clearReportedTasks() and correctly surfaces in hasCompletedTasks() / getStatusUpdate().
221
+ // Without this the tasks Map grows unbounded (memory leak).
222
+ const isTerminal = task.status === 'completed' || task.status === 'failed';
223
+ if (isTerminal && !task.completedAt)
224
+ task.completedAt = Date.now();
225
+ console.log(`[Dispatch] upsertDispatch id=${id.slice(0, 8)} state=${fields.dispatchState ?? '-'} type=${fields.subagentType ?? '-'}`);
226
+ }
185
227
  }
186
228
  // Singleton instance
187
229
  export const statusManager = new StatusManager();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "osborn",
3
- "version": "0.9.141",
3
+ "version": "0.9.142",
4
4
  "description": "Voice AI coding assistant - local agent that connects to Osborn frontend",
5
5
  "type": "module",
6
6
  "bin": {