@openagents-org/agent-launcher 0.2.130 → 0.2.132

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.
@@ -36,6 +36,8 @@ class ClaudeAdapter extends BaseAdapter {
36
36
  this._channelSessions = {}; // channel → Claude CLI session_id
37
37
  this._channelProcesses = {}; // channel → child process
38
38
  this._stoppingChannels = new Set();
39
+ this._persistentProcs = {}; // channel → { proc, lineBuffer, pendingLines, idleTimer, messageResolve }
40
+ this._IDLE_TIMEOUT_MS = 60 * 60 * 1000; // 1 hour
39
41
  this._sessionsFile = path.join(
40
42
  os.homedir(), '.openagents', 'sessions',
41
43
  `${this.workspaceId}_${this.agentName}.json`
@@ -157,6 +159,9 @@ class ClaudeAdapter extends BaseAdapter {
157
159
  * 5s to actually finish the cleanup before the parent exits.
158
160
  */
159
161
  stop() {
162
+ for (const channel of Object.keys(this._persistentProcs)) {
163
+ this._killPersistentProc(channel);
164
+ }
160
165
  this._stopAllProcesses(
161
166
  'Task interrupted — daemon restarting. Send another message to continue.'
162
167
  ).catch(() => {});
@@ -250,6 +255,9 @@ class ClaudeAdapter extends BaseAdapter {
250
255
  }
251
256
 
252
257
  async _stopAllProcesses(completionMessage = 'Execution stopped.') {
258
+ for (const channel of Object.keys(this._persistentProcs)) {
259
+ this._killPersistentProc(channel);
260
+ }
253
261
  const entries = Object.entries(this._channelProcesses);
254
262
  if (!entries.length) return;
255
263
  this._log(`Stopping ${entries.length} running process(es)...`);
@@ -258,9 +266,6 @@ class ClaudeAdapter extends BaseAdapter {
258
266
  await this._stopProcess(proc);
259
267
  delete this._channelProcesses[channel];
260
268
  delete this._channelQueues[channel];
261
- // Post as a chat message (not status) so the channel's last event
262
- // type is non-status — the workspace UI then transitions out of
263
- // "agent is working" state instead of shimmering forever.
264
269
  try {
265
270
  await this.sendResponse(channel, completionMessage);
266
271
  } catch {}
@@ -358,7 +363,7 @@ class ClaudeAdapter extends BaseAdapter {
358
363
  return null;
359
364
  }
360
365
 
361
- _buildClaudeCmd(prompt, channelName, { skipResume = false } = {}) {
366
+ _buildClaudeCmd(prompt, channelName, { skipResume = false, browserEnabled = false } = {}) {
362
367
  const claudeBin = this._findClaudeBinary();
363
368
  if (!claudeBin) {
364
369
  throw new Error('claude CLI not found. Install with: curl -fsSL https://claude.ai/install.sh | bash');
@@ -369,6 +374,7 @@ class ClaudeAdapter extends BaseAdapter {
369
374
  workspaceId: this.workspaceId,
370
375
  channelName,
371
376
  mode: this._mode,
377
+ browserEnabled,
372
378
  });
373
379
 
374
380
  // In skills mode, replace MCP tool references with curl-based instructions
@@ -432,6 +438,7 @@ class ClaudeAdapter extends BaseAdapter {
432
438
  agentName: this.agentName,
433
439
  channelName,
434
440
  disabledModules: this.disabledModules,
441
+ browserEnabled: this._browserEnabledCache === true,
435
442
  });
436
443
  fs.writeFileSync(skillFile, skillContent, 'utf-8');
437
444
  this._log(`Wrote workspace skill to ${skillFile}`);
@@ -565,6 +572,231 @@ class ClaudeAdapter extends BaseAdapter {
565
572
  return { cmd, mcpConfigFile: mcpFile };
566
573
  }
567
574
 
575
+ /**
576
+ * Kill a persistent process for a channel and clean up its idle timer.
577
+ */
578
+ _killPersistentProc(channel) {
579
+ const pp = this._persistentProcs[channel];
580
+ if (!pp) return;
581
+ if (pp.idleTimer) clearTimeout(pp.idleTimer);
582
+ this._stopProcess(pp.proc).catch(() => {});
583
+ delete this._persistentProcs[channel];
584
+ }
585
+
586
+ /**
587
+ * Reset the idle timer for a persistent process. Kills the process
588
+ * after _IDLE_TIMEOUT_MS of inactivity.
589
+ */
590
+ _resetIdleTimer(channel) {
591
+ const pp = this._persistentProcs[channel];
592
+ if (!pp) return;
593
+ if (pp.idleTimer) clearTimeout(pp.idleTimer);
594
+ pp.idleTimer = setTimeout(() => {
595
+ this._log(`Persistent process idle for ${this._IDLE_TIMEOUT_MS / 60000}min, releasing ${channel}`);
596
+ this._killPersistentProc(channel);
597
+ }, this._IDLE_TIMEOUT_MS);
598
+ }
599
+
600
+ /**
601
+ * Spawn a persistent Claude process for a channel that accepts messages
602
+ * via stdin (--input-format stream-json). Returns the persistent proc entry.
603
+ */
604
+ _spawnPersistentProc(channel, cmd, cleanEnv) {
605
+ // Remove -p and its argument from cmd — prompts go via stdin
606
+ const filteredCmd = [];
607
+ for (let i = 0; i < cmd.length; i++) {
608
+ if (cmd[i] === '-p' || cmd[i] === '--print') {
609
+ // -p in stream-json mode is just a flag (no argument to skip)
610
+ // but _buildClaudeCmd passes [-p, prompt] — skip both
611
+ if (i + 1 < cmd.length && !cmd[i + 1].startsWith('-')) {
612
+ i++; // skip the prompt argument
613
+ }
614
+ continue;
615
+ }
616
+ filteredCmd.push(cmd[i]);
617
+ }
618
+ // Add stdin streaming flags
619
+ filteredCmd.push('--input-format', 'stream-json');
620
+ // Ensure -p is present (required for stream-json)
621
+ if (!filteredCmd.includes('-p') && !filteredCmd.includes('--print')) {
622
+ filteredCmd.splice(1, 0, '-p');
623
+ }
624
+
625
+ const resolved = this._resolveToNodeCmd(filteredCmd[0]);
626
+ let finalCmd = filteredCmd;
627
+ if (resolved) {
628
+ finalCmd = [resolved[0], resolved[1], ...filteredCmd.slice(1)];
629
+ } else if (IS_WINDOWS && filteredCmd[0].toLowerCase().endsWith('.cmd')) {
630
+ finalCmd = ['cmd.exe', '/c', ...filteredCmd];
631
+ }
632
+
633
+ const proc = spawn(finalCmd[0], finalCmd.slice(1), {
634
+ stdio: ['pipe', 'pipe', 'pipe'],
635
+ env: cleanEnv,
636
+ cwd: this.workingDir,
637
+ detached: !IS_WINDOWS,
638
+ windowsHide: true,
639
+ });
640
+
641
+ const pp = {
642
+ proc,
643
+ lineBuffer: '',
644
+ pendingLines: Promise.resolve(),
645
+ idleTimer: null,
646
+ messageResolve: null,
647
+ msgChannel: channel,
648
+ lastResponseText: [],
649
+ hasToolUseSinceLastText: false,
650
+ postedThinking: false,
651
+ everPostedAnything: false,
652
+ stderrBuf: '',
653
+ alive: true,
654
+ };
655
+
656
+ if (proc.stderr) {
657
+ proc.stderr.on('data', (chunk) => { pp.stderrBuf += chunk.toString('utf-8'); });
658
+ }
659
+
660
+ const processLine = async (line) => {
661
+ line = line.trim();
662
+ if (!line) return;
663
+ let event;
664
+ try { event = JSON.parse(line); } catch { return; }
665
+ const eventType = event.type;
666
+
667
+ if (eventType === 'assistant') {
668
+ const blocks = (event.message || {}).content || [];
669
+ for (const block of blocks) {
670
+ if (block.type === 'text' && block.text && block.text.trim()) {
671
+ if (pp.hasToolUseSinceLastText) {
672
+ pp.lastResponseText.length = 0;
673
+ pp.hasToolUseSinceLastText = false;
674
+ }
675
+ pp.lastResponseText.push(block.text.trim());
676
+ pp.postedThinking = true;
677
+ pp.everPostedAnything = true;
678
+ try { await this.sendThinking(pp.msgChannel, block.text.trim()); } catch {}
679
+ } else if (block.type === 'tool_use') {
680
+ pp.hasToolUseSinceLastText = true;
681
+ pp.postedThinking = false;
682
+ pp.lastResponseText.length = 0;
683
+ const toolName = block.name || '';
684
+ if (toolName === 'TodoWrite' && block.input && block.input.todos) {
685
+ try {
686
+ const wsTodos = block.input.todos.map((t) => ({
687
+ content: t.content, status: t.status || 'pending', assignee: t.assignee,
688
+ }));
689
+ await this.sendTodos(pp.msgChannel, wsTodos);
690
+ } catch {}
691
+ }
692
+ let inputPreview = '';
693
+ if (block.input && typeof block.input === 'object') {
694
+ const inp = block.input;
695
+ if (inp.command) inputPreview = inp.command;
696
+ else if (inp.file_path || inp.path) inputPreview = inp.file_path || inp.path;
697
+ else if (inp.pattern) inputPreview = inp.pattern;
698
+ else if (inp.query) inputPreview = inp.query;
699
+ else if (inp.url) inputPreview = inp.url;
700
+ else if (inp.content) inputPreview = inp.content.slice(0, 100);
701
+ else inputPreview = JSON.stringify(inp).slice(0, 150);
702
+ } else {
703
+ inputPreview = String(block.input || '').slice(0, 150);
704
+ }
705
+ await this.sendStatus(pp.msgChannel, `${toolName} › ${inputPreview}`);
706
+ pp.everPostedAnything = true;
707
+ }
708
+ }
709
+ } else if (eventType === 'result') {
710
+ const sessionId = event.session_id;
711
+ if (sessionId) {
712
+ this._channelSessions[pp.msgChannel] = sessionId;
713
+ this._saveSessions();
714
+ }
715
+ if (event.is_error) {
716
+ this._log(`Claude error: ${String(event.result || '').slice(0, 200)}`);
717
+ }
718
+ if (pp.messageResolve) {
719
+ pp.messageResolve({ resultEvent: event });
720
+ pp.messageResolve = null;
721
+ }
722
+ } else if (eventType === 'system') {
723
+ const subtype = event.subtype || '';
724
+ const message = event.message || '';
725
+ if (subtype.includes('compact') || String(message).toLowerCase().includes('compact')) {
726
+ await this.sendStatus(pp.msgChannel, String(message) || 'Compacting conversation...');
727
+ }
728
+ } else if (eventType === 'rate_limit_event') {
729
+ this._log(`Rate limited: ${JSON.stringify(event).slice(0, 200)}`);
730
+ }
731
+ };
732
+
733
+ proc.stdout.on('data', (chunk) => {
734
+ pp.lineBuffer += chunk.toString('utf-8');
735
+ const lines = pp.lineBuffer.split('\n');
736
+ pp.lineBuffer = lines.pop();
737
+ for (const line of lines) {
738
+ pp.pendingLines = pp.pendingLines.then(() => processLine(line)).catch(() => {});
739
+ }
740
+ });
741
+
742
+ proc.on('exit', (code) => {
743
+ this._log(`Persistent process exited: channel=${channel} code=${code}`);
744
+ pp.alive = false;
745
+ if (pp.idleTimer) clearTimeout(pp.idleTimer);
746
+ if (pp.messageResolve) {
747
+ pp.messageResolve({ exited: true, code });
748
+ pp.messageResolve = null;
749
+ }
750
+ delete this._persistentProcs[channel];
751
+ delete this._channelProcesses[channel];
752
+ });
753
+
754
+ proc.on('error', (err) => {
755
+ this._log(`Persistent process error: ${err.message}`);
756
+ pp.alive = false;
757
+ if (pp.messageResolve) {
758
+ pp.messageResolve({ exited: true, error: err });
759
+ pp.messageResolve = null;
760
+ }
761
+ delete this._persistentProcs[channel];
762
+ });
763
+
764
+ this._persistentProcs[channel] = pp;
765
+ this._channelProcesses[channel] = proc;
766
+ this._resetIdleTimer(channel);
767
+ return pp;
768
+ }
769
+
770
+ /**
771
+ * Send a user message to a persistent process via stdin and wait for
772
+ * the result event. Returns { resultEvent } on success or { exited, code }
773
+ * if the process dies mid-message.
774
+ */
775
+ _sendToPersistentProc(pp, content) {
776
+ pp.lastResponseText = [];
777
+ pp.hasToolUseSinceLastText = false;
778
+ pp.postedThinking = false;
779
+ pp.everPostedAnything = false;
780
+
781
+ const stdinMsg = JSON.stringify({
782
+ type: 'user',
783
+ message: {
784
+ role: 'user',
785
+ content: [{ type: 'text', text: content }],
786
+ },
787
+ }) + '\n';
788
+
789
+ return new Promise((resolve) => {
790
+ pp.messageResolve = resolve;
791
+ try {
792
+ pp.proc.stdin.write(stdinMsg);
793
+ } catch (e) {
794
+ this._log(`stdin write failed: ${e.message}`);
795
+ resolve({ exited: true, error: e });
796
+ }
797
+ });
798
+ }
799
+
568
800
  async _handleMessage(msg) {
569
801
  let content = (msg.content || '').trim();
570
802
  const attachments = msg.attachments || [];
@@ -586,7 +818,6 @@ class ClaudeAdapter extends BaseAdapter {
586
818
  this._titledSessions.add(msgChannel);
587
819
  try {
588
820
  const info = await this.client.getSession(this.workspaceId, msgChannel, this.token);
589
- // Resume from a previous channel's Claude session if specified
590
821
  const resumeFrom = info.resumeFrom;
591
822
  if (resumeFrom && !this._channelSessions[msgChannel]) {
592
823
  const sourceSession = this._channelSessions[resumeFrom];
@@ -596,7 +827,6 @@ class ClaudeAdapter extends BaseAdapter {
596
827
  this._log(`Resuming channel ${msgChannel} from ${resumeFrom}`);
597
828
  }
598
829
  }
599
- // Auto-title
600
830
  const title = generateSessionTitle(content);
601
831
  if (title && !info.titleManuallySet && SESSION_DEFAULT_RE.test(info.title || '')) {
602
832
  await this.client.updateSession(
@@ -609,6 +839,41 @@ class ClaudeAdapter extends BaseAdapter {
609
839
 
610
840
  await this.sendStatus(msgChannel, 'thinking...');
611
841
 
842
+ // ── Persistent process fast-path ──
843
+ // If we have a living persistent process for this channel, send via stdin
844
+ // instead of spawning a new CLI (saves ~2s startup time).
845
+ const existingPP = this._persistentProcs[msgChannel];
846
+ if (existingPP && existingPP.alive) {
847
+ this._log(`Reusing persistent process for ${msgChannel}`);
848
+ this._resetIdleTimer(msgChannel);
849
+ existingPP.msgChannel = msgChannel;
850
+ const result = await this._sendToPersistentProc(existingPP, content);
851
+ if (result.resultEvent) {
852
+ const fullResponse = existingPP.lastResponseText.join('\n').trim();
853
+ if (fullResponse) {
854
+ try { await this.sendResponse(msgChannel, fullResponse); } catch {}
855
+ }
856
+ this._resetIdleTimer(msgChannel);
857
+ if (!msg._todoNudge) {
858
+ try {
859
+ const remaining = await this.getRemainingTodos(msgChannel);
860
+ if (remaining.length > 0) {
861
+ const items = remaining.map((t) => `- ${t.content}`).join('\n');
862
+ const nudge = `You have ${remaining.length} remaining task(s) from your plan:\n${items}\n\nPlease continue working on them.`;
863
+ if (!this._channelQueues[msgChannel]) this._channelQueues[msgChannel] = [];
864
+ this._channelQueues[msgChannel].push({
865
+ content: nudge, senderType: 'system', senderName: 'system:todos',
866
+ sessionId: msgChannel, messageType: 'chat', _todoNudge: true,
867
+ });
868
+ }
869
+ } catch {}
870
+ }
871
+ return;
872
+ }
873
+ // Process died mid-message — fall through to spawn a fresh one
874
+ this._log(`Persistent process died, falling back to fresh spawn for ${msgChannel}`);
875
+ }
876
+
612
877
  let mcpConfigFile = null;
613
878
  let cmd;
614
879
 
@@ -630,17 +895,13 @@ class ClaudeAdapter extends BaseAdapter {
630
895
  }
631
896
  }
632
897
 
633
- // Run up to 2 attempts: first with session resume, then fresh if stale session detected
634
- let _shouldRetry = false;
898
+ // Spawn a persistent process and send the first message via stdin
635
899
  let effectiveContent = content;
636
900
  for (let attempt = 0; attempt < 2; attempt++) {
637
901
  if (mcpConfigFile) { try { fs.unlinkSync(mcpConfigFile); } catch {} mcpConfigFile = null; }
638
902
 
639
- // On the retry pass after a stale --resume, the spawned `claude`
640
- // starts a brand-new session with no memory of prior turns. Replay
641
- // the channel's recent chat history so the agent at least has a
642
- // recap instead of saying "I don't see any previous messages."
643
903
  if (attempt > 0) {
904
+ this._killPersistentProc(msgChannel);
644
905
  try {
645
906
  const recap = await this._buildChannelRecap(msgChannel, content);
646
907
  if (recap) effectiveContent = `${recap}\n\n---\n\n${content}`;
@@ -648,7 +909,11 @@ class ClaudeAdapter extends BaseAdapter {
648
909
  }
649
910
 
650
911
  try {
651
- const built = this._buildClaudeCmd(effectiveContent, msgChannel, { skipResume: attempt > 0 });
912
+ const browserEnabled = await this.getBrowserEnabled();
913
+ const built = this._buildClaudeCmd(effectiveContent, msgChannel, {
914
+ skipResume: attempt > 0,
915
+ browserEnabled,
916
+ });
652
917
  cmd = built.cmd;
653
918
  mcpConfigFile = built.mcpConfigFile;
654
919
  } catch (e) {
@@ -656,263 +921,85 @@ class ClaudeAdapter extends BaseAdapter {
656
921
  return;
657
922
  }
658
923
 
659
- try {
660
- // Always resolve shim/symlink to node + JS entry point.
661
- // On Windows: .cmd shims need cmd.exe which creates visible windows.
662
- // On macOS/Linux: #!/usr/bin/env node fails when node isn't on system PATH.
663
- const resolved = this._resolveToNodeCmd(cmd[0]);
664
- if (resolved) {
665
- cmd = [resolved[0], resolved[1], ...cmd.slice(1)];
666
- } else if (IS_WINDOWS && cmd[0].toLowerCase().endsWith('.cmd')) {
667
- cmd = ['cmd.exe', '/c', ...cmd];
668
- }
924
+ try {
925
+ const pp = this._spawnPersistentProc(msgChannel, cmd, cleanEnv);
926
+ this._log(`Spawned persistent process for ${msgChannel} (attempt ${attempt + 1})`);
669
927
 
670
- const proc = spawn(cmd[0], cmd.slice(1), {
671
- stdio: ['ignore', 'pipe', 'pipe'],
672
- env: cleanEnv,
673
- cwd: this.workingDir,
674
- detached: !IS_WINDOWS,
675
- windowsHide: true,
676
- });
677
- this._channelProcesses[msgChannel] = proc;
678
-
679
- const lastResponseText = [];
680
- let hasToolUseSinceLastText = false;
681
- let postedThinking = false;
682
- let everPostedAnything = false;
683
- let stderrBuf = '';
684
- let lineBuffer = '';
685
- let _pendingLines = Promise.resolve(); // chain of in-flight processLine calls
686
-
687
- // Capture stderr for diagnostics
688
- if (proc.stderr) {
689
- proc.stderr.on('data', (chunk) => { stderrBuf += chunk.toString('utf-8'); });
690
- }
928
+ const result = await this._sendToPersistentProc(pp, effectiveContent);
691
929
 
692
- _shouldRetry = await new Promise((resolve, reject) => {
693
- let consecutiveTimeouts = 0;
694
- let lastDataTime = Date.now();
695
- let timeoutTimer = null;
696
-
697
- const resetTimeout = () => {
698
- consecutiveTimeouts = 0;
699
- lastDataTime = Date.now();
700
- };
701
-
702
- // 15-second idle timeout monitoring
703
- const startTimeoutMonitor = () => {
704
- timeoutTimer = setInterval(async () => {
705
- const elapsed = Date.now() - lastDataTime;
706
- if (elapsed >= 15000) {
707
- consecutiveTimeouts++;
708
- lastDataTime = Date.now(); // reset for next interval
709
- if (consecutiveTimeouts === 2) {
710
- try { await this.sendStatus(msgChannel, 'Compacting conversation...'); } catch {}
711
- }
712
- // Kill after 20 consecutive timeouts (~5 minutes of no output)
713
- if (consecutiveTimeouts >= 20) {
714
- this._log(`Process idle for ${consecutiveTimeouts * 15}s, killing...`);
715
- await this._stopProcess(proc);
716
- }
717
- }
718
- }, 15000);
719
- };
720
- startTimeoutMonitor();
721
-
722
- const processLine = async (line) => {
723
- line = line.trim();
724
- if (!line) return;
725
- resetTimeout();
726
-
727
- let event;
728
- try { event = JSON.parse(line); } catch { return; }
729
-
730
- const eventType = event.type;
731
-
732
- if (eventType === 'assistant') {
733
- const blocks = (event.message || {}).content || [];
734
- for (const block of blocks) {
735
- if (block.type === 'text' && block.text && block.text.trim()) {
736
- if (hasToolUseSinceLastText) {
737
- lastResponseText.length = 0;
738
- hasToolUseSinceLastText = false;
739
- }
740
- lastResponseText.push(block.text.trim());
741
- postedThinking = true;
742
- everPostedAnything = true;
743
- // Stream text in real-time as "thinking" (same as Python adapter)
744
- try { await this.sendThinking(msgChannel, block.text.trim()); } catch {}
745
- } else if (block.type === 'tool_use') {
746
- hasToolUseSinceLastText = true;
747
- postedThinking = false;
748
- lastResponseText.length = 0;
749
- const toolName = block.name || '';
750
-
751
- // Intercept TodoWrite: forward to workspace API so it emits a chat event
752
- if (toolName === 'TodoWrite' && block.input && block.input.todos) {
753
- try {
754
- const wsTodos = block.input.todos.map((t) => ({
755
- content: t.content,
756
- status: t.status || 'pending',
757
- assignee: t.assignee,
758
- }));
759
- await this.sendTodos(msgChannel, wsTodos);
760
- } catch {}
761
- }
762
-
763
- // Format tool input as readable text
764
- let inputPreview = '';
765
- if (block.input && typeof block.input === 'object') {
766
- // Extract key fields for common tools
767
- const inp = block.input;
768
- if (inp.command) inputPreview = inp.command;
769
- else if (inp.file_path || inp.path) inputPreview = inp.file_path || inp.path;
770
- else if (inp.pattern) inputPreview = inp.pattern;
771
- else if (inp.query) inputPreview = inp.query;
772
- else if (inp.url) inputPreview = inp.url;
773
- else if (inp.content) inputPreview = inp.content.slice(0, 100);
774
- else inputPreview = JSON.stringify(inp).slice(0, 150);
775
- } else {
776
- inputPreview = String(block.input || '').slice(0, 150);
777
- }
778
- await this.sendStatus(msgChannel, `${toolName} › ${inputPreview}`);
779
- everPostedAnything = true;
780
- }
781
- }
782
- } else if (eventType === 'result') {
783
- const sessionId = event.session_id;
784
- if (sessionId) {
785
- this._channelSessions[msgChannel] = sessionId;
786
- this._saveSessions();
787
- }
788
- if (event.is_error) {
789
- this._log(`Claude error: ${String(event.result || '').slice(0, 200)}`);
790
- }
791
- } else if (eventType === 'system') {
792
- const subtype = event.subtype || '';
793
- const message = event.message || '';
794
- if (subtype.includes('compact') || String(message).toLowerCase().includes('compact')) {
795
- await this.sendStatus(msgChannel, String(message) || 'Compacting conversation...');
796
- }
797
- } else if (eventType === 'rate_limit_event') {
798
- this._log(`Rate limited: ${JSON.stringify(event).slice(0, 200)}`);
930
+ if (result.exited) {
931
+ this._log(`Process exited during first message (attempt ${attempt + 1})`);
932
+ if (attempt === 0 && this._channelSessions[msgChannel]) {
933
+ this._log(`Stale session detected, retrying without resume`);
934
+ delete this._channelSessions[msgChannel];
935
+ this._saveSessions();
936
+ continue;
799
937
  }
800
- };
801
-
802
- proc.on('exit', async (code) => {
803
- if (timeoutTimer) clearInterval(timeoutTimer);
804
-
805
- // Wait for all in-flight processLine calls to complete
806
- try { await _pendingLines; } catch {}
807
-
808
- // Process remaining buffer
809
- const lines = lineBuffer.split('\n');
810
- for (const line of lines) {
811
- try { await processLine(line); } catch {}
938
+ if (!pp.everPostedAnything) {
939
+ try { await this.sendResponse(msgChannel, 'No response generated. Please try again.'); } catch {}
812
940
  }
941
+ break;
942
+ }
813
943
 
814
- delete this._channelProcesses[msgChannel];
815
-
816
- const stoppedByUser = this._stoppingChannels.has(msgChannel);
817
-
818
- if (stoppedByUser) {
819
- // User explicitly stopped — cancel all remaining todos
820
- try { await this.cleanupTodos(msgChannel); } catch {}
821
- } else if (!msg._todoNudge) {
822
- // Normal exit (not already a nudge) — check for remaining pending todos
823
- try {
824
- const remaining = await this.getRemainingTodos(msgChannel);
825
- if (remaining.length > 0) {
826
- const items = remaining.map((t) => `- ${t.content}`).join('\n');
827
- const nudge = `You have ${remaining.length} remaining task(s) from your plan:\n${items}\n\nPlease continue working on them.`;
828
- if (!this._channelQueues[msgChannel]) this._channelQueues[msgChannel] = [];
829
- this._channelQueues[msgChannel].push({
830
- content: nudge,
831
- senderType: 'system',
832
- senderName: 'system:todos',
833
- sessionId: msgChannel,
834
- messageType: 'chat',
835
- _todoNudge: true,
836
- });
944
+ // Success — post final response
945
+ const fullResponse = pp.lastResponseText.join('\n').trim();
946
+
947
+ if (this._mode === 'plan') {
948
+ try {
949
+ const planDir = path.join(this.workingDir || process.cwd(), '.claude', 'plans');
950
+ if (fs.existsSync(planDir)) {
951
+ const planFiles = fs.readdirSync(planDir)
952
+ .filter((f) => f.endsWith('.md'))
953
+ .map((f) => ({ name: f, mtime: fs.statSync(path.join(planDir, f)).mtimeMs }))
954
+ .sort((a, b) => b.mtime - a.mtime);
955
+ if (planFiles.length > 0) {
956
+ const planContent = fs.readFileSync(path.join(planDir, planFiles[0].name), 'utf-8').trim();
957
+ if (planContent) pp.lastResponseText.push('\n\n---\n\n**Plan:**\n\n' + planContent);
837
958
  }
838
- } catch {}
839
- } else {
840
- // Nudge response finished but todos still pending — cancel to avoid infinite loop
841
- try { await this.cleanupTodos(msgChannel); } catch {}
842
- }
843
- if (stoppedByUser) {
844
- this._stoppingChannels.delete(msgChannel);
845
- resolve(false);
846
- return;
847
- }
848
-
849
- this._log(`CLI exited: code=${code}, lastResponseText=${lastResponseText.length} items, everPosted=${everPostedAnything}, hasSession=${!!this._channelSessions[msgChannel]}`);
850
- if (code !== 0) {
851
- if (stderrBuf.trim()) {
852
- this._log(`stderr: ${stderrBuf.trim().slice(0, 500)}`);
853
959
  }
854
- }
960
+ } catch {}
961
+ }
855
962
 
856
- if (lastResponseText.length > 0) {
857
- const fullResponse = lastResponseText.join('\n').trim();
858
- // "Prompt is too long" means the resumed session's context
859
- // exceeded the model's limit. Clear the session and retry
860
- // fresh with a bounded recap instead of the full history.
861
- if (/prompt is too long/i.test(fullResponse) && this._channelSessions[msgChannel]) {
862
- this._log(`Prompt too long with resumed session for ${msgChannel}, clearing and retrying`);
863
- delete this._channelSessions[msgChannel];
864
- this._saveSessions();
865
- resolve(true);
866
- } else if (fullResponse) {
867
- try { await this.sendResponse(msgChannel, fullResponse); } catch {}
868
- resolve(false);
869
- } else {
870
- resolve(false);
871
- }
872
- } else if (this._channelSessions[msgChannel] && !everPostedAnything) {
873
- // No output + had a resume session → likely stale session, retry fresh.
874
- // Covers both error exits (code !== 0) and silent success (code === 0)
875
- // where Claude produced no text output on a resumed conversation.
876
- this._log(`Stale session detected for ${msgChannel}, clearing and retrying without resume`);
877
- delete this._channelSessions[msgChannel];
878
- this._saveSessions();
879
- resolve(true); // retry=true
880
- } else {
881
- if (!everPostedAnything) {
882
- try { await this.sendResponse(msgChannel, 'No response generated. Please try again.'); } catch {}
883
- }
884
- resolve(false);
885
- }
886
- });
963
+ const finalResponse = pp.lastResponseText.join('\n').trim();
964
+ if (/prompt is too long/i.test(finalResponse) && this._channelSessions[msgChannel]) {
965
+ this._log(`Prompt too long, clearing session and retrying`);
966
+ delete this._channelSessions[msgChannel];
967
+ this._saveSessions();
968
+ this._killPersistentProc(msgChannel);
969
+ continue;
970
+ }
887
971
 
888
- proc.on('error', (err) => {
889
- if (timeoutTimer) clearInterval(timeoutTimer);
890
- reject(err);
891
- });
972
+ if (finalResponse) {
973
+ try { await this.sendResponse(msgChannel, finalResponse); } catch {}
974
+ }
892
975
 
893
- // Process lines as they arrive (chained to preserve order)
894
- proc.stdout.on('data', (chunk) => {
895
- lineBuffer += chunk.toString('utf-8');
896
- resetTimeout();
897
- const lines = lineBuffer.split('\n');
898
- lineBuffer = lines.pop(); // keep incomplete line
899
- for (const line of lines) {
900
- _pendingLines = _pendingLines.then(() => processLine(line)).catch(() => {});
901
- }
902
- });
903
- });
904
- } catch (e) {
905
- this._log(`Error handling message: ${e.message}`);
906
- await this.sendError(msgChannel, `Error processing message: ${e.message}`);
907
- break; // no retry on spawn error
976
+ this._resetIdleTimer(msgChannel);
977
+
978
+ if (!msg._todoNudge) {
979
+ try {
980
+ const remaining = await this.getRemainingTodos(msgChannel);
981
+ if (remaining.length > 0) {
982
+ const items = remaining.map((t) => `- ${t.content}`).join('\n');
983
+ const nudge = `You have ${remaining.length} remaining task(s) from your plan:\n${items}\n\nPlease continue working on them.`;
984
+ if (!this._channelQueues[msgChannel]) this._channelQueues[msgChannel] = [];
985
+ this._channelQueues[msgChannel].push({
986
+ content: nudge, senderType: 'system', senderName: 'system:todos',
987
+ sessionId: msgChannel, messageType: 'chat', _todoNudge: true,
988
+ });
989
+ }
990
+ } catch {}
991
+ }
992
+ break;
993
+ } catch (e) {
994
+ this._log(`Error handling message: ${e.message}`);
995
+ await this.sendError(msgChannel, `Error processing message: ${e.message}`);
996
+ break;
997
+ }
908
998
  }
909
- if (!_shouldRetry) break; // exit loop if no retry needed
910
- } // end for attempt
911
999
 
912
1000
  if (mcpConfigFile) {
913
1001
  try { fs.unlinkSync(mcpConfigFile); } catch {}
914
1002
  }
915
- delete this._channelProcesses[msgChannel];
916
1003
  }
917
1004
  }
918
1005