orchestrix-yuri 3.2.0 → 3.3.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.
|
@@ -33,6 +33,9 @@ class TelegramAdapter {
|
|
|
33
33
|
chatId: String(ctx.chat.id),
|
|
34
34
|
text: ctx.message.text,
|
|
35
35
|
userName: ctx.from.first_name || ctx.from.username || 'Unknown',
|
|
36
|
+
replyToMessageId: ctx.message.reply_to_message
|
|
37
|
+
? String(ctx.message.reply_to_message.message_id)
|
|
38
|
+
: null,
|
|
36
39
|
};
|
|
37
40
|
|
|
38
41
|
try {
|
|
@@ -45,9 +45,72 @@ class PhaseOrchestrator {
|
|
|
45
45
|
this._timer = null;
|
|
46
46
|
this._lastHash = '';
|
|
47
47
|
this._stableCount = 0;
|
|
48
|
+
|
|
49
|
+
// Agent question bridging
|
|
50
|
+
this._waitingForInput = false; // true when agent asked a question
|
|
51
|
+
this._waitingMessageId = null; // Telegram message_id of the question notification
|
|
52
|
+
this._onQuestionAsked = opts.onQuestionAsked || (() => Promise.resolve(null));
|
|
53
|
+
// onQuestionAsked(text) → sends to Telegram, returns { messageId }
|
|
48
54
|
}
|
|
49
55
|
|
|
50
56
|
isRunning() { return this._phase !== null; }
|
|
57
|
+
isWaitingForInput() { return this._waitingForInput; }
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Try to recover an in-progress phase from YAML state + tmux session.
|
|
61
|
+
* Called once on gateway startup. If a phase was running when the gateway
|
|
62
|
+
* died, reconnect to the existing tmux session and resume polling.
|
|
63
|
+
*/
|
|
64
|
+
tryRecover(projectRoot) {
|
|
65
|
+
if (this._phase) return; // already running
|
|
66
|
+
if (!projectRoot) return;
|
|
67
|
+
|
|
68
|
+
// Check phase2 (plan)
|
|
69
|
+
const phase2Path = path.join(projectRoot, '.yuri', 'state', 'phase2.yaml');
|
|
70
|
+
if (fs.existsSync(phase2Path)) {
|
|
71
|
+
const phase2 = yaml.load(fs.readFileSync(phase2Path, 'utf8')) || {};
|
|
72
|
+
if (phase2.status === 'in_progress' && phase2.tmux && phase2.tmux.session) {
|
|
73
|
+
if (tmx.hasSession(phase2.tmux.session)) {
|
|
74
|
+
// Recover: reconnect to existing session, resume polling
|
|
75
|
+
this._phase = 'plan';
|
|
76
|
+
this._projectRoot = projectRoot;
|
|
77
|
+
this._session = phase2.tmux.session;
|
|
78
|
+
|
|
79
|
+
// Find current step from YAML
|
|
80
|
+
if (Array.isArray(phase2.steps)) {
|
|
81
|
+
const lastComplete = phase2.steps.findLastIndex((s) => s.status === 'complete');
|
|
82
|
+
this._step = lastComplete >= 0 ? lastComplete + 1 : 0;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const pollInterval = this.config.phase_poll_interval || 30000;
|
|
86
|
+
this._timer = setInterval(() => this._pollPlanAgent(), pollInterval);
|
|
87
|
+
|
|
88
|
+
const agent = PLAN_AGENTS[this._step];
|
|
89
|
+
log.engine(`Recovered plan phase: session=${this._session}, step ${this._step + 1}/${PLAN_AGENTS.length} (${agent ? agent.name : '?'})`);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Check phase3 (develop)
|
|
96
|
+
const phase3Path = path.join(projectRoot, '.yuri', 'state', 'phase3.yaml');
|
|
97
|
+
if (fs.existsSync(phase3Path)) {
|
|
98
|
+
const phase3 = yaml.load(fs.readFileSync(phase3Path, 'utf8')) || {};
|
|
99
|
+
if (phase3.status === 'in_progress' && phase3.tmux && phase3.tmux.session) {
|
|
100
|
+
if (tmx.hasSession(phase3.tmux.session)) {
|
|
101
|
+
this._phase = 'develop';
|
|
102
|
+
this._projectRoot = projectRoot;
|
|
103
|
+
this._session = phase3.tmux.session;
|
|
104
|
+
|
|
105
|
+
const pollInterval = this.config.dev_poll_interval || 300000;
|
|
106
|
+
this._timer = setInterval(() => this._pollDevSession(), pollInterval);
|
|
107
|
+
|
|
108
|
+
log.engine(`Recovered dev phase: session=${this._session}`);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
51
114
|
|
|
52
115
|
getStatus() {
|
|
53
116
|
if (!this._phase) {
|
|
@@ -140,20 +203,29 @@ class PhaseOrchestrator {
|
|
|
140
203
|
// Update memory
|
|
141
204
|
this._updatePlanMemory('in_progress');
|
|
142
205
|
|
|
143
|
-
//
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
206
|
+
// Check if the current agent's window already has Claude Code running.
|
|
207
|
+
// This happens when gateway restarts while an agent is mid-execution.
|
|
208
|
+
// In that case, just start polling — don't re-send commands.
|
|
209
|
+
const agent = PLAN_AGENTS[this._step];
|
|
210
|
+
const windowHasActivity = this._isWindowActive(agent.window);
|
|
211
|
+
|
|
212
|
+
if (windowHasActivity) {
|
|
213
|
+
log.engine(`Agent ${this._step + 1} (${agent.name}) already active in window ${agent.window}, resuming polling`);
|
|
214
|
+
} else {
|
|
215
|
+
try {
|
|
216
|
+
this._startPlanAgent(this._step);
|
|
217
|
+
} catch (err) {
|
|
218
|
+
this._phase = null;
|
|
219
|
+
return `❌ Failed to start agent: ${err.message}`;
|
|
220
|
+
}
|
|
149
221
|
}
|
|
150
222
|
|
|
151
223
|
// Start polling
|
|
152
224
|
const pollInterval = this.config.phase_poll_interval || 30000;
|
|
153
225
|
this._timer = setInterval(() => this._pollPlanAgent(), pollInterval);
|
|
154
226
|
|
|
155
|
-
const
|
|
156
|
-
return `🚀 Planning started! Agent ${this._step + 1}/${PLAN_AGENTS.length} (${
|
|
227
|
+
const currentAgent = PLAN_AGENTS[this._step];
|
|
228
|
+
return `🚀 Planning ${windowHasActivity ? 'resumed' : 'started'}! Agent ${this._step + 1}/${PLAN_AGENTS.length} (${currentAgent.name}) is running.\n\nI'll notify you as each agent completes. You can ask me anything in the meantime.`;
|
|
157
229
|
}
|
|
158
230
|
|
|
159
231
|
/**
|
|
@@ -174,6 +246,9 @@ class PhaseOrchestrator {
|
|
|
174
246
|
return;
|
|
175
247
|
}
|
|
176
248
|
|
|
249
|
+
// Skip polling while waiting for user input
|
|
250
|
+
if (this._waitingForInput) return;
|
|
251
|
+
|
|
177
252
|
// Check completion
|
|
178
253
|
const result = tmx.checkCompletion(this._session, agent.window, this._lastHash);
|
|
179
254
|
|
|
@@ -186,8 +261,21 @@ class PhaseOrchestrator {
|
|
|
186
261
|
this._stableCount++;
|
|
187
262
|
this._lastHash = result.hash;
|
|
188
263
|
if (this._stableCount >= 3) {
|
|
189
|
-
// Content stable for 3 polls —
|
|
190
|
-
this.
|
|
264
|
+
// Content stable for 3 polls — distinguish "done" vs "waiting for input"
|
|
265
|
+
const pane = tmx.capturePane(this._session, agent.window, 30);
|
|
266
|
+
const tail = pane.split('\n').slice(-15).join('\n');
|
|
267
|
+
|
|
268
|
+
if (/[A-Z][a-z]*ed for \d+/.test(tail)) {
|
|
269
|
+
// Completion message present — truly done
|
|
270
|
+
this._onAgentComplete(agent);
|
|
271
|
+
} else if (this._looksLikeQuestion(tail)) {
|
|
272
|
+
// Agent is asking a question — bridge to user
|
|
273
|
+
this._onAgentWaitingInput(agent, tail);
|
|
274
|
+
} else {
|
|
275
|
+
// Ambiguous — treat as done (agent may have finished without completion message)
|
|
276
|
+
log.engine(`Agent ${agent.name} stable without completion message, treating as done`);
|
|
277
|
+
this._onAgentComplete(agent);
|
|
278
|
+
}
|
|
191
279
|
return;
|
|
192
280
|
}
|
|
193
281
|
} else {
|
|
@@ -196,6 +284,82 @@ class PhaseOrchestrator {
|
|
|
196
284
|
}
|
|
197
285
|
}
|
|
198
286
|
|
|
287
|
+
/**
|
|
288
|
+
* Detect if pane content looks like the agent is asking a question.
|
|
289
|
+
*/
|
|
290
|
+
_looksLikeQuestion(tail) {
|
|
291
|
+
// Has question mark near the end
|
|
292
|
+
if (/[??]\s*$/.test(tail) || /[??]\s*\n\s*❯/m.test(tail)) return true;
|
|
293
|
+
// Has numbered options (1. xxx 2. xxx)
|
|
294
|
+
if (/^\s*[1-9]\.\s+\S/m.test(tail) && /❯/.test(tail)) return true;
|
|
295
|
+
// Has Y/N prompt
|
|
296
|
+
if (/\(Y\/N\)/i.test(tail) || /\(y\/n\)/i.test(tail)) return true;
|
|
297
|
+
// Has "please confirm" or similar
|
|
298
|
+
if (/confirm|choose|select|which|请选择|请确认|是否/i.test(tail) && /❯/.test(tail)) return true;
|
|
299
|
+
return false;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Agent is waiting for user input. Notify user via Telegram and pause polling.
|
|
304
|
+
*/
|
|
305
|
+
async _onAgentWaitingInput(agent, paneContent) {
|
|
306
|
+
this._waitingForInput = true;
|
|
307
|
+
|
|
308
|
+
// Extract the question from pane content (last meaningful lines before ❯)
|
|
309
|
+
const lines = paneContent.split('\n');
|
|
310
|
+
const promptIdx = lines.findLastIndex((l) => /❯/.test(l));
|
|
311
|
+
const questionLines = lines.slice(Math.max(0, promptIdx - 15), promptIdx).filter((l) => l.trim());
|
|
312
|
+
const question = questionLines.join('\n').trim() || '(unable to extract question)';
|
|
313
|
+
|
|
314
|
+
log.engine(`Agent ${agent.name} is asking a question, notifying user`);
|
|
315
|
+
|
|
316
|
+
const notification = `📋 **${agent.name}** is asking:\n\n${question}\n\n↩️ *Reply to this message to answer the agent.*`;
|
|
317
|
+
|
|
318
|
+
try {
|
|
319
|
+
const result = await this._onQuestionAsked(notification);
|
|
320
|
+
if (result && result.messageId) {
|
|
321
|
+
this._waitingMessageId = String(result.messageId);
|
|
322
|
+
}
|
|
323
|
+
} catch (err) {
|
|
324
|
+
log.warn(`Failed to send question notification: ${err.message}`);
|
|
325
|
+
// Auto-continue on failure
|
|
326
|
+
this._waitingForInput = false;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Relay user's reply to the agent's tmux window.
|
|
332
|
+
* Called by router when user replies to the question notification.
|
|
333
|
+
*/
|
|
334
|
+
relayUserInput(text) {
|
|
335
|
+
if (!this._waitingForInput || !this._session) {
|
|
336
|
+
return false;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const agent = PLAN_AGENTS[this._step];
|
|
340
|
+
if (!agent) return false;
|
|
341
|
+
|
|
342
|
+
log.engine(`Relaying user input to ${agent.name}: "${text.slice(0, 50)}..."`);
|
|
343
|
+
|
|
344
|
+
tmx.sendKeysWithEnter(this._session, agent.window, text);
|
|
345
|
+
|
|
346
|
+
// Resume polling
|
|
347
|
+
this._waitingForInput = false;
|
|
348
|
+
this._waitingMessageId = null;
|
|
349
|
+
this._stableCount = 0;
|
|
350
|
+
this._lastHash = '';
|
|
351
|
+
|
|
352
|
+
return true;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Get the Telegram message_id of the current question notification.
|
|
357
|
+
* Used by router to check if a reply-to matches.
|
|
358
|
+
*/
|
|
359
|
+
getWaitingMessageId() {
|
|
360
|
+
return this._waitingMessageId;
|
|
361
|
+
}
|
|
362
|
+
|
|
199
363
|
_onAgentComplete(agent) {
|
|
200
364
|
this._stableCount = 0;
|
|
201
365
|
this._lastHash = '';
|
|
@@ -254,6 +418,17 @@ class PhaseOrchestrator {
|
|
|
254
418
|
this.onComplete('plan', '🎉 Planning phase complete! All 6 agents finished.\n\nRun *develop to start automated development.');
|
|
255
419
|
}
|
|
256
420
|
|
|
421
|
+
/**
|
|
422
|
+
* Check if a tmux window has an active Claude Code session.
|
|
423
|
+
* Returns true if ❯ prompt or processing indicators are visible.
|
|
424
|
+
*/
|
|
425
|
+
_isWindowActive(windowIdx) {
|
|
426
|
+
if (!this._session || !tmx.hasSession(this._session)) return false;
|
|
427
|
+
const pane = tmx.capturePane(this._session, windowIdx, 15);
|
|
428
|
+
// ❯ means Claude Code is running (idle or processing)
|
|
429
|
+
return /❯/.test(pane);
|
|
430
|
+
}
|
|
431
|
+
|
|
257
432
|
_startPlanAgent(stepIdx) {
|
|
258
433
|
const agent = PLAN_AGENTS[stepIdx];
|
|
259
434
|
if (!agent) return;
|
package/lib/gateway/index.js
CHANGED
|
@@ -39,12 +39,16 @@ async function startGateway(opts = {}) {
|
|
|
39
39
|
});
|
|
40
40
|
adapters.push(telegram);
|
|
41
41
|
|
|
42
|
-
// Wire proactive messaging: orchestrator → Telegram
|
|
42
|
+
// Wire proactive messaging: orchestrator → Telegram (returns { messageId })
|
|
43
43
|
router.setSendCallback(null, async (chatId, text) => {
|
|
44
44
|
try {
|
|
45
|
-
await telegram.bot.api.sendMessage(chatId, text, { parse_mode: 'Markdown' });
|
|
45
|
+
const sent = await telegram.bot.api.sendMessage(chatId, text, { parse_mode: 'Markdown' });
|
|
46
|
+
return { messageId: String(sent.message_id) };
|
|
46
47
|
} catch {
|
|
47
|
-
|
|
48
|
+
try {
|
|
49
|
+
const sent = await telegram.bot.api.sendMessage(chatId, text);
|
|
50
|
+
return { messageId: String(sent.message_id) };
|
|
51
|
+
} catch { return null; }
|
|
48
52
|
}
|
|
49
53
|
});
|
|
50
54
|
|
package/lib/gateway/router.js
CHANGED
|
@@ -54,7 +54,14 @@ class Router {
|
|
|
54
54
|
onProgress: (msg) => this._sendProactive(msg),
|
|
55
55
|
onComplete: (phase, summary) => this._sendProactive(summary),
|
|
56
56
|
onError: (phase, err) => this._sendProactive(`❌ Phase ${phase} error: ${err}`),
|
|
57
|
+
onQuestionAsked: (text) => this._sendProactiveWithId(text),
|
|
57
58
|
});
|
|
59
|
+
|
|
60
|
+
// Auto-recover any in-progress phase from previous gateway run
|
|
61
|
+
const projectRoot = engine.resolveProjectRoot();
|
|
62
|
+
if (projectRoot) {
|
|
63
|
+
this.orchestrator.tryRecover(projectRoot);
|
|
64
|
+
}
|
|
58
65
|
}
|
|
59
66
|
|
|
60
67
|
/**
|
|
@@ -74,6 +81,20 @@ class Router {
|
|
|
74
81
|
}
|
|
75
82
|
}
|
|
76
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Send a proactive message and return { messageId } for reply-to tracking.
|
|
86
|
+
*/
|
|
87
|
+
async _sendProactiveWithId(text) {
|
|
88
|
+
if (!this._sendCallback || !this._ownerChatId) return null;
|
|
89
|
+
try {
|
|
90
|
+
const result = await this._sendCallback(this._ownerChatId, text);
|
|
91
|
+
return result; // { messageId }
|
|
92
|
+
} catch (err) {
|
|
93
|
+
log.warn(`Proactive send failed: ${err.message}`);
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
77
98
|
/**
|
|
78
99
|
* Handle an incoming channel message.
|
|
79
100
|
*/
|
|
@@ -107,6 +128,17 @@ class Router {
|
|
|
107
128
|
return this._handleStatusQuery(msg);
|
|
108
129
|
}
|
|
109
130
|
|
|
131
|
+
// ═══ AGENT REPLY — user replied to an agent question notification ═══
|
|
132
|
+
if (msg.replyToMessageId && this.orchestrator.isWaitingForInput()) {
|
|
133
|
+
const waitingId = this.orchestrator.getWaitingMessageId();
|
|
134
|
+
if (waitingId && msg.replyToMessageId === waitingId) {
|
|
135
|
+
const relayed = this.orchestrator.relayUserInput(msg.text);
|
|
136
|
+
if (relayed) {
|
|
137
|
+
return { text: '✅ Reply sent to agent. Resuming...' };
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
110
142
|
// ═══ CANCEL — stop running phase ═══
|
|
111
143
|
if (PHASE_COMMANDS.cancel.test(msg.text.trim())) {
|
|
112
144
|
if (this.orchestrator.isRunning()) {
|
|
@@ -186,13 +218,13 @@ class Router {
|
|
|
186
218
|
_handleStatusQuery(msg) {
|
|
187
219
|
const parts = [];
|
|
188
220
|
|
|
189
|
-
// Orchestrator status
|
|
221
|
+
// Orchestrator status (if actively tracking)
|
|
190
222
|
if (this.orchestrator.isRunning()) {
|
|
191
223
|
const status = this.orchestrator.getStatus();
|
|
192
224
|
parts.push(status.message);
|
|
193
225
|
}
|
|
194
226
|
|
|
195
|
-
// Read project
|
|
227
|
+
// Read project state from YAML — works even if orchestrator isn't tracking
|
|
196
228
|
const projectRoot = engine.resolveProjectRoot();
|
|
197
229
|
if (projectRoot) {
|
|
198
230
|
const focusPath = path.join(projectRoot, '.yuri', 'focus.yaml');
|
|
@@ -201,6 +233,21 @@ class Router {
|
|
|
201
233
|
const focus = yaml.load(fs.readFileSync(focusPath, 'utf8')) || {};
|
|
202
234
|
if (focus.pulse) parts.push(`Pulse: ${focus.pulse}`);
|
|
203
235
|
if (focus.step) parts.push(`Step: ${focus.step}`);
|
|
236
|
+
|
|
237
|
+
// If YAML says in_progress but orchestrator isn't tracking, warn
|
|
238
|
+
if (!this.orchestrator.isRunning() && focus.phase) {
|
|
239
|
+
const phasePaths = {
|
|
240
|
+
2: path.join(projectRoot, '.yuri', 'state', 'phase2.yaml'),
|
|
241
|
+
3: path.join(projectRoot, '.yuri', 'state', 'phase3.yaml'),
|
|
242
|
+
};
|
|
243
|
+
const statePath = phasePaths[focus.phase];
|
|
244
|
+
if (statePath && fs.existsSync(statePath)) {
|
|
245
|
+
const state = yaml.load(fs.readFileSync(statePath, 'utf8')) || {};
|
|
246
|
+
if (state.status === 'in_progress') {
|
|
247
|
+
parts.push(`\n⚠️ Phase ${focus.phase} was in progress but orchestrator lost track (gateway restarted?). Send *plan or *develop to reconnect.`);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
204
251
|
} catch { /* ok */ }
|
|
205
252
|
}
|
|
206
253
|
}
|