orchestrix-yuri 4.6.7 → 4.7.1

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.
@@ -25,6 +25,7 @@ const DEFAULTS = {
25
25
  phase_poll_interval: 30000, // plan phase: poll agent every 30s
26
26
  dev_poll_interval: 300000, // dev phase: poll progress every 5 min
27
27
  report_interval: 1800000, // dev phase: progress report every 30 min
28
+ conversation_timeout: 90000, // normal conversation reply timeout (90s)
28
29
  },
29
30
  };
30
31
 
@@ -309,7 +309,7 @@ function runClaude(args, cwd, timeout) {
309
309
  }, (err, stdout, stderr) => {
310
310
  if (err && err.killed) {
311
311
  log.warn('Claude CLI timed out');
312
- return resolve({ reply: '⏱ Response timed out.', raw: '' });
312
+ return resolve({ reply: '⏱ Taking longer than expected. Try a specific command like `*change "desc"` or `*status`.', raw: '' });
313
313
  }
314
314
  // Claude CLI may return non-zero exit code even with valid JSON output
315
315
  // (e.g., stderr "Warning: no stdin data received" causes exit code 1).
@@ -0,0 +1,169 @@
1
+ 'use strict';
2
+
3
+ const { execSync } = require('child_process');
4
+ const path = require('path');
5
+ const os = require('os');
6
+ const tmx = require('./tmux-utils');
7
+ const { log } = require('../log');
8
+
9
+ const SKILL_DIR = path.join(os.homedir(), '.claude', 'skills', 'yuri');
10
+ const SESSION_NAME = 'yuri-dispatcher';
11
+
12
+ /**
13
+ * Dispatcher: a persistent Claude Code instance in a tmux session
14
+ * that classifies incoming natural language messages into structured actions.
15
+ *
16
+ * Runs independently from the Yuri conversation session to avoid
17
+ * polluting chat history with classification prompts.
18
+ */
19
+ class Dispatcher {
20
+ constructor(config) {
21
+ this._config = config || {};
22
+ this._ready = false;
23
+ this._busy = false;
24
+ }
25
+
26
+ /**
27
+ * Start the dispatcher tmux session with Claude Code + system prompt.
28
+ * Called once on gateway startup. Reuses existing session if alive.
29
+ */
30
+ async start() {
31
+ if (tmx.hasSession(SESSION_NAME)) {
32
+ this._ready = true;
33
+ log.engine('Dispatcher: reusing existing session');
34
+ return;
35
+ }
36
+
37
+ try {
38
+ execSync(`tmux new-session -d -s "${SESSION_NAME}" -x 200 -y 50`, { timeout: 10000 });
39
+ } catch (err) {
40
+ log.warn(`Dispatcher: failed to create tmux session: ${err.message}`);
41
+ return;
42
+ }
43
+
44
+ // Launch Claude Code with dispatcher system prompt
45
+ const promptPath = path.join(SKILL_DIR, 'resources', 'dispatcher-prompt.txt');
46
+ tmx.sendKeysWithEnter(SESSION_NAME, 0,
47
+ `claude --system-prompt-file "${promptPath}" --dangerously-skip-permissions`);
48
+
49
+ // Wait for Claude Code to be ready (❯ prompt)
50
+ const ready = tmx.waitForPrompt(SESSION_NAME, 0, 45000);
51
+ this._ready = ready;
52
+
53
+ if (ready) {
54
+ log.engine('Dispatcher: ready');
55
+ } else {
56
+ log.warn('Dispatcher: Claude Code did not become ready within timeout');
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Classify a user message into an action.
62
+ * Returns { action, description, reasoning }.
63
+ *
64
+ * @param {string} text - raw user message
65
+ * @returns {Promise<{action: string, description: string, reasoning: string}>}
66
+ */
67
+ async classify(text) {
68
+ // Re-entry guard
69
+ if (this._busy) {
70
+ return { action: 'conversation', description: text, reasoning: 'dispatcher busy' };
71
+ }
72
+
73
+ // Auto-recover if session died
74
+ if (!this._ready || !tmx.hasSession(SESSION_NAME)) {
75
+ this._ready = false;
76
+ await this.start();
77
+ if (!this._ready) {
78
+ return { action: 'conversation', description: text, reasoning: 'dispatcher unavailable' };
79
+ }
80
+ }
81
+
82
+ this._busy = true;
83
+
84
+ try {
85
+ // Send user message to dispatcher
86
+ tmx.sendKeysWithEnter(SESSION_NAME, 0, text);
87
+
88
+ // Poll for completion (max 30s, every 2s)
89
+ const deadline = Date.now() + 30000;
90
+ let lastHash = '';
91
+ let stableCount = 0;
92
+
93
+ while (Date.now() < deadline) {
94
+ execSync('sleep 2');
95
+ const result = tmx.checkCompletion(SESSION_NAME, 0, lastHash);
96
+
97
+ if (result.status === 'complete' || (result.status === 'stable' && ++stableCount >= 2)) {
98
+ const output = tmx.capturePane(SESSION_NAME, 0, 30);
99
+ return this._parseResponse(output, text);
100
+ }
101
+
102
+ if (result.status !== 'stable') {
103
+ stableCount = 0;
104
+ lastHash = result.hash || '';
105
+ } else {
106
+ lastHash = result.hash;
107
+ }
108
+ }
109
+
110
+ // Timeout — default to conversation
111
+ log.warn('Dispatcher: classify timeout');
112
+ return { action: 'conversation', description: text, reasoning: 'classifier timeout' };
113
+ } finally {
114
+ this._busy = false;
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Parse the dispatcher's tmux pane output to extract the JSON response.
120
+ * Searches from bottom up for the first valid JSON line with "action".
121
+ */
122
+ _parseResponse(output, originalText) {
123
+ const lines = output.split('\n');
124
+
125
+ for (let i = lines.length - 1; i >= 0; i--) {
126
+ const line = lines[i].trim();
127
+ // Look for JSON that contains "action"
128
+ if (line.startsWith('{') && line.includes('"action"')) {
129
+ try {
130
+ const parsed = JSON.parse(line);
131
+ if (parsed.action) {
132
+ const valid = ['change', 'plan', 'develop', 'test', 'deploy', 'status', 'iterate', 'conversation'];
133
+ if (valid.includes(parsed.action)) {
134
+ return {
135
+ action: parsed.action,
136
+ description: parsed.description || originalText,
137
+ reasoning: parsed.reasoning || '',
138
+ };
139
+ }
140
+ }
141
+ } catch { /* not valid JSON, continue searching */ }
142
+ }
143
+ }
144
+
145
+ log.warn('Dispatcher: failed to parse response, defaulting to conversation');
146
+ return { action: 'conversation', description: originalText, reasoning: 'parse failed' };
147
+ }
148
+
149
+ /**
150
+ * Check if dispatcher is available.
151
+ */
152
+ isReady() {
153
+ return this._ready && tmx.hasSession(SESSION_NAME);
154
+ }
155
+
156
+ /**
157
+ * Gracefully shutdown the dispatcher session.
158
+ */
159
+ shutdown() {
160
+ if (tmx.hasSession(SESSION_NAME)) {
161
+ tmx.killSession(SESSION_NAME);
162
+ }
163
+ this._ready = false;
164
+ this._busy = false;
165
+ log.engine('Dispatcher: shutdown');
166
+ }
167
+ }
168
+
169
+ module.exports = { Dispatcher };
@@ -70,6 +70,19 @@ class ChatHistory {
70
70
  this._trimIfNeeded(filePath);
71
71
  }
72
72
 
73
+ /**
74
+ * Get the last assistant message for a chat.
75
+ * @param {string} chatId
76
+ * @returns {string|null}
77
+ */
78
+ getLastAssistantMessage(chatId) {
79
+ const messages = this.getRecent(chatId);
80
+ for (let i = messages.length - 1; i >= 0; i--) {
81
+ if (messages[i].role === 'assistant') return messages[i].text;
82
+ }
83
+ return null;
84
+ }
85
+
73
86
  _trimIfNeeded(filePath) {
74
87
  const lines = fs.readFileSync(filePath, 'utf8').trim().split('\n').filter(Boolean);
75
88
  const limit = this.maxMessages * 2;
@@ -10,6 +10,7 @@ const { OwnerBinding } = require('./binding');
10
10
  const engine = require('./engine/claude-sdk');
11
11
  const { runReflect } = require('./engine/reflect');
12
12
  const { PhaseOrchestrator } = require('./engine/phase-orchestrator');
13
+ const { Dispatcher } = require('./engine/dispatcher');
13
14
  const { log } = require('./log');
14
15
 
15
16
  const YURI_GLOBAL = path.join(os.homedir(), '.yuri');
@@ -73,6 +74,10 @@ class Router {
73
74
  this.orchestrator.tryRecover(projectRoot);
74
75
  }
75
76
 
77
+ // Dispatcher: persistent Claude agent for NL intent classification
78
+ this.dispatcher = new Dispatcher(config);
79
+ this.dispatcher.start().catch((err) => log.warn(`Dispatcher start failed: ${err.message}`));
80
+
76
81
  // Independent progress reporter: sends periodic status card
77
82
  // when dev phase is active (detected from focus.yaml + tmux),
78
83
  // regardless of whether orchestrator is tracking it.
@@ -276,7 +281,42 @@ class Router {
276
281
  return this._handlePhaseCommand(phaseCmd, msg);
277
282
  }
278
283
 
279
- // ═══ NORMAL MESSAGEgoes through Claude ═══
284
+ // ═══ DISPATCHER CLASSIFYNL intent detection via persistent Claude agent ═══
285
+ {
286
+ // Inject last assistant response as context for the dispatcher
287
+ const lastReply = this.history.getLastAssistantMessage(msg.chatId);
288
+ const contextText = lastReply
289
+ ? `[CONTEXT] Previous assistant response: ${lastReply.slice(0, 300)}\n\nUser message: ${msg.text}`
290
+ : msg.text;
291
+
292
+ let classified = null;
293
+ if (this.dispatcher) {
294
+ try {
295
+ classified = await this.dispatcher.classify(contextText);
296
+ log.router(`Dispatcher: ${classified.action} ← "${msg.text.slice(0, 50)}..." (${classified.reasoning})`);
297
+ } catch (err) {
298
+ log.warn(`Dispatcher classify failed: ${err.message}`);
299
+ }
300
+ }
301
+
302
+ // If dispatcher unavailable or failed, default to 'change' for work-like messages
303
+ if (!classified) {
304
+ classified = { action: 'change', description: msg.text, reasoning: 'dispatcher unavailable, defaulting to change' };
305
+ log.router(`Dispatcher unavailable, defaulting to change for: "${msg.text.slice(0, 50)}..."`);
306
+ }
307
+
308
+ if (classified.action !== 'conversation') {
309
+ try {
310
+ const intentResult = await this._executeClassifiedIntent(classified, msg);
311
+ if (intentResult) return intentResult;
312
+ } catch (err) {
313
+ log.warn(`Intent execution failed: ${err.message}`);
314
+ // Fall through to conversation
315
+ }
316
+ }
317
+ }
318
+
319
+ // ═══ NORMAL MESSAGE — conversation via Claude ═══
280
320
  if (this.processing.has(msg.chatId)) {
281
321
  return { text: '⏳ Still processing your previous message. Please wait.' };
282
322
  }
@@ -289,6 +329,35 @@ class Router {
289
329
  }
290
330
  }
291
331
 
332
+ // ── Dispatcher Intent Execution ─────────────────────────────────────────────
333
+
334
+ async _executeClassifiedIntent(classified, msg) {
335
+ const projectRoot = engine.resolveProjectRoot();
336
+ if (!projectRoot) {
337
+ return { text: '❌ No active project. Run `/yuri *create` in your terminal first.' };
338
+ }
339
+
340
+ this.history.append(msg.chatId, 'user', msg.text);
341
+
342
+ switch (classified.action) {
343
+ case 'change': {
344
+ const desc = classified.description || msg.text;
345
+ msg.text = `*change ${desc}`;
346
+ return this._handleChangeCommand(msg, projectRoot);
347
+ }
348
+ case 'plan':
349
+ case 'develop':
350
+ case 'test':
351
+ case 'iterate':
352
+ case 'deploy':
353
+ return this._handlePhaseCommand(classified.action, msg);
354
+ case 'status':
355
+ return this._handleStatusQuery(msg);
356
+ default:
357
+ return null;
358
+ }
359
+ }
360
+
292
361
  // ── Phase Command Handling ───────────────────────────────────────────────────
293
362
 
294
363
  _detectPhaseCommand(text) {
@@ -478,6 +547,7 @@ Reply with ONLY one word: small, medium, or large. Nothing else.`;
478
547
  prompt,
479
548
  cwd: projectRoot,
480
549
  engineConfig: this.config.engine,
550
+ timeout: (this.config.engine && this.config.engine.conversation_timeout) || 90000,
481
551
  });
482
552
 
483
553
  this.history.append(msg.chatId, 'user', msg.text);
@@ -952,6 +1022,7 @@ Reply with ONLY one word: small, medium, or large. Nothing else.`;
952
1022
  async shutdown() {
953
1023
  if (this._reportTimer) clearInterval(this._reportTimer);
954
1024
  this.orchestrator.shutdown();
1025
+ if (this.dispatcher) this.dispatcher.shutdown();
955
1026
  if (engine.destroySession) engine.destroySession();
956
1027
  }
957
1028
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orchestrix-yuri",
3
- "version": "4.6.7",
3
+ "version": "4.7.1",
4
4
  "description": "Yuri — Meta-Orchestrator for Orchestrix. Drive your entire project lifecycle with natural language.",
5
5
  "main": "lib/installer.js",
6
6
  "bin": {
@@ -0,0 +1,34 @@
1
+ You are a message classifier for Yuri, a project orchestrator.
2
+
3
+ Your ONLY job is to classify incoming user messages into structured actions.
4
+ Messages may include "[CONTEXT]" with the previous assistant response for reference.
5
+
6
+ ## Available Actions
7
+
8
+ | Action | When to use | Examples |
9
+ |--------|-------------|---------|
10
+ | change | User wants ANY work done: fix, modify, add, redesign, create a plan, write something, have an agent do work | "这个 bug 修一下", "让 UX 重新设计界面", "Add dark mode", "先做个完整的设计 plan", "写一份方案", "从 PO 开始改" |
11
+ | plan | User explicitly wants to START the planning phase from scratch | "开始规划", "*plan" |
12
+ | develop | User explicitly wants to START development phase | "开始开发", "*develop" |
13
+ | test | User wants to run tests | "跑一下测试", "冒烟测试" |
14
+ | deploy | User wants to deploy or release | "部署到线上", "发布", "上线" |
15
+ | status | User asks about progress, previous results, or what happened | "之前的方案呢?", "进展如何?", "怎么样了", "结果出来了吗" |
16
+ | iterate | User wants a new iteration cycle | "新迭代", "下一轮" |
17
+ | conversation | ONLY for pure questions or opinions with NO actionable request | "你觉得怎么样?", "这个架构好不好?", "解释一下这段代码" |
18
+
19
+ ## Response Format
20
+
21
+ Reply with EXACTLY this JSON format on a single line, nothing else:
22
+
23
+ {"action":"<action>","description":"<brief description for the agent>","reasoning":"<one sentence why>"}
24
+
25
+ ## Critical Rules
26
+
27
+ - **Bias toward "change"**: If the user wants ANYTHING done (create, write, fix, modify, redesign, evaluate, plan something), classify as "change". The "change" action is the safe default for any work request.
28
+ - If the user is replying to a question with a choice/decision (e.g., "先做 plan" answering "要 A 还是 B?"), that reply IS an instruction — classify as "change", NOT "conversation".
29
+ - If the user references a specific agent role (UX, UI, architect, PM, QA, dev, SM) and wants them to do something, classify as "change".
30
+ - If the user asks about previous results, output, or progress, classify as "status".
31
+ - "conversation" is ONLY for messages that require NO action at all — pure questions, opinions, greetings.
32
+ - When in doubt between "change" and "conversation", ALWAYS choose "change".
33
+ - The "description" field should be a concise summary of what the user wants done.
34
+ - ALWAYS respond with the JSON format above. Never add explanation, markdown, or anything else.