@dsiloed/silo-link 1.2.9 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/dsiloed-client.d.ts +30 -0
- package/dist/api/dsiloed-client.d.ts.map +1 -1
- package/dist/api/dsiloed-client.js +66 -0
- package/dist/api/dsiloed-client.js.map +1 -1
- package/dist/cable/subscription-manager.d.ts +7 -3
- package/dist/cable/subscription-manager.d.ts.map +1 -1
- package/dist/cable/subscription-manager.js +36 -11
- package/dist/cable/subscription-manager.js.map +1 -1
- package/dist/cli/claude-md-block.d.ts +1 -1
- package/dist/cli/claude-md-block.d.ts.map +1 -1
- package/dist/cli/claude-md-block.js +31 -70
- package/dist/cli/claude-md-block.js.map +1 -1
- package/dist/cli/commands.d.ts.map +1 -1
- package/dist/cli/commands.js +112 -24
- package/dist/cli/commands.js.map +1 -1
- package/dist/config/config-manager.d.ts +4 -4
- package/dist/config/config-manager.d.ts.map +1 -1
- package/dist/config/config-manager.js +28 -14
- package/dist/config/config-manager.js.map +1 -1
- package/dist/core/agent-launcher.d.ts +30 -1
- package/dist/core/agent-launcher.d.ts.map +1 -1
- package/dist/core/base-tmux-launcher.d.ts +102 -0
- package/dist/core/base-tmux-launcher.d.ts.map +1 -0
- package/dist/core/base-tmux-launcher.js +491 -0
- package/dist/core/base-tmux-launcher.js.map +1 -0
- package/dist/core/bridge.d.ts +14 -2
- package/dist/core/bridge.d.ts.map +1 -1
- package/dist/core/bridge.js +129 -35
- package/dist/core/bridge.js.map +1 -1
- package/dist/core/claude-launcher.d.ts +24 -100
- package/dist/core/claude-launcher.d.ts.map +1 -1
- package/dist/core/claude-launcher.js +130 -489
- package/dist/core/claude-launcher.js.map +1 -1
- package/dist/core/gemini-launcher.d.ts +37 -27
- package/dist/core/gemini-launcher.d.ts.map +1 -1
- package/dist/core/gemini-launcher.js +126 -38
- package/dist/core/gemini-launcher.js.map +1 -1
- package/dist/core/launcher-factory.d.ts.map +1 -1
- package/dist/core/launcher-factory.js +1 -2
- package/dist/core/launcher-factory.js.map +1 -1
- package/dist/core/openai-launcher.d.ts +14 -17
- package/dist/core/openai-launcher.d.ts.map +1 -1
- package/dist/core/openai-launcher.js +23 -44
- package/dist/core/openai-launcher.js.map +1 -1
- package/dist/core/session-manager.d.ts +3 -0
- package/dist/core/session-manager.d.ts.map +1 -1
- package/dist/core/session-manager.js +11 -0
- package/dist/core/session-manager.js.map +1 -1
- package/dist/core/workspace-manager.d.ts.map +1 -1
- package/dist/core/workspace-manager.js +53 -16
- package/dist/core/workspace-manager.js.map +1 -1
- package/dist/mcp/server.d.ts +3 -0
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +19 -2
- package/dist/mcp/server.js.map +1 -1
- package/dist/mcp/tools/register-tools.d.ts +2 -0
- package/dist/mcp/tools/register-tools.d.ts.map +1 -1
- package/dist/mcp/tools/register-tools.js +210 -49
- package/dist/mcp/tools/register-tools.js.map +1 -1
- package/dist/types/index.d.ts +11 -0
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +1 -1
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { existsSync, copyFileSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { BaseTmuxLauncher } from './base-tmux-launcher.js';
|
|
2
4
|
/**
|
|
3
5
|
* Claude Code implementation of the AgentLauncher interface.
|
|
4
6
|
*
|
|
@@ -9,226 +11,134 @@ import { spawn } from 'node:child_process';
|
|
|
9
11
|
* - Store Claude session ID in DSiloed agent_session metadata
|
|
10
12
|
* - Kill all child processes on SiloLink shutdown
|
|
11
13
|
*/
|
|
12
|
-
export class ClaudeLauncher {
|
|
13
|
-
config;
|
|
14
|
-
sessionManager;
|
|
15
|
-
dsiloedClient;
|
|
16
|
-
workspaceManager;
|
|
14
|
+
export class ClaudeLauncher extends BaseTmuxLauncher {
|
|
17
15
|
childProcess = null;
|
|
18
|
-
tmuxSessions = new Map(); // tmuxName -> conversationId
|
|
19
|
-
tmuxSessionWorkspaces = new Map(); // tmuxName -> sessionId (for worktree cleanup)
|
|
20
|
-
tmuxMonitorTimer = null;
|
|
21
|
-
idleCheckTimer = null;
|
|
22
|
-
launchingConversations = new Set();
|
|
23
|
-
messageQueue = null;
|
|
24
|
-
enabled;
|
|
25
|
-
lastNudgeAt = 0;
|
|
26
|
-
nudgeCount = 0; // Consecutive nudges without activity change
|
|
27
|
-
lastActivitySnapshot = new Map(); // sessionId -> lastActivity timestamp
|
|
28
|
-
// Last Claude Code session ID (for --resume)
|
|
29
|
-
lastClaudeSessionId = null;
|
|
30
|
-
// Message that triggered the current launch (included in prompt for context)
|
|
31
|
-
pendingTriggerMessage = null;
|
|
32
|
-
// Workspace config for the next launch (set by control channel or API)
|
|
33
|
-
pendingWorkspaceConfig = null;
|
|
34
|
-
// Override CWD for next launch (used when re-launching into an existing worktree)
|
|
35
|
-
pendingWorkspaceCwd = null;
|
|
36
16
|
constructor(config, sessionManager, dsiloedClient, messageQueue, workspaceManager) {
|
|
37
|
-
|
|
38
|
-
this.sessionManager = sessionManager;
|
|
39
|
-
this.dsiloedClient = dsiloedClient || null;
|
|
40
|
-
this.messageQueue = messageQueue || null;
|
|
41
|
-
this.workspaceManager = workspaceManager || null;
|
|
42
|
-
this.enabled = !!(config.claude_command && config.claude_working_directory);
|
|
17
|
+
super(config, sessionManager, dsiloedClient, messageQueue, workspaceManager);
|
|
43
18
|
}
|
|
44
|
-
|
|
45
|
-
|
|
19
|
+
// --- Abstract implementation ---
|
|
20
|
+
getCommand() {
|
|
21
|
+
return this.config.claude_command || 'claude';
|
|
46
22
|
}
|
|
47
|
-
|
|
48
|
-
return
|
|
23
|
+
getArgs() {
|
|
24
|
+
return ['--dangerously-skip-permissions'];
|
|
49
25
|
}
|
|
50
|
-
|
|
51
|
-
return
|
|
26
|
+
getReadyIndicator() {
|
|
27
|
+
return ['❯'];
|
|
52
28
|
}
|
|
53
|
-
|
|
54
|
-
return
|
|
55
|
-
}
|
|
56
|
-
setLastResumeId(resumeId) {
|
|
57
|
-
this.lastClaudeSessionId = resumeId;
|
|
58
|
-
console.log(`[ClaudeLauncher] Restored resume ID from previous session: ${resumeId}`);
|
|
29
|
+
formatPrompt(prompt) {
|
|
30
|
+
return prompt;
|
|
59
31
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
*/
|
|
63
|
-
startIdleMonitor() {
|
|
64
|
-
if (!this.isAutoRespawnEnabled())
|
|
32
|
+
async setupMcpConfig(cwd, toolAllowlist) {
|
|
33
|
+
if (cwd === this.config.claude_working_directory)
|
|
65
34
|
return;
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
async launchInternal(reason = 'manual', conversationId) {
|
|
81
|
-
if (!this.enabled) {
|
|
82
|
-
console.log('[ClaudeLauncher] Not configured — set claude_command and claude_working_directory in config');
|
|
83
|
-
return false;
|
|
84
|
-
}
|
|
85
|
-
const convKey = conversationId || 0;
|
|
86
|
-
if (this.launchingConversations.has(convKey)) {
|
|
87
|
-
console.log(`[ClaudeLauncher] Already launching for conversation ${convKey}, skipping`);
|
|
88
|
-
return false;
|
|
89
|
-
}
|
|
90
|
-
this.launchingConversations.add(convKey);
|
|
91
|
-
try {
|
|
92
|
-
const command = this.config.claude_command || 'claude';
|
|
93
|
-
let cwd = this.config.claude_working_directory || process.cwd();
|
|
94
|
-
const args = ['--dangerously-skip-permissions'];
|
|
95
|
-
// Create a worktree if workspace config is provided
|
|
96
|
-
let workspaceSessionId = null;
|
|
97
|
-
let workspaceBranch = null;
|
|
98
|
-
const wsConfig = this.pendingWorkspaceConfig;
|
|
99
|
-
this.pendingWorkspaceConfig = null;
|
|
100
|
-
if (wsConfig && this.workspaceManager) {
|
|
101
|
-
// Use a temporary session ID for the worktree (will be updated when Claude registers)
|
|
102
|
-
workspaceSessionId = `tmux-${Date.now()}`;
|
|
103
|
-
workspaceBranch = wsConfig.branch;
|
|
104
|
-
try {
|
|
105
|
-
const workspace = this.workspaceManager.createWorktree(workspaceSessionId, wsConfig.repoPath, wsConfig.branch, wsConfig.baseBranch || 'development');
|
|
106
|
-
cwd = workspace.worktreePath;
|
|
107
|
-
console.log(`[ClaudeLauncher] Created worktree at ${cwd} (branch: ${wsConfig.branch})`);
|
|
108
|
-
}
|
|
109
|
-
catch (err) {
|
|
110
|
-
console.error(`[ClaudeLauncher] Failed to create worktree: ${err}`);
|
|
111
|
-
// Fall back to normal cwd
|
|
112
|
-
workspaceSessionId = null;
|
|
113
|
-
workspaceBranch = null;
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
// Use existing worktree CWD if set (re-launching into a linked workspace)
|
|
117
|
-
const existingWorkspaceCwd = this.pendingWorkspaceCwd;
|
|
118
|
-
this.pendingWorkspaceCwd = null;
|
|
119
|
-
if (existingWorkspaceCwd && !workspaceSessionId) {
|
|
120
|
-
cwd = existingWorkspaceCwd;
|
|
121
|
-
console.log(`[ClaudeLauncher] Using existing worktree at ${cwd}`);
|
|
122
|
-
}
|
|
123
|
-
let prompt;
|
|
124
|
-
// Build workspace context string for inclusion in prompts
|
|
125
|
-
const workspaceContext = (workspaceSessionId || existingWorkspaceCwd)
|
|
126
|
-
? `\n\nWORKSPACE: You are working in an isolated git worktree at "${cwd}". This is separate from the main repo — your changes won't affect other sessions. Use remote_workspace_claim to claim files before editing, and remote_workspace_check to see what other sessions are working on.`
|
|
127
|
-
: '';
|
|
128
|
-
// Try to resume previous session if we have an ID
|
|
129
|
-
if (this.lastClaudeSessionId && (reason === 'process-exit' || reason === 'inbound-message')) {
|
|
130
|
-
args.push('--resume', this.lastClaudeSessionId);
|
|
131
|
-
console.log(`[ClaudeLauncher] Resuming session: ${this.lastClaudeSessionId}`);
|
|
132
|
-
prompt = 'Continue polling SiloLink for messages. Check memory for context if needed.' + workspaceContext;
|
|
133
|
-
}
|
|
134
|
-
else {
|
|
135
|
-
if (reason === 'process-exit' || reason === 'inbound-message') {
|
|
136
|
-
const registerParams = conversationId
|
|
137
|
-
? `{ "conversation_id": ${conversationId} }`
|
|
138
|
-
: `{}`;
|
|
139
|
-
const triggerLine = this.pendingTriggerMessage
|
|
140
|
-
? `\n\nThe user's latest message (which triggered this session restart): "${this.pendingTriggerMessage}"\nAddress this message AFTER loading context. Do NOT ask the user to repeat themselves.`
|
|
141
|
-
: '';
|
|
142
|
-
this.pendingTriggerMessage = null;
|
|
143
|
-
prompt = [
|
|
144
|
-
`Call mcp__silolink__remote_register with these exact parameters: ${registerParams}. After registering, call remote_load_context() to load the prior conversation history. Review all returned messages to understand what was discussed. Then send a remote_notify telling the user you are continuing where the last session left off. Then enter the poll loop using remote_poll. See CLAUDE.md for the poll loop pattern.`,
|
|
145
|
-
'',
|
|
146
|
-
'Context: This is a new session replacing a previous one that ended.',
|
|
147
|
-
'Before entering the poll loop:',
|
|
148
|
-
'1. Call remote_load_context() and review the conversation history',
|
|
149
|
-
'2. Check your memory files for additional context on recent work',
|
|
150
|
-
'3. Send a remote_notify summarizing what you loaded and confirming you are ready',
|
|
151
|
-
'4. Process the user\'s pending message (if any) before entering the poll loop',
|
|
152
|
-
'',
|
|
153
|
-
'CRITICAL: The user is NOT watching the terminal. You MUST send a remote_notify progress update after EVERY major step (file edits, commits, merges, deploys, errors). Do not go silent while working.',
|
|
154
|
-
triggerLine,
|
|
155
|
-
workspaceContext,
|
|
156
|
-
].join('\n');
|
|
157
|
-
}
|
|
158
|
-
else {
|
|
159
|
-
prompt = (this.config.claude_session_prompt ||
|
|
160
|
-
'Register with SiloLink as "portablemind" and enter the poll loop.') + workspaceContext;
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
console.log(`[ClaudeLauncher] Launching Claude Code via tmux (reason: ${reason})`);
|
|
164
|
-
console.log(` Command: ${command} ${args.join(' ')}`);
|
|
165
|
-
console.log(` Working directory: ${cwd}`);
|
|
166
|
-
if (workspaceBranch)
|
|
167
|
-
console.log(` Workspace branch: ${workspaceBranch}`);
|
|
168
|
-
console.log(` Prompt: ${prompt.substring(0, 100)}...`);
|
|
169
|
-
const tmuxSession = `silolink-claude-${Date.now()}`;
|
|
170
|
-
// Create a new tmux session running Claude Code in a real TTY
|
|
171
|
-
const claudeCmd = `cd ${cwd} && ${command} ${args.join(' ')}`;
|
|
172
|
-
const tmuxCreate = spawn('tmux', [
|
|
173
|
-
'new-session', '-d', '-s', tmuxSession, '-x', '200', '-y', '50', claudeCmd,
|
|
174
|
-
], {
|
|
175
|
-
stdio: 'ignore',
|
|
176
|
-
detached: true,
|
|
177
|
-
env: {
|
|
178
|
-
...process.env,
|
|
179
|
-
SILOLINK_MCP_URL: `http://localhost:${this.config.mcp_port}/mcp`,
|
|
180
|
-
},
|
|
181
|
-
});
|
|
182
|
-
tmuxCreate.unref();
|
|
183
|
-
// Wait for tmux session to start, then send the prompt
|
|
184
|
-
await new Promise(resolve => setTimeout(resolve, 3000));
|
|
185
|
-
// Check if tmux session exists
|
|
186
|
-
const checkResult = spawn('tmux', ['has-session', '-t', tmuxSession], { stdio: 'ignore' });
|
|
187
|
-
const sessionExists = await new Promise(resolve => {
|
|
188
|
-
checkResult.on('exit', code => resolve(code === 0));
|
|
189
|
-
});
|
|
190
|
-
if (!sessionExists) {
|
|
191
|
-
console.error(`[ClaudeLauncher] tmux session ${tmuxSession} failed to start`);
|
|
192
|
-
// Clean up worktree if we created one
|
|
193
|
-
if (workspaceSessionId && this.workspaceManager) {
|
|
194
|
-
this.workspaceManager.removeWorktree(workspaceSessionId);
|
|
35
|
+
const mcpTarget = join(cwd, '.mcp.json');
|
|
36
|
+
const mcpSource = join(this.config.claude_working_directory || cwd, '.mcp.json');
|
|
37
|
+
if (toolAllowlist && toolAllowlist.length > 0 && existsSync(mcpSource)) {
|
|
38
|
+
try {
|
|
39
|
+
const mcpConfig = JSON.parse(readFileSync(mcpSource, 'utf-8'));
|
|
40
|
+
if (mcpConfig.mcpServers?.portablemind?.args) {
|
|
41
|
+
const configArgs = mcpConfig.mcpServers.portablemind.args;
|
|
42
|
+
const urlIdx = configArgs.findIndex((a) => a.startsWith('http'));
|
|
43
|
+
if (urlIdx >= 0) {
|
|
44
|
+
const url = new URL(configArgs[urlIdx]);
|
|
45
|
+
url.searchParams.delete('categories');
|
|
46
|
+
url.searchParams.set('tools', toolAllowlist.join(','));
|
|
47
|
+
configArgs[urlIdx] = url.toString();
|
|
48
|
+
}
|
|
195
49
|
}
|
|
196
|
-
|
|
50
|
+
writeFileSync(mcpTarget, JSON.stringify(mcpConfig, null, 2));
|
|
51
|
+
console.log(`[ClaudeLauncher] Generated filtered .mcp.json (${toolAllowlist.length} tools) at ${cwd}`);
|
|
197
52
|
}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
53
|
+
catch (err) {
|
|
54
|
+
console.warn(`[ClaudeLauncher] Failed to generate filtered .mcp.json: ${err}`);
|
|
55
|
+
if (existsSync(mcpSource))
|
|
56
|
+
copyFileSync(mcpSource, mcpTarget);
|
|
202
57
|
}
|
|
203
|
-
// Send the prompt to Claude via tmux send-keys after MCP servers initialize
|
|
204
|
-
const sessionToPrompt = tmuxSession;
|
|
205
|
-
const promptToSend = prompt;
|
|
206
|
-
setTimeout(() => {
|
|
207
|
-
const escapedPrompt = promptToSend.replace(/"/g, '\\"').replace(/\n/g, ' ');
|
|
208
|
-
const sendKeys = spawn('tmux', ['send-keys', '-t', sessionToPrompt, escapedPrompt], { stdio: 'pipe' });
|
|
209
|
-
sendKeys.on('exit', (code) => {
|
|
210
|
-
if (code === 0) {
|
|
211
|
-
console.log(`[ClaudeLauncher] Sent prompt to tmux session "${sessionToPrompt}" (${promptToSend.length} chars)`);
|
|
212
|
-
// Send Enter separately after a short delay to avoid bracketed paste swallowing it
|
|
213
|
-
setTimeout(() => {
|
|
214
|
-
spawn('tmux', ['send-keys', '-t', sessionToPrompt, 'Enter'], { stdio: 'pipe' });
|
|
215
|
-
}, 500);
|
|
216
|
-
}
|
|
217
|
-
else {
|
|
218
|
-
console.error(`[ClaudeLauncher] Failed to send prompt to tmux session "${sessionToPrompt}" (exit code: ${code})`);
|
|
219
|
-
}
|
|
220
|
-
});
|
|
221
|
-
sendKeys.on('error', (err) => {
|
|
222
|
-
console.error(`[ClaudeLauncher] Error sending prompt to "${sessionToPrompt}": ${err.message}`);
|
|
223
|
-
});
|
|
224
|
-
}, 8000); // Wait 8s for MCP to initialize
|
|
225
|
-
// Monitor the tmux session for exit
|
|
226
|
-
this.monitorTmuxSession(tmuxSession);
|
|
227
|
-
return true;
|
|
228
58
|
}
|
|
229
|
-
|
|
230
|
-
|
|
59
|
+
else if (!existsSync(mcpTarget) && existsSync(mcpSource)) {
|
|
60
|
+
this.copyMcpJson(this.config.claude_working_directory || cwd, cwd);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
checkEnabled() {
|
|
64
|
+
return !!(this.config.claude_command && this.config.claude_working_directory);
|
|
65
|
+
}
|
|
66
|
+
getWorkingDirectory() {
|
|
67
|
+
return this.config.claude_working_directory || process.cwd();
|
|
68
|
+
}
|
|
69
|
+
isAutoRespawnConfigured() {
|
|
70
|
+
return !!this.config.claude_auto_respawn;
|
|
71
|
+
}
|
|
72
|
+
getIdleTimeoutMs() {
|
|
73
|
+
return this.config.claude_idle_timeout_ms || 30000;
|
|
74
|
+
}
|
|
75
|
+
getSessionPrompt() {
|
|
76
|
+
return this.config.claude_session_prompt;
|
|
77
|
+
}
|
|
78
|
+
getAgentEnv() {
|
|
79
|
+
return {
|
|
80
|
+
SILOLINK_MCP_URL: `http://localhost:${this.config.mcp_port}/mcp`,
|
|
81
|
+
SILOLINK_AGENT_PROVIDER: 'claude',
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
getTmuxPrefix() {
|
|
85
|
+
return 'silolink-claude';
|
|
86
|
+
}
|
|
87
|
+
getLogTag() {
|
|
88
|
+
return '[ClaudeLauncher]';
|
|
89
|
+
}
|
|
90
|
+
getCostMetadataKey() {
|
|
91
|
+
return 'claude_code_cost_usd';
|
|
92
|
+
}
|
|
93
|
+
buildLaunchPrompt(reason, conversationId, agentContext, workspaceContext) {
|
|
94
|
+
const extraArgs = [];
|
|
95
|
+
if (this.lastSessionId && (reason === 'process-exit' || reason === 'inbound-message')) {
|
|
96
|
+
extraArgs.push('--resume', this.lastSessionId);
|
|
97
|
+
return {
|
|
98
|
+
prompt: 'Continue polling SiloLink for messages. Check memory for context if needed.' + workspaceContext,
|
|
99
|
+
extraArgs,
|
|
100
|
+
};
|
|
231
101
|
}
|
|
102
|
+
if (reason === 'process-exit' || reason === 'inbound-message') {
|
|
103
|
+
const agentIdParam = this.pendingLlmAgentId
|
|
104
|
+
? `, "llm_agent_id": ${this.pendingLlmAgentId}`
|
|
105
|
+
: '';
|
|
106
|
+
const registerParams = conversationId
|
|
107
|
+
? `{ "conversation_id": ${conversationId}${agentIdParam} }`
|
|
108
|
+
: `{${agentIdParam ? agentIdParam.substring(2) : ''}}`;
|
|
109
|
+
this.pendingLlmAgentId = null;
|
|
110
|
+
const triggerLine = this.pendingTriggerMessage
|
|
111
|
+
? `\n\nThe user's latest message (which triggered this session restart): "${this.pendingTriggerMessage}"\nAddress this message AFTER loading context. Do NOT ask the user to repeat themselves.`
|
|
112
|
+
: '';
|
|
113
|
+
this.pendingTriggerMessage = null;
|
|
114
|
+
return {
|
|
115
|
+
prompt: [
|
|
116
|
+
`Call mcp__silolink__remote_register with these exact parameters: ${registerParams}. After registering, call remote_load_context() to load the prior conversation history. Review all returned messages to understand what was discussed. Then send a remote_notify telling the user you are continuing where the last session left off. Then enter the poll loop using remote_poll. See CLAUDE.md for the poll loop pattern.`,
|
|
117
|
+
'',
|
|
118
|
+
'Context: This is a new session replacing a previous one that ended.',
|
|
119
|
+
'Before entering the poll loop:',
|
|
120
|
+
'1. Call remote_load_context() and review the conversation history',
|
|
121
|
+
'2. Check your memory files for additional context on recent work',
|
|
122
|
+
'3. Send a remote_notify summarizing what you loaded and confirming you are ready',
|
|
123
|
+
'4. Process the user\'s pending message (if any) before entering the poll loop',
|
|
124
|
+
'',
|
|
125
|
+
'CRITICAL: The user is NOT watching the terminal. You MUST send a remote_notify progress update after EVERY major step (file edits, commits, merges, deploys, errors). Do not go silent while working.',
|
|
126
|
+
triggerLine,
|
|
127
|
+
agentContext,
|
|
128
|
+
workspaceContext,
|
|
129
|
+
].join('\n'),
|
|
130
|
+
extraArgs,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
prompt: (this.config.claude_session_prompt ||
|
|
135
|
+
'Register with SiloLink as "portablemind" and enter the poll loop.') + agentContext + workspaceContext,
|
|
136
|
+
extraArgs,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
// --- Claude-specific additions ---
|
|
140
|
+
getLastClaudeSessionId() {
|
|
141
|
+
return this.lastSessionId;
|
|
232
142
|
}
|
|
233
143
|
/**
|
|
234
144
|
* Store the Claude resume ID in the DSiloed agent session metadata.
|
|
@@ -248,64 +158,21 @@ export class ClaudeLauncher {
|
|
|
248
158
|
}
|
|
249
159
|
}
|
|
250
160
|
/**
|
|
251
|
-
*
|
|
252
|
-
* The worktree will be created when launch() is called.
|
|
253
|
-
*/
|
|
254
|
-
setWorkspaceConfig(config) {
|
|
255
|
-
this.pendingWorkspaceConfig = config;
|
|
256
|
-
}
|
|
257
|
-
/**
|
|
258
|
-
* Called when an inbound message arrives and no active session can handle it.
|
|
259
|
-
* @param triggerMessage - Content of the message that triggered the launch (included in prompt)
|
|
260
|
-
*/
|
|
261
|
-
async launchOnMessage(conversationId, triggerMessage) {
|
|
262
|
-
if (!this.enabled)
|
|
263
|
-
return false;
|
|
264
|
-
// Check if there's already a session registered for this conversation
|
|
265
|
-
if (conversationId) {
|
|
266
|
-
const existingSession = this.sessionManager.getByConversationId(conversationId);
|
|
267
|
-
if (existingSession) {
|
|
268
|
-
console.log(`[ClaudeLauncher] Session already exists for conversation ${conversationId} — skipping`);
|
|
269
|
-
return false;
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
// Store trigger message so the launch prompt can include it
|
|
273
|
-
this.pendingTriggerMessage = triggerMessage || null;
|
|
274
|
-
// If a workspace is linked to this conversation, set it as pending so
|
|
275
|
-
// launchInternal creates the session in the correct worktree directory.
|
|
276
|
-
if (conversationId && this.workspaceManager) {
|
|
277
|
-
const workspace = this.workspaceManager.getByConversationId(conversationId);
|
|
278
|
-
if (workspace) {
|
|
279
|
-
console.log(`[ClaudeLauncher] Found linked workspace for conv=${conversationId}: ${workspace.branch}`);
|
|
280
|
-
// Don't create a new worktree — just set CWD to existing one.
|
|
281
|
-
// We skip setWorkspaceConfig to avoid re-creating the worktree.
|
|
282
|
-
// Instead, override the working directory directly for this launch.
|
|
283
|
-
this.pendingWorkspaceCwd = workspace.worktreePath;
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
return this.launchInternal('inbound-message', conversationId);
|
|
287
|
-
}
|
|
288
|
-
/**
|
|
289
|
-
* Check for idle sessions — nudge if process alive, respawn if dead.
|
|
290
|
-
* If a session has been nudged multiple times without responding and has
|
|
291
|
-
* pending messages, kill and relaunch it (hung session recovery).
|
|
161
|
+
* Extended idle checking with Claude-specific hung session recovery.
|
|
292
162
|
*/
|
|
293
163
|
async checkIdle() {
|
|
294
164
|
const sessions = this.sessionManager.getAll();
|
|
295
|
-
const idleTimeout = this.
|
|
296
|
-
const maxNudges = 10;
|
|
165
|
+
const idleTimeout = this.getIdleTimeoutMs();
|
|
166
|
+
const maxNudges = 10;
|
|
297
167
|
const now = Date.now();
|
|
298
168
|
if (sessions.length === 0)
|
|
299
169
|
return;
|
|
300
|
-
|
|
301
|
-
// process the launch prompt and initial messages before intervening.
|
|
302
|
-
const startupGracePeriod = 90000; // 90 seconds
|
|
170
|
+
const startupGracePeriod = 90000;
|
|
303
171
|
const allPastStartup = sessions.every(s => now - s.createdAt.getTime() > startupGracePeriod);
|
|
304
172
|
if (!allPastStartup)
|
|
305
173
|
return;
|
|
306
174
|
const allIdle = sessions.every(s => now - s.lastActivity.getTime() > idleTimeout);
|
|
307
175
|
if (!allIdle) {
|
|
308
|
-
// Activity detected — reset nudge counter and update snapshots
|
|
309
176
|
this.nudgeCount = 0;
|
|
310
177
|
for (const s of sessions) {
|
|
311
178
|
this.lastActivitySnapshot.set(s.sessionId, s.lastActivity.getTime());
|
|
@@ -313,7 +180,6 @@ export class ClaudeLauncher {
|
|
|
313
180
|
return;
|
|
314
181
|
}
|
|
315
182
|
if (this.hasLiveProcess()) {
|
|
316
|
-
// Check if activity changed since last nudge
|
|
317
183
|
const activityChanged = sessions.some(s => {
|
|
318
184
|
const prev = this.lastActivitySnapshot.get(s.sessionId) || 0;
|
|
319
185
|
return s.lastActivity.getTime() > prev;
|
|
@@ -321,15 +187,11 @@ export class ClaudeLauncher {
|
|
|
321
187
|
if (activityChanged) {
|
|
322
188
|
this.nudgeCount = 0;
|
|
323
189
|
}
|
|
324
|
-
// Update snapshots
|
|
325
190
|
for (const s of sessions) {
|
|
326
191
|
this.lastActivitySnapshot.set(s.sessionId, s.lastActivity.getTime());
|
|
327
192
|
}
|
|
328
|
-
// Check if any session has messages that haven't been delivered yet.
|
|
329
|
-
// Messages already polled by Claude (pending ack) don't count — Claude is working on them.
|
|
330
193
|
const hasUndelivered = this.messageQueue && sessions.some(s => this.messageQueue.hasUndeliveredMessages(s.sessionId));
|
|
331
194
|
if (this.nudgeCount >= maxNudges && hasUndelivered) {
|
|
332
|
-
// Session is hung with undelivered messages — kill and relaunch
|
|
333
195
|
console.log(`[ClaudeLauncher] Session hung (${this.nudgeCount} nudges with no response, undelivered messages) — killing and relaunching`);
|
|
334
196
|
const hungSessions = sessions.filter(s => this.messageQueue.hasUndeliveredMessages(s.sessionId));
|
|
335
197
|
for (const s of hungSessions) {
|
|
@@ -338,36 +200,28 @@ export class ClaudeLauncher {
|
|
|
338
200
|
this.messageQueue.clearSession(s.sessionId);
|
|
339
201
|
}
|
|
340
202
|
this.sessionManager.unregister(s.sessionId);
|
|
341
|
-
// Wait for tmux kill to complete before relaunching
|
|
342
203
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
343
204
|
await this.launchInternal('inbound-message', s.conversationId);
|
|
344
205
|
}
|
|
345
206
|
this.nudgeCount = 0;
|
|
346
207
|
return;
|
|
347
208
|
}
|
|
348
|
-
|
|
349
|
-
// AND has undelivered messages waiting. A session doing non-MCP work (file edits, bash
|
|
350
|
-
// commands, etc.) is not a zombie — it's just not using SiloLink tools.
|
|
351
|
-
const zombieTimeout = 10 * 60 * 1000; // 10 minutes
|
|
209
|
+
const zombieTimeout = 10 * 60 * 1000;
|
|
352
210
|
const zombieSessions = sessions.filter(s => now - s.lastActivity.getTime() > zombieTimeout &&
|
|
353
211
|
this.messageQueue?.hasUndeliveredMessages(s.sessionId));
|
|
354
212
|
for (const s of zombieSessions) {
|
|
355
213
|
console.log(`[ClaudeLauncher] Zombie session detected (no MCP activity for ${Math.round((now - s.lastActivity.getTime()) / 60000)}min, has undelivered messages) — killing conv=${s.conversationId}`);
|
|
356
214
|
this.killByConversation(s.conversationId);
|
|
357
|
-
// Clear queue before unregister so messages can be re-buffered on relaunch
|
|
358
215
|
if (this.messageQueue) {
|
|
359
216
|
this.messageQueue.clearSession(s.sessionId);
|
|
360
217
|
}
|
|
361
218
|
this.sessionManager.unregister(s.sessionId);
|
|
362
|
-
// Wait for tmux kill to complete before relaunching
|
|
363
219
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
364
220
|
console.log(`[ClaudeLauncher] Relaunching for conversation ${s.conversationId} after zombie cleanup`);
|
|
365
221
|
this.launchInternal('inbound-message', s.conversationId).catch(err => {
|
|
366
222
|
console.error(`[ClaudeLauncher] Relaunch after zombie kill failed: ${err}`);
|
|
367
223
|
});
|
|
368
224
|
}
|
|
369
|
-
// Only nudge if there are messages waiting — no point nudging an idle session
|
|
370
|
-
// that's already polling with nothing to do
|
|
371
225
|
if (hasUndelivered) {
|
|
372
226
|
this.nudge();
|
|
373
227
|
this.nudgeCount++;
|
|
@@ -375,241 +229,28 @@ export class ClaudeLauncher {
|
|
|
375
229
|
return;
|
|
376
230
|
}
|
|
377
231
|
else if (this.launchingConversations.size === 0) {
|
|
378
|
-
// Process is dead — clean up orphaned sessions instead of auto-respawning.
|
|
379
|
-
// New sessions will be launched on-demand when inbound messages arrive
|
|
380
|
-
// (handled by SubscriptionManager.handleCableMessage → launchOnMessage).
|
|
381
232
|
console.log(`[ClaudeLauncher] Sessions orphaned (process dead) — cleaning up ${sessions.length} session(s). Will relaunch on next inbound message.`);
|
|
382
233
|
for (const s of sessions) {
|
|
383
|
-
if (!this.
|
|
384
|
-
this.
|
|
234
|
+
if (!this.lastSessionId && s.claudeResumeId) {
|
|
235
|
+
this.lastSessionId = s.claudeResumeId;
|
|
385
236
|
}
|
|
386
237
|
this.sessionManager.unregister(s.sessionId);
|
|
387
238
|
}
|
|
388
239
|
this.nudgeCount = 0;
|
|
389
240
|
}
|
|
390
241
|
}
|
|
391
|
-
/**
|
|
392
|
-
* Monitor a tmux session and detect when it exits.
|
|
393
|
-
*/
|
|
394
|
-
monitorTmuxSession(sessionName) {
|
|
395
|
-
// Start monitor if not already running
|
|
396
|
-
if (!this.tmuxMonitorTimer) {
|
|
397
|
-
this.tmuxMonitorTimer = setInterval(() => this.checkTmuxSessions(), 5000);
|
|
398
|
-
}
|
|
399
|
-
}
|
|
400
|
-
checkTmuxSessions() {
|
|
401
|
-
for (const [name] of this.tmuxSessions) {
|
|
402
|
-
const check = spawn('tmux', ['has-session', '-t', name], { stdio: 'ignore' });
|
|
403
|
-
check.on('exit', (code) => {
|
|
404
|
-
if (code !== 0) {
|
|
405
|
-
console.log(`[ClaudeLauncher] tmux session "${name}" ended`);
|
|
406
|
-
// Clean up worktree if this session had one
|
|
407
|
-
const wsSessionId = this.tmuxSessionWorkspaces.get(name);
|
|
408
|
-
if (wsSessionId && this.workspaceManager) {
|
|
409
|
-
console.log(`[ClaudeLauncher] Cleaning up worktree for workspace session ${wsSessionId}`);
|
|
410
|
-
this.workspaceManager.removeWorktree(wsSessionId);
|
|
411
|
-
this.tmuxSessionWorkspaces.delete(name);
|
|
412
|
-
}
|
|
413
|
-
this.tmuxSessions.delete(name);
|
|
414
|
-
if (this.tmuxSessions.size === 0 && this.tmuxMonitorTimer) {
|
|
415
|
-
clearInterval(this.tmuxMonitorTimer);
|
|
416
|
-
this.tmuxMonitorTimer = null;
|
|
417
|
-
}
|
|
418
|
-
}
|
|
419
|
-
});
|
|
420
|
-
}
|
|
421
|
-
}
|
|
422
|
-
/**
|
|
423
|
-
* Send a message to all running Claude sessions via tmux send-keys.
|
|
424
|
-
*/
|
|
425
|
-
nudge() {
|
|
426
|
-
if (this.tmuxSessions.size === 0)
|
|
427
|
-
return false;
|
|
428
|
-
const now = Date.now();
|
|
429
|
-
// Exponential backoff: 30s, 60s, 120s, 240s, ... (capped at 5 min)
|
|
430
|
-
const baseInterval = 30000;
|
|
431
|
-
const interval = Math.min(baseInterval * Math.pow(2, this.nudgeCount), 5 * 60 * 1000);
|
|
432
|
-
if (now - this.lastNudgeAt < interval)
|
|
433
|
-
return false;
|
|
434
|
-
this.lastNudgeAt = now;
|
|
435
|
-
const message = 'Continue polling SiloLink for messages. Do not stop the poll loop.';
|
|
436
|
-
for (const [name] of this.tmuxSessions) {
|
|
437
|
-
spawn('tmux', ['send-keys', '-t', name, message, 'Enter'], { stdio: 'ignore' });
|
|
438
|
-
}
|
|
439
|
-
console.log(`[ClaudeLauncher] Nudged ${this.tmuxSessions.size} Claude session(s) via tmux (attempt ${this.nudgeCount + 1}, next in ${Math.round(interval / 1000)}s)`);
|
|
440
|
-
return true;
|
|
441
|
-
}
|
|
442
|
-
/**
|
|
443
|
-
* Interrupt the Claude session for a specific conversation.
|
|
444
|
-
* Sends Escape to stop current work, then redirects Claude to check
|
|
445
|
-
* for new messages via remote_poll.
|
|
446
|
-
*/
|
|
447
|
-
async interrupt(conversationId, message) {
|
|
448
|
-
for (const [name, convId] of this.tmuxSessions) {
|
|
449
|
-
if (convId === conversationId) {
|
|
450
|
-
console.log(`[ClaudeLauncher] Interrupting tmux session "${name}" (conversation ${conversationId})`);
|
|
451
|
-
// Send Escape to interrupt current work
|
|
452
|
-
spawn('tmux', ['send-keys', '-t', name, 'Escape'], { stdio: 'ignore' });
|
|
453
|
-
// Wait for Claude to stop
|
|
454
|
-
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
455
|
-
// Tell Claude to check for the new message
|
|
456
|
-
const redirect = message
|
|
457
|
-
|| 'The user interrupted you. Check remote_poll for their latest message and follow those instructions instead.';
|
|
458
|
-
const escapedRedirect = redirect.replace(/"/g, '\\"').replace(/\n/g, ' ');
|
|
459
|
-
spawn('tmux', ['send-keys', '-t', name, escapedRedirect], { stdio: 'ignore' });
|
|
460
|
-
await new Promise(resolve => setTimeout(resolve, 500));
|
|
461
|
-
spawn('tmux', ['send-keys', '-t', name, 'Enter'], { stdio: 'ignore' });
|
|
462
|
-
console.log(`[ClaudeLauncher] Interrupt sent to "${name}"`);
|
|
463
|
-
return true;
|
|
464
|
-
}
|
|
465
|
-
}
|
|
466
|
-
console.log(`[ClaudeLauncher] No tmux session found for conversation ${conversationId} to interrupt`);
|
|
467
|
-
return false;
|
|
468
|
-
}
|
|
469
|
-
/**
|
|
470
|
-
* Kill the Claude session for a specific conversation.
|
|
471
|
-
*/
|
|
472
|
-
killByConversation(conversationId) {
|
|
473
|
-
for (const [name, convId] of this.tmuxSessions) {
|
|
474
|
-
if (convId === conversationId) {
|
|
475
|
-
console.log(`[ClaudeLauncher] Killing tmux session "${name}" (conversation ${conversationId})`);
|
|
476
|
-
// Clean up worktree
|
|
477
|
-
const wsSessionId = this.tmuxSessionWorkspaces.get(name);
|
|
478
|
-
if (wsSessionId && this.workspaceManager) {
|
|
479
|
-
this.workspaceManager.removeWorktree(wsSessionId);
|
|
480
|
-
this.tmuxSessionWorkspaces.delete(name);
|
|
481
|
-
}
|
|
482
|
-
spawn('tmux', ['kill-session', '-t', name], { stdio: 'ignore' });
|
|
483
|
-
this.tmuxSessions.delete(name);
|
|
484
|
-
return;
|
|
485
|
-
}
|
|
486
|
-
}
|
|
487
|
-
console.log(`[ClaudeLauncher] No tmux session found for conversation ${conversationId}`);
|
|
488
|
-
}
|
|
489
|
-
/**
|
|
490
|
-
* Kill all running Claude Code sessions.
|
|
491
|
-
*/
|
|
492
242
|
kill() {
|
|
493
|
-
|
|
494
|
-
console.log(`[ClaudeLauncher] Killing tmux session "${name}"`);
|
|
495
|
-
// Clean up worktree
|
|
496
|
-
const wsSessionId = this.tmuxSessionWorkspaces.get(name);
|
|
497
|
-
if (wsSessionId && this.workspaceManager) {
|
|
498
|
-
this.workspaceManager.removeWorktree(wsSessionId);
|
|
499
|
-
}
|
|
500
|
-
spawn('tmux', ['kill-session', '-t', name], { stdio: 'ignore' });
|
|
501
|
-
}
|
|
502
|
-
this.tmuxSessions.clear();
|
|
503
|
-
this.tmuxSessionWorkspaces.clear();
|
|
504
|
-
if (this.tmuxMonitorTimer) {
|
|
505
|
-
clearInterval(this.tmuxMonitorTimer);
|
|
506
|
-
this.tmuxMonitorTimer = null;
|
|
507
|
-
}
|
|
243
|
+
super.kill();
|
|
508
244
|
if (this.childProcess) {
|
|
509
245
|
this.childProcess.kill('SIGTERM');
|
|
510
246
|
this.childProcess = null;
|
|
511
247
|
}
|
|
512
248
|
}
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
async launch(config) {
|
|
518
|
-
if (config.workspace) {
|
|
519
|
-
this.setWorkspaceConfig(config.workspace);
|
|
520
|
-
}
|
|
521
|
-
if (config.triggerMessage) {
|
|
522
|
-
this.pendingTriggerMessage = config.triggerMessage;
|
|
523
|
-
}
|
|
524
|
-
if (config.sessionPrompt) {
|
|
525
|
-
this.config.claude_session_prompt = config.sessionPrompt;
|
|
526
|
-
}
|
|
527
|
-
const success = await this.launchInternal(config.reason, config.conversationId);
|
|
528
|
-
if (!success)
|
|
529
|
-
return null;
|
|
530
|
-
// Find the process we just created (most recent tmux session)
|
|
531
|
-
const entries = Array.from(this.tmuxSessions.entries());
|
|
532
|
-
const latest = entries[entries.length - 1];
|
|
533
|
-
if (!latest)
|
|
534
|
-
return null;
|
|
535
|
-
const [processId, conversationId] = latest;
|
|
536
|
-
const wsSessionId = this.tmuxSessionWorkspaces.get(processId);
|
|
537
|
-
return {
|
|
538
|
-
processId,
|
|
539
|
-
conversationId,
|
|
540
|
-
workingDirectory: this.config.claude_working_directory || process.cwd(),
|
|
541
|
-
branch: config.workspace?.branch,
|
|
542
|
-
workspaceSessionId: wsSessionId || undefined,
|
|
543
|
-
startedAt: new Date(),
|
|
544
|
-
};
|
|
545
|
-
}
|
|
546
|
-
/**
|
|
547
|
-
* Resume a previous session by ID.
|
|
548
|
-
*/
|
|
549
|
-
async resume(resumeId, config) {
|
|
550
|
-
this.setLastResumeId(resumeId);
|
|
551
|
-
return this.launch({ ...config, reason: 'process-exit' });
|
|
552
|
-
}
|
|
553
|
-
/**
|
|
554
|
-
* Check if a specific process is still alive.
|
|
555
|
-
*/
|
|
556
|
-
async isAlive(process) {
|
|
557
|
-
return new Promise(resolve => {
|
|
558
|
-
const check = spawn('tmux', ['has-session', '-t', process.processId], { stdio: 'ignore' });
|
|
559
|
-
check.on('exit', code => resolve(code === 0));
|
|
560
|
-
check.on('error', () => resolve(false));
|
|
561
|
-
});
|
|
562
|
-
}
|
|
563
|
-
/**
|
|
564
|
-
* Terminate a specific agent process.
|
|
565
|
-
*/
|
|
566
|
-
terminate(process) {
|
|
567
|
-
this.killByConversation(process.conversationId);
|
|
568
|
-
}
|
|
569
|
-
/**
|
|
570
|
-
* Terminate all managed agent processes.
|
|
571
|
-
*/
|
|
572
|
-
terminateAll() {
|
|
573
|
-
this.kill();
|
|
574
|
-
}
|
|
575
|
-
/**
|
|
576
|
-
* Get all currently running agent processes.
|
|
577
|
-
*/
|
|
578
|
-
getProcesses() {
|
|
579
|
-
const processes = [];
|
|
580
|
-
for (const [processId, conversationId] of this.tmuxSessions) {
|
|
581
|
-
const wsSessionId = this.tmuxSessionWorkspaces.get(processId);
|
|
582
|
-
processes.push({
|
|
583
|
-
processId,
|
|
584
|
-
conversationId,
|
|
585
|
-
workingDirectory: this.config.claude_working_directory || process.cwd(),
|
|
586
|
-
workspaceSessionId: wsSessionId,
|
|
587
|
-
startedAt: new Date(), // Approximate — we don't track exact start time per tmux session
|
|
588
|
-
});
|
|
589
|
-
}
|
|
590
|
-
return processes;
|
|
591
|
-
}
|
|
592
|
-
/**
|
|
593
|
-
* Get the process handling a specific conversation.
|
|
594
|
-
*/
|
|
595
|
-
getByConversation(conversationId) {
|
|
596
|
-
for (const [processId, convId] of this.tmuxSessions) {
|
|
597
|
-
if (convId === conversationId) {
|
|
598
|
-
const wsSessionId = this.tmuxSessionWorkspaces.get(processId);
|
|
599
|
-
return {
|
|
600
|
-
processId,
|
|
601
|
-
conversationId,
|
|
602
|
-
workingDirectory: this.config.claude_working_directory || process.cwd(),
|
|
603
|
-
workspaceSessionId: wsSessionId,
|
|
604
|
-
startedAt: new Date(),
|
|
605
|
-
};
|
|
606
|
-
}
|
|
249
|
+
stopIdleMonitor() {
|
|
250
|
+
if (this.idleCheckTimer) {
|
|
251
|
+
clearInterval(this.idleCheckTimer);
|
|
252
|
+
this.idleCheckTimer = null;
|
|
607
253
|
}
|
|
608
|
-
return undefined;
|
|
609
|
-
}
|
|
610
|
-
destroy() {
|
|
611
|
-
this.stopIdleMonitor();
|
|
612
|
-
this.kill();
|
|
613
254
|
}
|
|
614
255
|
}
|
|
615
256
|
//# sourceMappingURL=claude-launcher.js.map
|