@dsiloed/silo-link 1.3.0 → 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.
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 +112 -24
  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 +28 -14
  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 +102 -0
  19. package/dist/core/base-tmux-launcher.d.ts.map +1 -0
  20. package/dist/core/base-tmux-launcher.js +491 -0
  21. package/dist/core/base-tmux-launcher.js.map +1 -0
  22. package/dist/core/bridge.d.ts +14 -2
  23. package/dist/core/bridge.d.ts.map +1 -1
  24. package/dist/core/bridge.js +129 -35
  25. package/dist/core/bridge.js.map +1 -1
  26. package/dist/core/claude-launcher.d.ts +24 -100
  27. package/dist/core/claude-launcher.d.ts.map +1 -1
  28. package/dist/core/claude-launcher.js +130 -489
  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 +126 -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 -0
  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 { 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
- 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
+ formatPrompt(prompt) {
30
+ return prompt;
59
31
  }
60
- /**
61
- * Start the idle monitor that nudges idle sessions or respawns dead ones.
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 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;
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
- return false;
50
+ writeFileSync(mcpTarget, JSON.stringify(mcpConfig, null, 2));
51
+ console.log(`[ClaudeLauncher] Generated filtered .mcp.json (${toolAllowlist.length} tools) at ${cwd}`);
197
52
  }
198
- console.log(`[ClaudeLauncher] tmux session "${tmuxSession}" created`);
199
- this.tmuxSessions.set(tmuxSession, conversationId || 0);
200
- if (workspaceSessionId) {
201
- this.tmuxSessionWorkspaces.set(tmuxSession, workspaceSessionId);
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
- finally {
230
- setTimeout(() => { this.launchingConversations.delete(convKey); }, 10000);
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
- * 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).
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.config.claude_idle_timeout_ms || 30000;
296
- const maxNudges = 10; // Kill after 10 failed nudges (~5 min with no response)
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
- // 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
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
- // 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
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.lastClaudeSessionId && s.claudeResumeId) {
384
- this.lastClaudeSessionId = s.claudeResumeId;
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
- 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
- }
243
+ super.kill();
508
244
  if (this.childProcess) {
509
245
  this.childProcess.kill('SIGTERM');
510
246
  this.childProcess = null;
511
247
  }
512
248
  }
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
- }
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