@dsiloed/silo-link 1.3.0 → 1.5.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.
Files changed (58) hide show
  1. package/dist/api/dsiloed-client.d.ts +30 -0
  2. package/dist/api/dsiloed-client.d.ts.map +1 -1
  3. package/dist/api/dsiloed-client.js +66 -0
  4. package/dist/api/dsiloed-client.js.map +1 -1
  5. package/dist/cable/subscription-manager.d.ts +7 -3
  6. package/dist/cable/subscription-manager.d.ts.map +1 -1
  7. package/dist/cable/subscription-manager.js +36 -11
  8. package/dist/cable/subscription-manager.js.map +1 -1
  9. package/dist/cli/commands.d.ts.map +1 -1
  10. package/dist/cli/commands.js +118 -27
  11. package/dist/cli/commands.js.map +1 -1
  12. package/dist/config/config-manager.d.ts +4 -4
  13. package/dist/config/config-manager.d.ts.map +1 -1
  14. package/dist/config/config-manager.js +30 -15
  15. package/dist/config/config-manager.js.map +1 -1
  16. package/dist/core/agent-launcher.d.ts +30 -1
  17. package/dist/core/agent-launcher.d.ts.map +1 -1
  18. package/dist/core/base-tmux-launcher.d.ts +106 -0
  19. package/dist/core/base-tmux-launcher.d.ts.map +1 -0
  20. package/dist/core/base-tmux-launcher.js +506 -0
  21. package/dist/core/base-tmux-launcher.js.map +1 -0
  22. package/dist/core/bridge.d.ts +19 -2
  23. package/dist/core/bridge.d.ts.map +1 -1
  24. package/dist/core/bridge.js +155 -37
  25. package/dist/core/bridge.js.map +1 -1
  26. package/dist/core/claude-launcher.d.ts +28 -100
  27. package/dist/core/claude-launcher.d.ts.map +1 -1
  28. package/dist/core/claude-launcher.js +164 -487
  29. package/dist/core/claude-launcher.js.map +1 -1
  30. package/dist/core/gemini-launcher.d.ts +37 -27
  31. package/dist/core/gemini-launcher.d.ts.map +1 -1
  32. package/dist/core/gemini-launcher.js +135 -38
  33. package/dist/core/gemini-launcher.js.map +1 -1
  34. package/dist/core/launcher-factory.d.ts.map +1 -1
  35. package/dist/core/launcher-factory.js +1 -2
  36. package/dist/core/launcher-factory.js.map +1 -1
  37. package/dist/core/openai-launcher.d.ts +14 -17
  38. package/dist/core/openai-launcher.d.ts.map +1 -1
  39. package/dist/core/openai-launcher.js +23 -44
  40. package/dist/core/openai-launcher.js.map +1 -1
  41. package/dist/core/session-manager.d.ts +3 -0
  42. package/dist/core/session-manager.d.ts.map +1 -1
  43. package/dist/core/session-manager.js +11 -0
  44. package/dist/core/session-manager.js.map +1 -1
  45. package/dist/core/workspace-manager.d.ts.map +1 -1
  46. package/dist/core/workspace-manager.js +53 -16
  47. package/dist/core/workspace-manager.js.map +1 -1
  48. package/dist/mcp/server.d.ts +3 -0
  49. package/dist/mcp/server.d.ts.map +1 -1
  50. package/dist/mcp/server.js +19 -2
  51. package/dist/mcp/server.js.map +1 -1
  52. package/dist/mcp/tools/register-tools.d.ts +2 -0
  53. package/dist/mcp/tools/register-tools.d.ts.map +1 -1
  54. package/dist/mcp/tools/register-tools.js +174 -16
  55. package/dist/mcp/tools/register-tools.js.map +1 -1
  56. package/dist/types/index.d.ts +11 -1
  57. package/dist/types/index.d.ts.map +1 -1
  58. package/package.json +1 -1
@@ -1,4 +1,6 @@
1
- import { spawn } from 'node:child_process';
1
+ import { execFileSync } from 'node:child_process';
2
+ import jwt from 'jsonwebtoken';
3
+ import { BaseTmuxLauncher } from './base-tmux-launcher.js';
2
4
  /**
3
5
  * Claude Code implementation of the AgentLauncher interface.
4
6
  *
@@ -9,226 +11,170 @@ 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
- this.config = config;
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
- isEnabled() {
45
- return this.enabled;
19
+ // --- Abstract implementation ---
20
+ getCommand() {
21
+ return this.config.claude_command || 'claude';
46
22
  }
47
- isAutoRespawnEnabled() {
48
- return this.enabled && !!this.config.claude_auto_respawn;
23
+ getArgs() {
24
+ return ['--dangerously-skip-permissions'];
49
25
  }
50
- hasLiveProcess() {
51
- return this.tmuxSessions.size > 0;
26
+ getReadyIndicator() {
27
+ return ['❯'];
52
28
  }
53
- getLastClaudeSessionId() {
54
- return this.lastClaudeSessionId;
55
- }
56
- setLastResumeId(resumeId) {
57
- this.lastClaudeSessionId = resumeId;
58
- console.log(`[ClaudeLauncher] Restored resume ID from previous session: ${resumeId}`);
29
+ /** Wait 5s before checking — Claude Code shows ❯ in its welcome screen before it's ready */
30
+ getMinReadyWaitMs() { return 5000; }
31
+ /** Wait 3s after ready for npx mcp-remote servers to finish connecting */
32
+ getPostReadyDelayMs() { return 3000; }
33
+ formatPrompt(prompt) {
34
+ return prompt;
59
35
  }
60
- /**
61
- * Start the idle monitor that nudges idle sessions or respawns dead ones.
62
- */
63
- startIdleMonitor() {
64
- if (!this.isAutoRespawnEnabled())
65
- return;
66
- const checkInterval = Math.max((this.config.claude_idle_timeout_ms || 30000) / 2, 5000);
67
- this.idleCheckTimer = setInterval(() => this.checkIdle(), checkInterval);
68
- console.log(` Claude auto-manage enabled (idle timeout: ${this.config.claude_idle_timeout_ms || 30000}ms)`);
69
- }
70
- stopIdleMonitor() {
71
- if (this.idleCheckTimer) {
72
- clearInterval(this.idleCheckTimer);
73
- this.idleCheckTimer = null;
74
- }
75
- }
76
- /**
77
- * Launch a new Claude Code session. Uses --resume if a previous session ID is available.
78
- * This is the internal launch method — use launchAgent() for the AgentLauncher interface.
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;
36
+ async setupMcpConfig(cwd, toolAllowlist) {
37
+ // Generate a JWT for the dsiloed MCP server
38
+ const token = jwt.sign({
39
+ sub: this.config.user_sub,
40
+ iat: Math.floor(Date.now() / 1000),
41
+ tenant_enterprise_identifier: this.config.tenant_id,
42
+ }, this.config.shared_key, { algorithm: 'HS256', expiresIn: '24h' });
43
+ // Build the dsiloed MCP URL pointing to the same server SiloLink connects to
44
+ let dsiloedUrl = `${this.config.host}/api/v1/mcp?categories=data,admin,conversation,utility`;
45
+ if (toolAllowlist && toolAllowlist.length > 0) {
46
+ const url = new URL(`${this.config.host}/api/v1/mcp`);
47
+ url.searchParams.set('tools', toolAllowlist.join(','));
48
+ dsiloedUrl = url.toString();
89
49
  }
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;
50
+ // Register MCP servers via `claude mcp add` (project scope for the target CWD).
51
+ // This is the official API — works across Claude Code versions and doesn't
52
+ // require interactive approval when launched with --dangerously-skip-permissions.
53
+ const servers = [
54
+ {
55
+ name: 'silolink',
56
+ args: ['--', 'npx', 'mcp-remote', `http://localhost:${this.config.mcp_port}/mcp`],
57
+ },
58
+ {
59
+ name: 'silolink_server',
60
+ args: [
61
+ '--', 'npx', 'mcp-remote', dsiloedUrl,
62
+ '--header', `Authorization: Bearer ${token}`,
63
+ '--header', `Tenant-Id: ${this.config.tenant_id}`,
64
+ ],
65
+ },
66
+ ];
67
+ for (const server of servers) {
68
+ try {
69
+ execFileSync('claude', ['mcp', 'add', '-s', 'user', server.name, ...server.args], {
70
+ cwd,
71
+ stdio: 'pipe',
72
+ timeout: 10000,
73
+ });
74
+ }
75
+ catch {
76
+ // Server may already exist at user or project scope — remove both and re-add at user scope
104
77
  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})`);
78
+ try {
79
+ execFileSync('claude', ['mcp', 'remove', '-s', 'user', server.name], { cwd, stdio: 'pipe', timeout: 5000 });
80
+ }
81
+ catch { /* may not exist */ }
82
+ try {
83
+ execFileSync('claude', ['mcp', 'remove', '-s', 'project', server.name], { cwd, stdio: 'pipe', timeout: 5000 });
84
+ }
85
+ catch { /* may not exist */ }
86
+ execFileSync('claude', ['mcp', 'add', '-s', 'user', server.name, ...server.args], {
87
+ cwd,
88
+ stdio: 'pipe',
89
+ timeout: 10000,
90
+ });
108
91
  }
109
92
  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;
93
+ console.error(`[ClaudeLauncher] Failed to register MCP server '${server.name}': ${err}`);
161
94
  }
162
95
  }
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);
195
- }
196
- return false;
197
- }
198
- console.log(`[ClaudeLauncher] tmux session "${tmuxSession}" created`);
199
- this.tmuxSessions.set(tmuxSession, conversationId || 0);
200
- if (workspaceSessionId) {
201
- this.tmuxSessionWorkspaces.set(tmuxSession, workspaceSessionId);
202
- }
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
96
  }
229
- finally {
230
- setTimeout(() => { this.launchingConversations.delete(convKey); }, 10000);
97
+ console.log(`[ClaudeLauncher] Registered MCP servers for ${cwd} (host: ${this.config.host})`);
98
+ }
99
+ checkEnabled() {
100
+ return !!(this.config.claude_command && this.config.projects_path);
101
+ }
102
+ getWorkingDirectory() {
103
+ return this.config.projects_path || process.cwd();
104
+ }
105
+ isAutoRespawnConfigured() {
106
+ return !!this.config.claude_auto_respawn;
107
+ }
108
+ getIdleTimeoutMs() {
109
+ return this.config.claude_idle_timeout_ms || 30000;
110
+ }
111
+ getSessionPrompt() {
112
+ return this.config.claude_session_prompt;
113
+ }
114
+ getAgentEnv() {
115
+ return {
116
+ SILOLINK_MCP_URL: `http://localhost:${this.config.mcp_port}/mcp`,
117
+ SILOLINK_AGENT_PROVIDER: 'claude',
118
+ };
119
+ }
120
+ getTmuxPrefix() {
121
+ return 'silolink-claude';
122
+ }
123
+ getLogTag() {
124
+ return '[ClaudeLauncher]';
125
+ }
126
+ getCostMetadataKey() {
127
+ return 'claude_code_cost_usd';
128
+ }
129
+ buildLaunchPrompt(reason, conversationId, agentContext, workspaceContext) {
130
+ const extraArgs = [];
131
+ if (this.lastSessionId && (reason === 'process-exit' || reason === 'inbound-message')) {
132
+ extraArgs.push('--resume', this.lastSessionId);
133
+ return {
134
+ prompt: 'Continue polling SiloLink for messages. Check memory for context if needed.' + workspaceContext,
135
+ extraArgs,
136
+ };
137
+ }
138
+ if (reason === 'process-exit' || reason === 'inbound-message') {
139
+ const agentIdParam = this.pendingLlmAgentId
140
+ ? `, "llm_agent_id": ${this.pendingLlmAgentId}`
141
+ : '';
142
+ const registerParams = conversationId
143
+ ? `{ "conversation_id": ${conversationId}${agentIdParam} }`
144
+ : `{${agentIdParam ? agentIdParam.substring(2) : ''}}`;
145
+ this.pendingLlmAgentId = null;
146
+ const triggerLine = this.pendingTriggerMessage
147
+ ? `\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.`
148
+ : '';
149
+ this.pendingTriggerMessage = null;
150
+ return {
151
+ prompt: [
152
+ `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.`,
153
+ '',
154
+ 'Context: This is a new session replacing a previous one that ended.',
155
+ 'Before entering the poll loop:',
156
+ '1. Call remote_load_context() and review the conversation history',
157
+ '2. Check your memory files for additional context on recent work',
158
+ '3. Send a remote_notify summarizing what you loaded and confirming you are ready',
159
+ '4. Process the user\'s pending message (if any) before entering the poll loop',
160
+ '',
161
+ '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.',
162
+ triggerLine,
163
+ agentContext,
164
+ workspaceContext,
165
+ ].join('\n'),
166
+ extraArgs,
167
+ };
231
168
  }
169
+ return {
170
+ prompt: (this.config.claude_session_prompt ||
171
+ 'Register with SiloLink as "portablemind" and enter the poll loop.') + agentContext + workspaceContext,
172
+ extraArgs,
173
+ };
174
+ }
175
+ // --- Claude-specific additions ---
176
+ getLastClaudeSessionId() {
177
+ return this.lastSessionId;
232
178
  }
233
179
  /**
234
180
  * Store the Claude resume ID in the DSiloed agent session metadata.
@@ -248,64 +194,21 @@ export class ClaudeLauncher {
248
194
  }
249
195
  }
250
196
  /**
251
- * Set workspace configuration for the next launch.
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).
197
+ * Extended idle checking with Claude-specific hung session recovery.
292
198
  */
293
199
  async checkIdle() {
294
200
  const sessions = this.sessionManager.getAll();
295
- const idleTimeout = this.config.claude_idle_timeout_ms || 30000;
296
- const maxNudges = 10; // Kill after 10 failed nudges (~5 min with no response)
201
+ const idleTimeout = this.getIdleTimeoutMs();
202
+ const maxNudges = 10;
297
203
  const now = Date.now();
298
204
  if (sessions.length === 0)
299
205
  return;
300
- // Don't nudge sessions that were just created — give them time to
301
- // process the launch prompt and initial messages before intervening.
302
- const startupGracePeriod = 90000; // 90 seconds
206
+ const startupGracePeriod = 90000;
303
207
  const allPastStartup = sessions.every(s => now - s.createdAt.getTime() > startupGracePeriod);
304
208
  if (!allPastStartup)
305
209
  return;
306
210
  const allIdle = sessions.every(s => now - s.lastActivity.getTime() > idleTimeout);
307
211
  if (!allIdle) {
308
- // Activity detected — reset nudge counter and update snapshots
309
212
  this.nudgeCount = 0;
310
213
  for (const s of sessions) {
311
214
  this.lastActivitySnapshot.set(s.sessionId, s.lastActivity.getTime());
@@ -313,7 +216,6 @@ export class ClaudeLauncher {
313
216
  return;
314
217
  }
315
218
  if (this.hasLiveProcess()) {
316
- // Check if activity changed since last nudge
317
219
  const activityChanged = sessions.some(s => {
318
220
  const prev = this.lastActivitySnapshot.get(s.sessionId) || 0;
319
221
  return s.lastActivity.getTime() > prev;
@@ -321,15 +223,11 @@ export class ClaudeLauncher {
321
223
  if (activityChanged) {
322
224
  this.nudgeCount = 0;
323
225
  }
324
- // Update snapshots
325
226
  for (const s of sessions) {
326
227
  this.lastActivitySnapshot.set(s.sessionId, s.lastActivity.getTime());
327
228
  }
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
229
  const hasUndelivered = this.messageQueue && sessions.some(s => this.messageQueue.hasUndeliveredMessages(s.sessionId));
331
230
  if (this.nudgeCount >= maxNudges && hasUndelivered) {
332
- // Session is hung with undelivered messages — kill and relaunch
333
231
  console.log(`[ClaudeLauncher] Session hung (${this.nudgeCount} nudges with no response, undelivered messages) — killing and relaunching`);
334
232
  const hungSessions = sessions.filter(s => this.messageQueue.hasUndeliveredMessages(s.sessionId));
335
233
  for (const s of hungSessions) {
@@ -338,36 +236,28 @@ export class ClaudeLauncher {
338
236
  this.messageQueue.clearSession(s.sessionId);
339
237
  }
340
238
  this.sessionManager.unregister(s.sessionId);
341
- // Wait for tmux kill to complete before relaunching
342
239
  await new Promise(resolve => setTimeout(resolve, 2000));
343
240
  await this.launchInternal('inbound-message', s.conversationId);
344
241
  }
345
242
  this.nudgeCount = 0;
346
243
  return;
347
244
  }
348
- // Kill zombie sessions only if a session hasn't made any MCP tool call in 10 minutes
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
245
+ const zombieTimeout = 10 * 60 * 1000;
352
246
  const zombieSessions = sessions.filter(s => now - s.lastActivity.getTime() > zombieTimeout &&
353
247
  this.messageQueue?.hasUndeliveredMessages(s.sessionId));
354
248
  for (const s of zombieSessions) {
355
249
  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
250
  this.killByConversation(s.conversationId);
357
- // Clear queue before unregister so messages can be re-buffered on relaunch
358
251
  if (this.messageQueue) {
359
252
  this.messageQueue.clearSession(s.sessionId);
360
253
  }
361
254
  this.sessionManager.unregister(s.sessionId);
362
- // Wait for tmux kill to complete before relaunching
363
255
  await new Promise(resolve => setTimeout(resolve, 2000));
364
256
  console.log(`[ClaudeLauncher] Relaunching for conversation ${s.conversationId} after zombie cleanup`);
365
257
  this.launchInternal('inbound-message', s.conversationId).catch(err => {
366
258
  console.error(`[ClaudeLauncher] Relaunch after zombie kill failed: ${err}`);
367
259
  });
368
260
  }
369
- // Only nudge if there are messages waiting — no point nudging an idle session
370
- // that's already polling with nothing to do
371
261
  if (hasUndelivered) {
372
262
  this.nudge();
373
263
  this.nudgeCount++;
@@ -375,241 +265,28 @@ export class ClaudeLauncher {
375
265
  return;
376
266
  }
377
267
  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
268
  console.log(`[ClaudeLauncher] Sessions orphaned (process dead) — cleaning up ${sessions.length} session(s). Will relaunch on next inbound message.`);
382
269
  for (const s of sessions) {
383
- if (!this.lastClaudeSessionId && s.claudeResumeId) {
384
- this.lastClaudeSessionId = s.claudeResumeId;
270
+ if (!this.lastSessionId && s.claudeResumeId) {
271
+ this.lastSessionId = s.claudeResumeId;
385
272
  }
386
273
  this.sessionManager.unregister(s.sessionId);
387
274
  }
388
275
  this.nudgeCount = 0;
389
276
  }
390
277
  }
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
278
  kill() {
493
- for (const [name] of this.tmuxSessions) {
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
- }
279
+ super.kill();
508
280
  if (this.childProcess) {
509
281
  this.childProcess.kill('SIGTERM');
510
282
  this.childProcess = null;
511
283
  }
512
284
  }
513
- // ---- AgentLauncher interface methods ----
514
- /**
515
- * Launch a new agent process (AgentLauncher interface).
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
- }
285
+ stopIdleMonitor() {
286
+ if (this.idleCheckTimer) {
287
+ clearInterval(this.idleCheckTimer);
288
+ this.idleCheckTimer = null;
607
289
  }
608
- return undefined;
609
- }
610
- destroy() {
611
- this.stopIdleMonitor();
612
- this.kill();
613
290
  }
614
291
  }
615
292
  //# sourceMappingURL=claude-launcher.js.map