@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.
- package/package.json +1 -1
- package/registry.json +7 -5
- package/src/adapters/base.js +130 -7
- package/src/adapters/claude.js +342 -255
- package/src/adapters/cursor.js +27 -8
- package/src/adapters/workspace-prompt.js +133 -28
- package/src/cli.js +85 -0
- package/src/daemon.js +12 -3
- package/src/github-client.js +165 -0
- package/src/index.js +13 -1
- package/src/installer.js +151 -19
- package/src/mcp-server.js +279 -91
- package/src/paths.js +31 -3
- package/src/registry.js +4 -4
- package/src/skill-catalog.js +125 -0
- package/src/tui.js +1 -16
- package/src/update-check.js +5 -25
- package/src/workspace-client.js +101 -6
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 {}
|
|
@@ -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
|
-
//
|
|
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
|
|
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
|
-
|
|
660
|
-
|
|
661
|
-
|
|
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
|
-
|
|
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
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
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
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
if (
|
|
826
|
-
const
|
|
827
|
-
|
|
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
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
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
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
});
|
|
972
|
+
if (finalResponse) {
|
|
973
|
+
try { await this.sendResponse(msgChannel, finalResponse); } catch {}
|
|
974
|
+
}
|
|
892
975
|
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
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
|
|