orchestrix-yuri 3.2.1 → 3.3.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.
@@ -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,16 @@ 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; }
51
58
 
52
59
  /**
53
60
  * Try to recover an in-progress phase from YAML state + tmux session.
@@ -105,6 +112,43 @@ class PhaseOrchestrator {
105
112
  }
106
113
  }
107
114
 
115
+ /**
116
+ * Capture the current agent's tmux pane content for Claude context injection.
117
+ * Returns a formatted string or null if no agent is active.
118
+ */
119
+ captureCurrentAgentContext() {
120
+ if (!this._phase || !this._session || !tmx.hasSession(this._session)) {
121
+ return null;
122
+ }
123
+
124
+ if (this._phase === 'plan') {
125
+ const agent = PLAN_AGENTS[this._step];
126
+ if (!agent) return null;
127
+
128
+ const pane = tmx.capturePane(this._session, agent.window, 40);
129
+ if (!pane.trim()) return null;
130
+
131
+ const waiting = this._waitingForInput ? ' (WAITING FOR YOUR INPUT)' : '';
132
+ return `[LIVE AGENT CONTEXT] Phase: plan, Agent: ${agent.name} (${this._step + 1}/${PLAN_AGENTS.length})${waiting}\n` +
133
+ `tmux session: ${this._session}, window: ${agent.window}\n` +
134
+ `--- Agent output (last 40 lines) ---\n${pane}\n--- End agent output ---`;
135
+ }
136
+
137
+ if (this._phase === 'develop') {
138
+ // Capture all 4 dev windows briefly
139
+ const windows = ['Architect', 'SM', 'Dev', 'QA'];
140
+ const summaries = [];
141
+ for (let w = 0; w < 4; w++) {
142
+ const tail = tmx.capturePane(this._session, w, 5);
143
+ const lastLine = tail.split('\n').filter((l) => l.trim()).pop() || '(empty)';
144
+ summaries.push(` Window ${w} (${windows[w]}): ${lastLine.trim().slice(0, 80)}`);
145
+ }
146
+ return `[LIVE AGENT CONTEXT] Phase: develop, Session: ${this._session}\n${summaries.join('\n')}`;
147
+ }
148
+
149
+ return null;
150
+ }
151
+
108
152
  getStatus() {
109
153
  if (!this._phase) {
110
154
  return { phase: null, message: 'No phase is running.' };
@@ -239,6 +283,9 @@ class PhaseOrchestrator {
239
283
  return;
240
284
  }
241
285
 
286
+ // Skip polling while waiting for user input
287
+ if (this._waitingForInput) return;
288
+
242
289
  // Check completion
243
290
  const result = tmx.checkCompletion(this._session, agent.window, this._lastHash);
244
291
 
@@ -251,8 +298,21 @@ class PhaseOrchestrator {
251
298
  this._stableCount++;
252
299
  this._lastHash = result.hash;
253
300
  if (this._stableCount >= 3) {
254
- // Content stable for 3 polls — agent likely done
255
- this._onAgentComplete(agent);
301
+ // Content stable for 3 polls — distinguish "done" vs "waiting for input"
302
+ const pane = tmx.capturePane(this._session, agent.window, 30);
303
+ const tail = pane.split('\n').slice(-15).join('\n');
304
+
305
+ if (/[A-Z][a-z]*ed for \d+/.test(tail)) {
306
+ // Completion message present — truly done
307
+ this._onAgentComplete(agent);
308
+ } else if (this._looksLikeQuestion(tail)) {
309
+ // Agent is asking a question — bridge to user
310
+ this._onAgentWaitingInput(agent, tail);
311
+ } else {
312
+ // Ambiguous — treat as done (agent may have finished without completion message)
313
+ log.engine(`Agent ${agent.name} stable without completion message, treating as done`);
314
+ this._onAgentComplete(agent);
315
+ }
256
316
  return;
257
317
  }
258
318
  } else {
@@ -261,6 +321,82 @@ class PhaseOrchestrator {
261
321
  }
262
322
  }
263
323
 
324
+ /**
325
+ * Detect if pane content looks like the agent is asking a question.
326
+ */
327
+ _looksLikeQuestion(tail) {
328
+ // Has question mark near the end
329
+ if (/[??]\s*$/.test(tail) || /[??]\s*\n\s*❯/m.test(tail)) return true;
330
+ // Has numbered options (1. xxx 2. xxx)
331
+ if (/^\s*[1-9]\.\s+\S/m.test(tail) && /❯/.test(tail)) return true;
332
+ // Has Y/N prompt
333
+ if (/\(Y\/N\)/i.test(tail) || /\(y\/n\)/i.test(tail)) return true;
334
+ // Has "please confirm" or similar
335
+ if (/confirm|choose|select|which|请选择|请确认|是否/i.test(tail) && /❯/.test(tail)) return true;
336
+ return false;
337
+ }
338
+
339
+ /**
340
+ * Agent is waiting for user input. Notify user via Telegram and pause polling.
341
+ */
342
+ async _onAgentWaitingInput(agent, paneContent) {
343
+ this._waitingForInput = true;
344
+
345
+ // Extract the question from pane content (last meaningful lines before ❯)
346
+ const lines = paneContent.split('\n');
347
+ const promptIdx = lines.findLastIndex((l) => /❯/.test(l));
348
+ const questionLines = lines.slice(Math.max(0, promptIdx - 15), promptIdx).filter((l) => l.trim());
349
+ const question = questionLines.join('\n').trim() || '(unable to extract question)';
350
+
351
+ log.engine(`Agent ${agent.name} is asking a question, notifying user`);
352
+
353
+ const notification = `📋 **${agent.name}** is asking:\n\n${question}\n\n↩️ *Reply to this message to answer the agent.*`;
354
+
355
+ try {
356
+ const result = await this._onQuestionAsked(notification);
357
+ if (result && result.messageId) {
358
+ this._waitingMessageId = String(result.messageId);
359
+ }
360
+ } catch (err) {
361
+ log.warn(`Failed to send question notification: ${err.message}`);
362
+ // Auto-continue on failure
363
+ this._waitingForInput = false;
364
+ }
365
+ }
366
+
367
+ /**
368
+ * Relay user's reply to the agent's tmux window.
369
+ * Called by router when user replies to the question notification.
370
+ */
371
+ relayUserInput(text) {
372
+ if (!this._waitingForInput || !this._session) {
373
+ return false;
374
+ }
375
+
376
+ const agent = PLAN_AGENTS[this._step];
377
+ if (!agent) return false;
378
+
379
+ log.engine(`Relaying user input to ${agent.name}: "${text.slice(0, 50)}..."`);
380
+
381
+ tmx.sendKeysWithEnter(this._session, agent.window, text);
382
+
383
+ // Resume polling
384
+ this._waitingForInput = false;
385
+ this._waitingMessageId = null;
386
+ this._stableCount = 0;
387
+ this._lastHash = '';
388
+
389
+ return true;
390
+ }
391
+
392
+ /**
393
+ * Get the Telegram message_id of the current question notification.
394
+ * Used by router to check if a reply-to matches.
395
+ */
396
+ getWaitingMessageId() {
397
+ return this._waitingMessageId;
398
+ }
399
+
264
400
  _onAgentComplete(agent) {
265
401
  this._stableCount = 0;
266
402
  this._lastHash = '';
@@ -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
- await telegram.bot.api.sendMessage(chatId, text).catch(() => {});
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
 
@@ -54,6 +54,7 @@ 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
  });
58
59
 
59
60
  // Auto-recover any in-progress phase from previous gateway run
@@ -80,6 +81,20 @@ class Router {
80
81
  }
81
82
  }
82
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
+
83
98
  /**
84
99
  * Handle an incoming channel message.
85
100
  */
@@ -113,6 +128,17 @@ class Router {
113
128
  return this._handleStatusQuery(msg);
114
129
  }
115
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
+
116
142
  // ═══ CANCEL — stop running phase ═══
117
143
  if (PHASE_COMMANDS.cancel.test(msg.text.trim())) {
118
144
  if (this.orchestrator.isRunning()) {
@@ -244,7 +270,15 @@ class Router {
244
270
  await this._runCatchUp();
245
271
 
246
272
  const projectRoot = engine.resolveProjectRoot();
247
- const prompt = engine.composePrompt(msg.text);
273
+
274
+ // If a phase is running, inject tmux pane context so Claude can see agent state
275
+ let prompt = engine.composePrompt(msg.text);
276
+ if (this.orchestrator.isRunning()) {
277
+ const agentContext = this.orchestrator.captureCurrentAgentContext();
278
+ if (agentContext) {
279
+ prompt = `${agentContext}\n\n---\n\nUser message: ${msg.text}`;
280
+ }
281
+ }
248
282
 
249
283
  log.router(`Processing: "${msg.text.slice(0, 80)}..." → cwd: ${projectRoot || '~'}`);
250
284
  const result = await engine.callClaude({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orchestrix-yuri",
3
- "version": "3.2.1",
3
+ "version": "3.3.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": {