orchestrix-yuri 4.6.7 → 4.7.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/lib/gateway/config.js
CHANGED
|
@@ -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: '⏱
|
|
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 };
|
package/lib/gateway/router.js
CHANGED
|
@@ -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,23 @@ class Router {
|
|
|
276
281
|
return this._handlePhaseCommand(phaseCmd, msg);
|
|
277
282
|
}
|
|
278
283
|
|
|
279
|
-
// ═══
|
|
284
|
+
// ═══ DISPATCHER CLASSIFY — NL intent detection via persistent Claude agent ═══
|
|
285
|
+
if (this.dispatcher && this.dispatcher.isReady()) {
|
|
286
|
+
try {
|
|
287
|
+
const classified = await this.dispatcher.classify(msg.text);
|
|
288
|
+
log.router(`Dispatcher: ${classified.action} ← "${msg.text.slice(0, 50)}..." (${classified.reasoning})`);
|
|
289
|
+
|
|
290
|
+
if (classified.action !== 'conversation') {
|
|
291
|
+
const intentResult = await this._executeClassifiedIntent(classified, msg);
|
|
292
|
+
if (intentResult) return intentResult;
|
|
293
|
+
}
|
|
294
|
+
} catch (err) {
|
|
295
|
+
log.warn(`Dispatcher classify failed: ${err.message}`);
|
|
296
|
+
// Fall through to normal processing
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// ═══ NORMAL MESSAGE — conversation via Claude ═══
|
|
280
301
|
if (this.processing.has(msg.chatId)) {
|
|
281
302
|
return { text: '⏳ Still processing your previous message. Please wait.' };
|
|
282
303
|
}
|
|
@@ -289,6 +310,35 @@ class Router {
|
|
|
289
310
|
}
|
|
290
311
|
}
|
|
291
312
|
|
|
313
|
+
// ── Dispatcher Intent Execution ─────────────────────────────────────────────
|
|
314
|
+
|
|
315
|
+
async _executeClassifiedIntent(classified, msg) {
|
|
316
|
+
const projectRoot = engine.resolveProjectRoot();
|
|
317
|
+
if (!projectRoot) {
|
|
318
|
+
return { text: '❌ No active project. Run `/yuri *create` in your terminal first.' };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
this.history.append(msg.chatId, 'user', msg.text);
|
|
322
|
+
|
|
323
|
+
switch (classified.action) {
|
|
324
|
+
case 'change': {
|
|
325
|
+
const desc = classified.description || msg.text;
|
|
326
|
+
msg.text = `*change ${desc}`;
|
|
327
|
+
return this._handleChangeCommand(msg, projectRoot);
|
|
328
|
+
}
|
|
329
|
+
case 'plan':
|
|
330
|
+
case 'develop':
|
|
331
|
+
case 'test':
|
|
332
|
+
case 'iterate':
|
|
333
|
+
case 'deploy':
|
|
334
|
+
return this._handlePhaseCommand(classified.action, msg);
|
|
335
|
+
case 'status':
|
|
336
|
+
return this._handleStatusQuery(msg);
|
|
337
|
+
default:
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
292
342
|
// ── Phase Command Handling ───────────────────────────────────────────────────
|
|
293
343
|
|
|
294
344
|
_detectPhaseCommand(text) {
|
|
@@ -478,6 +528,7 @@ Reply with ONLY one word: small, medium, or large. Nothing else.`;
|
|
|
478
528
|
prompt,
|
|
479
529
|
cwd: projectRoot,
|
|
480
530
|
engineConfig: this.config.engine,
|
|
531
|
+
timeout: (this.config.engine && this.config.engine.conversation_timeout) || 90000,
|
|
481
532
|
});
|
|
482
533
|
|
|
483
534
|
this.history.append(msg.chatId, 'user', msg.text);
|
|
@@ -952,6 +1003,7 @@ Reply with ONLY one word: small, medium, or large. Nothing else.`;
|
|
|
952
1003
|
async shutdown() {
|
|
953
1004
|
if (this._reportTimer) clearInterval(this._reportTimer);
|
|
954
1005
|
this.orchestrator.shutdown();
|
|
1006
|
+
if (this.dispatcher) this.dispatcher.shutdown();
|
|
955
1007
|
if (engine.destroySession) engine.destroySession();
|
|
956
1008
|
}
|
|
957
1009
|
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,32 @@
|
|
|
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
|
+
|
|
5
|
+
## Available Actions
|
|
6
|
+
|
|
7
|
+
| Action | When to use | Examples |
|
|
8
|
+
|--------|-------------|---------|
|
|
9
|
+
| change | User wants something fixed, modified, added, redesigned, or wants a specific agent to do work | "这个 bug 修一下", "让 UX 重新设计界面", "Add dark mode", "让 architect 评估可行性" |
|
|
10
|
+
| plan | User wants to start or restart the planning phase | "开始规划", "重新做 PRD", "重新规划架构" |
|
|
11
|
+
| develop | User wants to start or continue development | "开始开发", "继续写代码" |
|
|
12
|
+
| test | User wants to run tests | "跑一下测试", "冒烟测试" |
|
|
13
|
+
| deploy | User wants to deploy or release | "部署到线上", "发布", "上线" |
|
|
14
|
+
| status | User asks about progress, previous results, or what happened | "之前的方案呢?", "进展如何?", "怎么样了", "结果出来了吗" |
|
|
15
|
+
| iterate | User wants a new iteration cycle | "新迭代", "下一轮" |
|
|
16
|
+
| conversation | Pure question, opinion, or chat — no actionable request | "你觉得怎么样?", "这个架构好不好?", "解释一下这段代码" |
|
|
17
|
+
|
|
18
|
+
## Response Format
|
|
19
|
+
|
|
20
|
+
Reply with EXACTLY this JSON format on a single line, nothing else:
|
|
21
|
+
|
|
22
|
+
{"action":"<action>","description":"<brief description for the agent>","reasoning":"<one sentence why>"}
|
|
23
|
+
|
|
24
|
+
## Rules
|
|
25
|
+
|
|
26
|
+
- If the user references a specific agent role (UX, UI, architect, PM, QA, dev, SM), and wants them to DO something, classify as "change"
|
|
27
|
+
- If the user asks about previous results, output, or progress, classify as "status"
|
|
28
|
+
- If the user expresses dissatisfaction and wants improvement, classify as "change" (they want something fixed/redesigned)
|
|
29
|
+
- If the user expresses an opinion or asks a question WITHOUT requesting any action, classify as "conversation"
|
|
30
|
+
- When in doubt, prefer "conversation" — it is safer to chat than to trigger an unintended action
|
|
31
|
+
- The "description" field should be a concise summary of what the user wants done, suitable for passing to an agent
|
|
32
|
+
- ALWAYS respond with the JSON format above. Never add explanation, markdown, or anything else.
|