@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.
- package/package.json +1 -1
- package/src/adapters/base.js +113 -0
- package/src/adapters/claude.js +334 -274
- package/src/adapters/codex.js +37 -2
- package/src/adapters/workspace-prompt.js +54 -4
- package/src/cli.js +118 -2
- package/src/daemon.js +12 -3
- package/src/index.js +11 -0
- package/src/installer.js +43 -3
- package/src/mcp-server.js +255 -88
- package/src/paths.js +15 -0
- package/src/skill-catalog.js +125 -0
- package/src/skill-installer.js +382 -0
- package/src/update-check.js +5 -25
- package/src/workspace-client.js +93 -0
package/src/adapters/claude.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
666
|
-
|
|
667
|
-
|
|
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
|
-
|
|
812
|
-
try { await _pendingLines; } catch {}
|
|
928
|
+
const result = await this._sendToPersistentProc(pp, effectiveContent);
|
|
813
929
|
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
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
|
-
|
|
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
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
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
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
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
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
});
|
|
972
|
+
if (finalResponse) {
|
|
973
|
+
try { await this.sendResponse(msgChannel, finalResponse); } catch {}
|
|
974
|
+
}
|
|
919
975
|
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
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
|
|