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
- // Start first (or resumed) agent
144
- try {
145
- this._startPlanAgent(this._step);
146
- } catch (err) {
147
- this._phase = null;
148
- return `❌ Failed to start agent: ${err.message}`;
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 agent = PLAN_AGENTS[this._step];
156
- return `🚀 Planning started! Agent ${this._step + 1}/${PLAN_AGENTS.length} (${agent.name}) is running.\n\nI'll notify you as each agent completes. You can ask me anything in the meantime.`;
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 — agent likely done
190
- this._onAgentComplete(agent);
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;
@@ -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,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 focus for additional context
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orchestrix-yuri",
3
- "version": "3.2.0",
3
+ "version": "3.3.0",
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": {