orchestrix-yuri 2.4.0 → 2.6.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.
@@ -174,15 +174,39 @@ const PROCESSING_RE = /●/;
174
174
  */
175
175
  function stripChrome(raw) {
176
176
  return raw
177
- .replace(/\x1B\[[0-9;]*[a-zA-Z]/g, '') // ANSI CSI escapes
178
- .replace(/\x1B\].*?\x07/g, '') // OSC sequences
179
- .replace(/[○●◐◑]/g, '') // TUI state indicators
180
- .replace(/[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/g, '') // Braille spinner frames
181
- .replace(/[⏵━─█·…→❯]/g, '') // UI decoration chars
182
- .replace(/^\s*\d+\s*[│|]\s*/gm, '') // line-number gutter
183
- .replace(/^.*[A-Z][a-z]*ed for \d+.*$/gm, '') // completion stats (all verbs)
184
- .replace(/^.*[A-Z][a-z]*ing\.{3}.*$/gm, '') // spinner verb lines ("Baking...")
185
- .replace(/^\s*$/gm, '') // blank lines
177
+ // ANSI escape sequences
178
+ .replace(/\x1B\[[0-9;]*[a-zA-Z]/g, '')
179
+ .replace(/\x1B\].*?\x07/g, '')
180
+ // TUI indicators and decorations
181
+ .replace(/[○●◐◑⏺]/g, '')
182
+ .replace(/[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/g, '')
183
+ .replace(/[⏵━─█·…→]/g, '')
184
+ // Claude Code banner
185
+ .replace(/^.*▐▛.*$/gm, '')
186
+ .replace(/^.*▝▜.*$/gm, '')
187
+ .replace(/^.*▘▘.*$/gm, '')
188
+ .replace(/^.*Claude Code v[\d.]+.*$/gm, '')
189
+ .replace(/^.*Opus.*context.*$/gm, '')
190
+ // Status line elements
191
+ .replace(/^.*bypass permissions.*$/gm, '')
192
+ .replace(/^.*shift\+tab to cycle.*$/gm, '')
193
+ .replace(/^.*◐\s*(min|medium|max|low|high).*$/gm, '')
194
+ .replace(/^.*\/effort.*$/gm, '')
195
+ .replace(/^.*Proxy\s*-\s*(On|Off).*$/gm, '')
196
+ // Shell commands that leaked
197
+ .replace(/^.*export\s+CLAUDE_AUTOCOMPACT.*$/gm, '')
198
+ .replace(/^.*dangerously-skip-permissions.*$/gm, '')
199
+ // Completion stats and spinner verbs
200
+ .replace(/^.*[A-Z][a-z]*ed for \d+.*$/gm, '')
201
+ .replace(/^.*[A-Z][a-z]*ing\.{3}.*$/gm, '')
202
+ // Prompt cursor and line decorations
203
+ .replace(/^❯\s*$/gm, '')
204
+ .replace(/^─+$/gm, '')
205
+ // Line-number gutter
206
+ .replace(/^\s*\d+\s*[│|]\s*/gm, '')
207
+ // Collapse blank lines
208
+ .replace(/^\s*$/gm, '')
209
+ .replace(/\n{3,}/g, '\n\n')
186
210
  .trim();
187
211
  }
188
212
 
@@ -204,39 +228,31 @@ function paneTail(name, n) {
204
228
  }
205
229
 
206
230
  /**
207
- * Detect Claude Code's current state from pane output.
231
+ * Check if Claude Code has started up and is ready for input.
232
+ * Used ONLY during session initialization — looks for the ❯ input prompt
233
+ * which appears once Claude Code has fully loaded.
208
234
  *
209
- * @returns {'idle'|'processing'|'complete'|'unknown'}
235
+ * DO NOT use this for response completion detection — ❯ is always visible.
210
236
  */
211
- function detectState(name) {
237
+ function isStarted(name) {
212
238
  const tail = paneTail(name, 15);
239
+ return IDLE_RE.test(tail);
240
+ }
213
241
 
214
- // Priority 1: Completion message — most reliable signal
215
- // e.g. "Baked for 31s", "Worked for 2m 45s"
216
- if (COMPLETION_RE.test(tail)) {
217
- return 'complete';
218
- }
219
-
220
- // Priority 2: Idle indicator — waiting for input
221
- if (IDLE_RE.test(tail)) {
222
- return 'idle';
223
- }
224
-
225
- // Priority 3: Processing indicator — still working
226
- if (PROCESSING_RE.test(tail) || BRAILLE_SPINNER.test(tail)) {
227
- return 'processing';
228
- }
229
-
230
- return 'unknown';
242
+ /**
243
+ * Check if a completion message is present in the pane output.
244
+ * e.g. "Baked for 31s", "Worked for 2m 45s"
245
+ * This is the most reliable signal that Claude has finished responding.
246
+ */
247
+ function hasCompletionMessage(text) {
248
+ return COMPLETION_RE.test(text);
231
249
  }
232
250
 
233
251
  /**
234
- * Detect if Claude Code is idle (ready for input).
235
- * Checks for ○ idle indicator or completion message.
252
+ * Check if Claude Code is actively processing ( spinner visible).
236
253
  */
237
- function isIdle(name) {
238
- const state = detectState(name);
239
- return state === 'idle' || state === 'complete';
254
+ function isProcessing(text) {
255
+ return PROCESSING_RE.test(text) || BRAILLE_SPINNER.test(text);
240
256
  }
241
257
 
242
258
  // ── Context Management ─────────────────────────────────────────────────────────
@@ -304,7 +320,7 @@ async function proactiveCompact(name) {
304
320
  log.tmux('Proactive /compact triggered');
305
321
  injectMessage(name, '/compact focus on the most recent user conversation and any pending operations');
306
322
 
307
- const ok = await waitForIdle(name, 120000); // compact can take up to 2min
323
+ const ok = await waitForReady(name, 120000); // compact can take up to 2min
308
324
  if (ok) {
309
325
  _messageCount = 0;
310
326
  log.tmux('Proactive /compact completed');
@@ -343,46 +359,45 @@ async function createSession(engineConfig) {
343
359
  tmux(`set-option -t ${sessionName} history-limit ${HISTORY_LIMIT}`);
344
360
  log.tmux(`Session "${sessionName}" created, launching Claude Code...`);
345
361
 
346
- // Set auto-compact threshold to 80% (default is 95%)
362
+ // Set env var and launch Claude Code in a single command to keep pane clean.
363
+ // Using && chains avoids separate shell prompt lines polluting capture output.
347
364
  const compactPct = engineConfig.autocompact_pct || 80;
348
- tmux(`send-keys -t ${sessionName}:0 'export CLAUDE_AUTOCOMPACT_PCT_OVERRIDE=${compactPct}' Enter`);
349
-
350
- // Launch Claude Code in interactive mode
351
- tmux(`send-keys -t ${sessionName}:0 '"${binary}" --dangerously-skip-permissions' Enter`);
365
+ tmux(`send-keys -t ${sessionName}:0 'export CLAUDE_AUTOCOMPACT_PCT_OVERRIDE=${compactPct} && "${binary}" --dangerously-skip-permissions' Enter`);
352
366
 
353
- // Wait for Claude Code to initialize (detect idle indicator ○)
354
- // Default 60s — Claude Code needs time to load CLAUDE.md, connect MCP servers, etc.
367
+ // Wait for Claude Code to initialize (detect prompt)
355
368
  const startupTimeout = engineConfig.startup_timeout || 60000;
356
- log.tmux(`Waiting for Claude Code to become idle (timeout: ${startupTimeout / 1000}s)...`);
357
- const started = await waitForIdle(sessionName, startupTimeout);
369
+ log.tmux(`Waiting for Claude Code to start (timeout: ${startupTimeout / 1000}s)...`);
370
+ const started = await waitForReady(sessionName, startupTimeout);
358
371
  if (!started) {
359
- // Don't kill session on failure — let user debug with tmux attach
360
372
  const tail = paneTail(sessionName, 10);
361
- log.error(`Claude Code did not become idle within ${startupTimeout / 1000}s`);
373
+ log.error(`Claude Code did not start within ${startupTimeout / 1000}s`);
362
374
  log.error(`Last pane output:\n${tail}`);
363
375
  log.info(`Debug: tmux attach -t ${sessionName}`);
364
- throw new Error(`Claude Code did not become idle within ${startupTimeout / 1000}s`);
376
+ throw new Error(`Claude Code did not start within ${startupTimeout / 1000}s`);
365
377
  }
366
378
 
367
- // Send L1 context as the initial system message.
368
- // Channel Mode Instructions are already in CLAUDE.md (survives compact),
369
- // so we only inject L1 global memory here to prime the session.
370
- const l1 = loadL1Context();
371
- if (l1) {
372
- log.tmux('Injecting L1 context...');
373
- await injectMessage(sessionName, l1);
374
- await waitForIdle(sessionName, 120000); // allow up to 2min for L1 processing
375
- }
379
+ // Clear tmux scrollback so session setup commands don't pollute response capture.
380
+ // The pane currently contains: shell commands, banner, status line.
381
+ // We want captureResponse to only see content from user messages onward.
382
+ await new Promise((r) => setTimeout(r, 1000)); // let TUI fully render
383
+ tmuxSafe(`clear-history -t ${sessionName}:0`);
384
+
385
+ // NOTE: We do NOT inject L1 context here. Instead, composePrompt() prepends
386
+ // L1 to the first user message. This avoids: (1) scrollback pollution from
387
+ // a huge YAML block, (2) waitForReady returning immediately because ❯ is
388
+ // always visible, (3) race conditions between L1 processing and first message.
376
389
 
377
390
  _sessionReady = true;
378
391
  log.tmux(`Session "${sessionName}" ready (cwd: ${projectRoot})`);
379
392
  }
380
393
 
381
394
  /**
382
- * Wait for Claude Code to become idle.
383
- * @returns {Promise<boolean>} true if idle detected, false if timeout
395
+ * Wait for Claude Code to be ready (❯ prompt visible).
396
+ * Used for session init and after /compact NOT for response capture.
397
+ *
398
+ * @returns {Promise<boolean>} true if ready detected, false if timeout
384
399
  */
385
- function waitForIdle(name, timeoutMs) {
400
+ function waitForReady(name, timeoutMs) {
386
401
  const pollInterval = 2000;
387
402
  return new Promise((resolve) => {
388
403
  const deadline = Date.now() + timeoutMs;
@@ -394,7 +409,7 @@ function waitForIdle(name, timeoutMs) {
394
409
  return resolve(false);
395
410
  }
396
411
 
397
- if (isIdle(name)) {
412
+ if (isStarted(name)) {
398
413
  return resolve(true);
399
414
  }
400
415
  setTimeout(poll, pollInterval);
@@ -422,10 +437,14 @@ function injectMessage(name, text) {
422
437
  /**
423
438
  * Capture the response after injecting a message.
424
439
  *
425
- * Detection priority:
440
+ * Detection strategy (❯ prompt is always visible, so we CANNOT use idle detection):
426
441
  * P1: Completion message — "[Verb]ed for [N]s/m" (e.g. "Baked for 31s")
427
- * P2: Idle indicator appears in pane tail
428
- * P3: Content stability — 3 consecutive polls with identical MD5 hash
442
+ * Most reliable signal. Appears exactly once when Claude finishes.
443
+ * P2: Content stability — pane output unchanged for N consecutive polls.
444
+ * Fallback for edge cases where completion message is missed.
445
+ *
446
+ * We also track whether content has changed since injection (via marker)
447
+ * to avoid returning before Claude has even started responding.
429
448
  */
430
449
  async function captureResponse(name, marker, engineConfig) {
431
450
  const timeout = engineConfig.timeout || 300000;
@@ -435,7 +454,12 @@ async function captureResponse(name, marker, engineConfig) {
435
454
  const deadline = Date.now() + timeout;
436
455
  let lastHash = '';
437
456
  let stableCount = 0;
438
- let sawProcessing = false;
457
+ let contentChanged = false;
458
+
459
+ // Capture baseline right after injection
460
+ const baselineRaw = capturePaneRaw(name, 500);
461
+ const baselineHash = crypto.createHash('md5').update(baselineRaw).digest('hex');
462
+ lastHash = baselineHash;
439
463
 
440
464
  return new Promise((resolve) => {
441
465
  const poll = () => {
@@ -451,31 +475,22 @@ async function captureResponse(name, marker, engineConfig) {
451
475
  return resolve({ reply: '❌ Claude Code session terminated unexpectedly.', raw: '' });
452
476
  }
453
477
 
454
- const state = detectState(name);
455
478
  const raw = capturePaneRaw(name, 500);
456
479
  const hash = crypto.createHash('md5').update(raw).digest('hex');
457
480
 
458
- // Track that Claude has started processing (● appeared)
459
- // This prevents premature completion detection if is still visible
460
- // from the previous idle state before Claude begins processing.
461
- if (state === 'processing') {
462
- sawProcessing = true;
463
- stableCount = 0;
464
- lastHash = hash;
465
- return setTimeout(poll, pollInterval);
481
+ // Track if content has changed since injection
482
+ if (hash !== baselineHash) {
483
+ contentChanged = true;
466
484
  }
467
485
 
468
486
  // P1: Completion message — most reliable done signal
469
- if (state === 'complete' && sawProcessing) {
487
+ // Only check after content has changed (Claude has started responding)
488
+ if (contentChanged && hasCompletionMessage(paneTail(name, 15))) {
470
489
  return resolve(extractResponse(raw, marker));
471
490
  }
472
491
 
473
- // P2: Idle indicatordone if we saw processing start
474
- if (state === 'idle' && sawProcessing) {
475
- return resolve(extractResponse(raw, marker));
476
- }
477
-
478
- // P4: Content stability fallback
492
+ // P2: Content stabilitypane unchanged for N polls
493
+ // Only trigger after content has changed from baseline
479
494
  if (hash === lastHash) {
480
495
  stableCount++;
481
496
  } else {
@@ -483,7 +498,7 @@ async function captureResponse(name, marker, engineConfig) {
483
498
  lastHash = hash;
484
499
  }
485
500
 
486
- if (stableCount >= stableThreshold && sawProcessing) {
501
+ if (contentChanged && stableCount >= stableThreshold) {
487
502
  log.tmux('Response detected via content stability');
488
503
  return resolve(extractResponse(raw, marker));
489
504
  }
@@ -492,7 +507,6 @@ async function captureResponse(name, marker, engineConfig) {
492
507
  };
493
508
 
494
509
  // Initial delay: give Claude time to start processing
495
- // before first poll (avoids false-positive idle detection)
496
510
  setTimeout(poll, Math.max(pollInterval, 3000));
497
511
  });
498
512
  }
@@ -515,12 +529,12 @@ function extractResponse(raw, marker) {
515
529
 
516
530
  let responseText;
517
531
  if (markerIdx >= 0) {
518
- // Skip the marker line and any immediate echo of the user message
532
+ log.tmux(`Marker found at line ${markerIdx}/${lines.length}`);
519
533
  const afterMarker = lines.slice(markerIdx + 1).join('\n');
520
534
  responseText = stripChrome(afterMarker);
521
535
  } else {
522
- // Fallback: take last chunk of output, strip chrome
523
- const tail = lines.slice(-100).join('\n');
536
+ log.warn(`Marker not found in pane output, using last 50 lines as fallback`);
537
+ const tail = lines.slice(-50).join('\n');
524
538
  responseText = stripChrome(tail);
525
539
  }
526
540
 
@@ -626,14 +640,21 @@ async function callClaude(opts) {
626
640
 
627
641
  /**
628
642
  * Compose prompt for the persistent session.
629
- * Only sends the raw user message the session already has L1 context
630
- * from initialization, and Claude Code maintains its own conversation history.
643
+ * First message includes L1 context to prime the session.
644
+ * Subsequent messages send only the raw user text.
631
645
  *
632
646
  * @param {string} userMessage - The user's message text
633
647
  * @param {Array} _chatHistory - Unused (Claude keeps its own context)
634
648
  * @returns {string}
635
649
  */
636
650
  function composePrompt(userMessage, _chatHistory) {
651
+ // First message: prepend L1 context so Claude knows who it is
652
+ if (_messageCount === 0) {
653
+ const l1 = loadL1Context();
654
+ if (l1) {
655
+ return `${l1}\n\n---\n\nUser message: ${userMessage}`;
656
+ }
657
+ }
637
658
  return userMessage;
638
659
  }
639
660
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orchestrix-yuri",
3
- "version": "2.4.0",
3
+ "version": "2.6.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": {