@openagents-org/agent-launcher 0.2.131 → 0.2.133

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 {}
@@ -567,6 +572,231 @@ class ClaudeAdapter extends BaseAdapter {
567
572
  return { cmd, mcpConfigFile: mcpFile };
568
573
  }
569
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
+
570
800
  async _handleMessage(msg) {
571
801
  let content = (msg.content || '').trim();
572
802
  const attachments = msg.attachments || [];
@@ -588,7 +818,6 @@ class ClaudeAdapter extends BaseAdapter {
588
818
  this._titledSessions.add(msgChannel);
589
819
  try {
590
820
  const info = await this.client.getSession(this.workspaceId, msgChannel, this.token);
591
- // Resume from a previous channel's Claude session if specified
592
821
  const resumeFrom = info.resumeFrom;
593
822
  if (resumeFrom && !this._channelSessions[msgChannel]) {
594
823
  const sourceSession = this._channelSessions[resumeFrom];
@@ -598,7 +827,6 @@ class ClaudeAdapter extends BaseAdapter {
598
827
  this._log(`Resuming channel ${msgChannel} from ${resumeFrom}`);
599
828
  }
600
829
  }
601
- // Auto-title
602
830
  const title = generateSessionTitle(content);
603
831
  if (title && !info.titleManuallySet && SESSION_DEFAULT_RE.test(info.title || '')) {
604
832
  await this.client.updateSession(
@@ -611,6 +839,41 @@ class ClaudeAdapter extends BaseAdapter {
611
839
 
612
840
  await this.sendStatus(msgChannel, 'thinking...');
613
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
+
614
877
  let mcpConfigFile = null;
615
878
  let cmd;
616
879
 
@@ -632,17 +895,13 @@ class ClaudeAdapter extends BaseAdapter {
632
895
  }
633
896
  }
634
897
 
635
- // Run up to 2 attempts: first with session resume, then fresh if stale session detected
636
- let _shouldRetry = false;
898
+ // Spawn a persistent process and send the first message via stdin
637
899
  let effectiveContent = content;
638
900
  for (let attempt = 0; attempt < 2; attempt++) {
639
901
  if (mcpConfigFile) { try { fs.unlinkSync(mcpConfigFile); } catch {} mcpConfigFile = null; }
640
902
 
641
- // On the retry pass after a stale --resume, the spawned `claude`
642
- // starts a brand-new session with no memory of prior turns. Replay
643
- // the channel's recent chat history so the agent at least has a
644
- // recap instead of saying "I don't see any previous messages."
645
903
  if (attempt > 0) {
904
+ this._killPersistentProc(msgChannel);
646
905
  try {
647
906
  const recap = await this._buildChannelRecap(msgChannel, content);
648
907
  if (recap) effectiveContent = `${recap}\n\n---\n\n${content}`;
@@ -662,284 +921,85 @@ class ClaudeAdapter extends BaseAdapter {
662
921
  return;
663
922
  }
664
923
 
665
- try {
666
- // Always resolve shim/symlink to node + JS entry point.
667
- // On Windows: .cmd shims need cmd.exe which creates visible windows.
668
- // On macOS/Linux: #!/usr/bin/env node fails when node isn't on system PATH.
669
- const resolved = this._resolveToNodeCmd(cmd[0]);
670
- if (resolved) {
671
- cmd = [resolved[0], resolved[1], ...cmd.slice(1)];
672
- } else if (IS_WINDOWS && cmd[0].toLowerCase().endsWith('.cmd')) {
673
- cmd = ['cmd.exe', '/c', ...cmd];
674
- }
675
-
676
- const proc = spawn(cmd[0], cmd.slice(1), {
677
- stdio: ['ignore', 'pipe', 'pipe'],
678
- env: cleanEnv,
679
- cwd: this.workingDir,
680
- detached: !IS_WINDOWS,
681
- windowsHide: true,
682
- });
683
- this._channelProcesses[msgChannel] = proc;
684
-
685
- const lastResponseText = [];
686
- let hasToolUseSinceLastText = false;
687
- let postedThinking = false;
688
- let everPostedAnything = false;
689
- let stderrBuf = '';
690
- let lineBuffer = '';
691
- let _pendingLines = Promise.resolve(); // chain of in-flight processLine calls
692
-
693
- // Capture stderr for diagnostics
694
- if (proc.stderr) {
695
- proc.stderr.on('data', (chunk) => { stderrBuf += chunk.toString('utf-8'); });
696
- }
697
-
698
- _shouldRetry = await new Promise((resolve, reject) => {
699
- let consecutiveTimeouts = 0;
700
- let lastDataTime = Date.now();
701
- let timeoutTimer = null;
702
-
703
- const resetTimeout = () => {
704
- consecutiveTimeouts = 0;
705
- lastDataTime = Date.now();
706
- };
707
-
708
- // 15-second idle timeout monitoring
709
- const startTimeoutMonitor = () => {
710
- timeoutTimer = setInterval(async () => {
711
- const elapsed = Date.now() - lastDataTime;
712
- if (elapsed >= 15000) {
713
- consecutiveTimeouts++;
714
- lastDataTime = Date.now(); // reset for next interval
715
- if (consecutiveTimeouts === 2) {
716
- try { await this.sendStatus(msgChannel, 'Compacting conversation...'); } catch {}
717
- }
718
- // Kill after 20 consecutive timeouts (~5 minutes of no output)
719
- if (consecutiveTimeouts >= 20) {
720
- this._log(`Process idle for ${consecutiveTimeouts * 15}s, killing...`);
721
- await this._stopProcess(proc);
722
- }
723
- }
724
- }, 15000);
725
- };
726
- startTimeoutMonitor();
727
-
728
- const processLine = async (line) => {
729
- line = line.trim();
730
- if (!line) return;
731
- resetTimeout();
732
-
733
- let event;
734
- try { event = JSON.parse(line); } catch { return; }
735
-
736
- const eventType = event.type;
737
-
738
- if (eventType === 'assistant') {
739
- const blocks = (event.message || {}).content || [];
740
- for (const block of blocks) {
741
- if (block.type === 'text' && block.text && block.text.trim()) {
742
- if (hasToolUseSinceLastText) {
743
- lastResponseText.length = 0;
744
- hasToolUseSinceLastText = false;
745
- }
746
- lastResponseText.push(block.text.trim());
747
- postedThinking = true;
748
- everPostedAnything = true;
749
- // Stream text in real-time as "thinking" (same as Python adapter)
750
- try { await this.sendThinking(msgChannel, block.text.trim()); } catch {}
751
- } else if (block.type === 'tool_use') {
752
- hasToolUseSinceLastText = true;
753
- postedThinking = false;
754
- lastResponseText.length = 0;
755
- const toolName = block.name || '';
756
-
757
- // Intercept TodoWrite: forward to workspace API so it emits a chat event
758
- if (toolName === 'TodoWrite' && block.input && block.input.todos) {
759
- try {
760
- const wsTodos = block.input.todos.map((t) => ({
761
- content: t.content,
762
- status: t.status || 'pending',
763
- assignee: t.assignee,
764
- }));
765
- await this.sendTodos(msgChannel, wsTodos);
766
- } catch {}
767
- }
768
-
769
- // Format tool input as readable text
770
- let inputPreview = '';
771
- if (block.input && typeof block.input === 'object') {
772
- // Extract key fields for common tools
773
- const inp = block.input;
774
- if (inp.command) inputPreview = inp.command;
775
- else if (inp.file_path || inp.path) inputPreview = inp.file_path || inp.path;
776
- else if (inp.pattern) inputPreview = inp.pattern;
777
- else if (inp.query) inputPreview = inp.query;
778
- else if (inp.url) inputPreview = inp.url;
779
- else if (inp.content) inputPreview = inp.content.slice(0, 100);
780
- else inputPreview = JSON.stringify(inp).slice(0, 150);
781
- } else {
782
- inputPreview = String(block.input || '').slice(0, 150);
783
- }
784
- await this.sendStatus(msgChannel, `${toolName} › ${inputPreview}`);
785
- everPostedAnything = true;
786
- }
787
- }
788
- } else if (eventType === 'result') {
789
- const sessionId = event.session_id;
790
- if (sessionId) {
791
- this._channelSessions[msgChannel] = sessionId;
792
- this._saveSessions();
793
- }
794
- if (event.is_error) {
795
- this._log(`Claude error: ${String(event.result || '').slice(0, 200)}`);
796
- }
797
- } else if (eventType === 'system') {
798
- const subtype = event.subtype || '';
799
- const message = event.message || '';
800
- if (subtype.includes('compact') || String(message).toLowerCase().includes('compact')) {
801
- await this.sendStatus(msgChannel, String(message) || 'Compacting conversation...');
802
- }
803
- } else if (eventType === 'rate_limit_event') {
804
- this._log(`Rate limited: ${JSON.stringify(event).slice(0, 200)}`);
805
- }
806
- };
807
-
808
- proc.on('exit', async (code) => {
809
- if (timeoutTimer) clearInterval(timeoutTimer);
924
+ try {
925
+ const pp = this._spawnPersistentProc(msgChannel, cmd, cleanEnv);
926
+ this._log(`Spawned persistent process for ${msgChannel} (attempt ${attempt + 1})`);
810
927
 
811
- // Wait for all in-flight processLine calls to complete
812
- try { await _pendingLines; } catch {}
928
+ const result = await this._sendToPersistentProc(pp, effectiveContent);
813
929
 
814
- // Process remaining buffer
815
- const lines = lineBuffer.split('\n');
816
- for (const line of lines) {
817
- try { await processLine(line); } catch {}
818
- }
819
-
820
- delete this._channelProcesses[msgChannel];
821
-
822
- const stoppedByUser = this._stoppingChannels.has(msgChannel);
823
-
824
- if (stoppedByUser) {
825
- // User explicitly stopped — cancel all remaining todos
826
- try { await this.cleanupTodos(msgChannel); } catch {}
827
- } else if (!msg._todoNudge) {
828
- // Normal exit (not already a nudge) — check for remaining pending todos
829
- try {
830
- const remaining = await this.getRemainingTodos(msgChannel);
831
- if (remaining.length > 0) {
832
- const items = remaining.map((t) => `- ${t.content}`).join('\n');
833
- const nudge = `You have ${remaining.length} remaining task(s) from your plan:\n${items}\n\nPlease continue working on them.`;
834
- if (!this._channelQueues[msgChannel]) this._channelQueues[msgChannel] = [];
835
- this._channelQueues[msgChannel].push({
836
- content: nudge,
837
- senderType: 'system',
838
- senderName: 'system:todos',
839
- sessionId: msgChannel,
840
- messageType: 'chat',
841
- _todoNudge: true,
842
- });
843
- }
844
- } catch {}
845
- } else {
846
- // Nudge response finished but todos still pending — cancel to avoid infinite loop
847
- try { await this.cleanupTodos(msgChannel); } catch {}
848
- }
849
- if (stoppedByUser) {
850
- this._stoppingChannels.delete(msgChannel);
851
- resolve(false);
852
- return;
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;
853
937
  }
854
-
855
- this._log(`CLI exited: code=${code}, lastResponseText=${lastResponseText.length} items, everPosted=${everPostedAnything}, hasSession=${!!this._channelSessions[msgChannel]}`);
856
- if (code !== 0) {
857
- if (stderrBuf.trim()) {
858
- this._log(`stderr: ${stderrBuf.trim().slice(0, 500)}`);
859
- }
938
+ if (!pp.everPostedAnything) {
939
+ try { await this.sendResponse(msgChannel, 'No response generated. Please try again.'); } catch {}
860
940
  }
941
+ break;
942
+ }
861
943
 
862
- // In plan mode, read the plan file and include it in the response
863
- if (this._mode === 'plan') {
864
- try {
865
- const planDir = path.join(this.workingDir || process.cwd(), '.claude', 'plans');
866
- if (fs.existsSync(planDir)) {
867
- const planFiles = fs.readdirSync(planDir)
868
- .filter((f) => f.endsWith('.md'))
869
- .map((f) => ({ name: f, mtime: fs.statSync(path.join(planDir, f)).mtimeMs }))
870
- .sort((a, b) => b.mtime - a.mtime);
871
- if (planFiles.length > 0) {
872
- const planContent = fs.readFileSync(path.join(planDir, planFiles[0].name), 'utf-8').trim();
873
- if (planContent) {
874
- lastResponseText.push('\n\n---\n\n**Plan:**\n\n' + planContent);
875
- }
876
- }
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);
877
958
  }
878
- } catch (e) {
879
- this._log(`Failed to read plan file: ${e.message}`);
880
959
  }
881
- }
960
+ } catch {}
961
+ }
882
962
 
883
- if (lastResponseText.length > 0) {
884
- const fullResponse = lastResponseText.join('\n').trim();
885
- // "Prompt is too long" means the resumed session's context
886
- // exceeded the model's limit. Clear the session and retry
887
- // fresh with a bounded recap instead of the full history.
888
- if (/prompt is too long/i.test(fullResponse) && this._channelSessions[msgChannel]) {
889
- this._log(`Prompt too long with resumed session for ${msgChannel}, clearing and retrying`);
890
- delete this._channelSessions[msgChannel];
891
- this._saveSessions();
892
- resolve(true);
893
- } else if (fullResponse) {
894
- try { await this.sendResponse(msgChannel, fullResponse); } catch {}
895
- resolve(false);
896
- } else {
897
- resolve(false);
898
- }
899
- } else if (this._channelSessions[msgChannel] && !everPostedAnything) {
900
- // No output + had a resume session → likely stale session, retry fresh.
901
- // Covers both error exits (code !== 0) and silent success (code === 0)
902
- // where Claude produced no text output on a resumed conversation.
903
- this._log(`Stale session detected for ${msgChannel}, clearing and retrying without resume`);
904
- delete this._channelSessions[msgChannel];
905
- this._saveSessions();
906
- resolve(true); // retry=true
907
- } else {
908
- if (!everPostedAnything) {
909
- try { await this.sendResponse(msgChannel, 'No response generated. Please try again.'); } catch {}
910
- }
911
- resolve(false);
912
- }
913
- });
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
+ }
914
971
 
915
- proc.on('error', (err) => {
916
- if (timeoutTimer) clearInterval(timeoutTimer);
917
- reject(err);
918
- });
972
+ if (finalResponse) {
973
+ try { await this.sendResponse(msgChannel, finalResponse); } catch {}
974
+ }
919
975
 
920
- // Process lines as they arrive (chained to preserve order)
921
- proc.stdout.on('data', (chunk) => {
922
- lineBuffer += chunk.toString('utf-8');
923
- resetTimeout();
924
- const lines = lineBuffer.split('\n');
925
- lineBuffer = lines.pop(); // keep incomplete line
926
- for (const line of lines) {
927
- _pendingLines = _pendingLines.then(() => processLine(line)).catch(() => {});
928
- }
929
- });
930
- });
931
- } catch (e) {
932
- this._log(`Error handling message: ${e.message}`);
933
- await this.sendError(msgChannel, `Error processing message: ${e.message}`);
934
- 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
+ }
935
998
  }
936
- if (!_shouldRetry) break; // exit loop if no retry needed
937
- } // end for attempt
938
999
 
939
1000
  if (mcpConfigFile) {
940
1001
  try { fs.unlinkSync(mcpConfigFile); } catch {}
941
1002
  }
942
- delete this._channelProcesses[msgChannel];
943
1003
  }
944
1004
  }
945
1005