orchestrix-yuri 2.7.0 → 3.0.1

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/bin/status.js CHANGED
@@ -4,12 +4,11 @@
4
4
  const fs = require('fs');
5
5
  const path = require('path');
6
6
  const os = require('os');
7
- const { execSync } = require('child_process');
8
7
 
9
8
  const PID_FILE = path.join(os.homedir(), '.yuri', 'gateway.pid');
9
+ const SESSION_FILE = path.join(os.homedir(), '.yuri', 'gateway-session.json');
10
10
 
11
11
  function status() {
12
- // Check PID
13
12
  let pid = null;
14
13
  let running = false;
15
14
 
@@ -23,32 +22,37 @@ function status() {
23
22
  }
24
23
  }
25
24
 
26
- // Check tmux session
27
- let tmuxAlive = false;
28
- try {
29
- execSync('tmux has-session -t yuri-gateway 2>/dev/null');
30
- tmuxAlive = true;
31
- } catch {
32
- tmuxAlive = false;
25
+ // Check session
26
+ let sessionInfo = 'none';
27
+ if (fs.existsSync(SESSION_FILE)) {
28
+ try {
29
+ const s = JSON.parse(fs.readFileSync(SESSION_FILE, 'utf8'));
30
+ const age = Date.now() - new Date(s.savedAt).getTime();
31
+ if (age < 24 * 3600_000) {
32
+ sessionInfo = `${s.sessionId.slice(0, 8)}... (${s.messageCount || 0} messages)`;
33
+ } else {
34
+ sessionInfo = 'expired';
35
+ }
36
+ } catch { /* ignore */ }
33
37
  }
34
38
 
35
- console.log('');
36
- console.log(' Yuri Gateway Status');
37
- console.log(' ───────────────────');
38
- console.log(` Gateway process: ${running ? `\x1b[32mrunning\x1b[0m (PID ${pid})` : '\x1b[90mnot running\x1b[0m'}`);
39
- console.log(` tmux session: ${tmuxAlive ? '\x1b[32myuri-gateway (active)\x1b[0m' : '\x1b[90mnone\x1b[0m'}`);
40
-
41
39
  // Check config
42
40
  const configPath = path.join(os.homedir(), '.yuri', 'config', 'channels.yaml');
41
+ let tokenStatus = '\x1b[90mnot set\x1b[0m';
43
42
  if (fs.existsSync(configPath)) {
44
43
  const content = fs.readFileSync(configPath, 'utf8');
45
- const hasToken = /token:\s*".+"/.test(content) || /token:\s*'.+'/.test(content);
46
- console.log(` Telegram token: ${hasToken ? '\x1b[32mconfigured\x1b[0m' : '\x1b[90mnot set\x1b[0m'}`);
47
- } else {
48
- console.log(' Config: \x1b[90mnot found\x1b[0m');
44
+ if (/token:\s*".+"/.test(content) || /token:\s*'.+'/.test(content)) {
45
+ tokenStatus = '\x1b[32mconfigured\x1b[0m';
46
+ }
49
47
  }
50
48
 
51
49
  console.log('');
50
+ console.log(' Yuri Gateway Status');
51
+ console.log(' ───────────────────');
52
+ console.log(` Gateway process: ${running ? `\x1b[32mrunning\x1b[0m (PID ${pid})` : '\x1b[90mnot running\x1b[0m'}`);
53
+ console.log(` Claude session: ${sessionInfo}`);
54
+ console.log(` Telegram token: ${tokenStatus}`);
55
+ console.log('');
52
56
  }
53
57
 
54
58
  status();
package/bin/stop.js CHANGED
@@ -4,8 +4,6 @@
4
4
  const fs = require('fs');
5
5
  const path = require('path');
6
6
  const os = require('os');
7
- const { execSync } = require('child_process');
8
-
9
7
  const PID_FILE = path.join(os.homedir(), '.yuri', 'gateway.pid');
10
8
 
11
9
  function stop() {
@@ -22,7 +20,6 @@ function stop() {
22
20
  } catch {
23
21
  console.log(` Gateway PID ${pid} is not running. Cleaning up stale PID file.`);
24
22
  fs.unlinkSync(PID_FILE);
25
- cleanupTmux();
26
23
  process.exit(0);
27
24
  }
28
25
 
@@ -45,18 +42,9 @@ function stop() {
45
42
  } catch {
46
43
  // Dead, good
47
44
  }
48
- cleanupTmux();
49
45
  try { fs.unlinkSync(PID_FILE); } catch {}
50
46
  console.log(' ✅ Gateway stopped.');
51
47
  }, 2000);
52
48
  }
53
49
 
54
- function cleanupTmux() {
55
- try {
56
- execSync('tmux kill-session -t yuri-gateway 2>/dev/null');
57
- } catch {
58
- // no session to kill
59
- }
60
- }
61
-
62
50
  stop();
@@ -19,13 +19,7 @@ const DEFAULTS = {
19
19
  },
20
20
  engine: {
21
21
  skill: 'yuri',
22
- tmux_session: 'yuri-gateway',
23
- startup_timeout: 60000, // ms to wait for Claude Code to initialize
24
- poll_interval: 2000, // ms between capture-pane polls
25
- stable_count: 3, // consecutive stable polls before declaring done
26
- max_retries: 3, // session restart retries before error
27
22
  timeout: 300000, // per-message timeout (5 min)
28
- history_limit: 10000, // tmux scrollback lines
29
23
  autocompact_pct: 80, // trigger auto-compact at this % (default 95%)
30
24
  compact_every: 50, // proactive /compact after N messages
31
25
  },
@@ -0,0 +1,359 @@
1
+ 'use strict';
2
+
3
+ const { execFile, execSync } = require('child_process');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const os = require('os');
7
+ const yaml = require('js-yaml');
8
+
9
+ const YURI_GLOBAL = path.join(os.homedir(), '.yuri');
10
+ const SESSION_FILE = path.join(YURI_GLOBAL, 'gateway-session.json');
11
+ const { log } = require('../log');
12
+
13
+ // ── Shared Utilities ───────────────────────────────────────────────────────────
14
+
15
+ function loadL1Context() {
16
+ const files = [
17
+ { label: 'Yuri Identity', path: path.join(YURI_GLOBAL, 'self.yaml') },
18
+ { label: 'Boss Profile', path: path.join(YURI_GLOBAL, 'boss', 'profile.yaml') },
19
+ { label: 'Boss Preferences', path: path.join(YURI_GLOBAL, 'boss', 'preferences.yaml') },
20
+ { label: 'Portfolio Registry', path: path.join(YURI_GLOBAL, 'portfolio', 'registry.yaml') },
21
+ { label: 'Global Focus', path: path.join(YURI_GLOBAL, 'focus.yaml') },
22
+ ];
23
+
24
+ const sections = [];
25
+ for (const f of files) {
26
+ if (fs.existsSync(f.path)) {
27
+ const content = fs.readFileSync(f.path, 'utf8').trim();
28
+ if (content) {
29
+ sections.push(`### ${f.label}\n\`\`\`yaml\n${content}\n\`\`\``);
30
+ }
31
+ }
32
+ }
33
+
34
+ return sections.length > 0
35
+ ? `## Yuri Global Memory (L1 — pre-loaded)\n\n${sections.join('\n\n')}`
36
+ : '';
37
+ }
38
+
39
+ function resolveProjectRoot() {
40
+ const registryPath = path.join(YURI_GLOBAL, 'portfolio', 'registry.yaml');
41
+ if (!fs.existsSync(registryPath)) return null;
42
+
43
+ const registry = yaml.load(fs.readFileSync(registryPath, 'utf8')) || {};
44
+ const projects = registry.projects || [];
45
+ const active = projects.filter((p) => p.status === 'active');
46
+
47
+ if (active.length === 0) return null;
48
+
49
+ const focusPath = path.join(YURI_GLOBAL, 'focus.yaml');
50
+ if (fs.existsSync(focusPath)) {
51
+ const focus = yaml.load(fs.readFileSync(focusPath, 'utf8')) || {};
52
+ if (focus.active_project) {
53
+ const match = active.find((p) => p.id === focus.active_project);
54
+ if (match && fs.existsSync(match.root)) return match.root;
55
+ }
56
+ }
57
+
58
+ if (active[0] && fs.existsSync(active[0].root)) return active[0].root;
59
+ return null;
60
+ }
61
+
62
+ function findClaudeBinary() {
63
+ try {
64
+ const resolved = execSync('zsh -lc "which claude" 2>/dev/null', { encoding: 'utf8' }).trim();
65
+ if (resolved && fs.existsSync(resolved)) return resolved;
66
+ } catch { /* fall through */ }
67
+
68
+ const candidates = [
69
+ '/usr/local/bin/claude',
70
+ '/opt/homebrew/bin/claude',
71
+ path.join(os.homedir(), '.npm-global', 'bin', 'claude'),
72
+ path.join(os.homedir(), '.local', 'bin', 'claude'),
73
+ path.join(os.homedir(), '.claude', 'bin', 'claude'),
74
+ ];
75
+
76
+ for (const c of candidates) {
77
+ if (fs.existsSync(c)) return c;
78
+ }
79
+ return 'claude';
80
+ }
81
+
82
+ let _claudeBinary = null;
83
+ function getClaudeBinary() {
84
+ if (!_claudeBinary) {
85
+ _claudeBinary = findClaudeBinary();
86
+ log.engine(`Using binary: ${_claudeBinary}`);
87
+ }
88
+ return _claudeBinary;
89
+ }
90
+
91
+ // ── State ──────────────────────────────────────────────────────────────────────
92
+
93
+ let _sessionId = null;
94
+ let _messageCount = 0;
95
+ let _messageQueue = Promise.resolve();
96
+
97
+ // ── System Prompt ──────────────────────────────────────────────────────────────
98
+
99
+ const SKILL_PATH = path.join(os.homedir(), '.claude', 'skills', 'yuri', 'SKILL.md');
100
+
101
+ const CHANNEL_MODE_INSTRUCTIONS = [
102
+ '## Channel Mode (Yuri Gateway)',
103
+ '',
104
+ 'You are responding via a messaging channel (Telegram/Feishu), not a terminal.',
105
+ '- Keep responses concise and mobile-friendly.',
106
+ '- Use markdown formatting sparingly (Telegram supports basic markdown).',
107
+ '- If you need to perform operations, do so and report the result.',
108
+ '- At the end of your response, if you observed any memory-worthy signals',
109
+ ' (user preferences, priority changes, tech lessons, corrections),',
110
+ ' write them to ~/.yuri/inbox.jsonl.',
111
+ '- Update ~/.yuri/focus.yaml and the project\'s focus.yaml after any operation.',
112
+ ].join('\n');
113
+
114
+ /**
115
+ * Build the system prompt that gives Claude the Yuri identity.
116
+ *
117
+ * Layers (in order):
118
+ * 1. SKILL.md — Yuri persona, commands, activation protocol, behavior rules
119
+ * 2. L1 context — global memory (self.yaml, boss profile, portfolio, focus)
120
+ * 3. Channel mode — Telegram-specific response formatting rules
121
+ *
122
+ * In terminal mode, SKILL.md is loaded automatically by Claude Code's /yuri skill.
123
+ * In gateway mode (-p), we must inject it manually via --system-prompt.
124
+ */
125
+ function buildSystemPrompt() {
126
+ const parts = [];
127
+
128
+ // Layer 1: Yuri skill definition (persona + commands + behavior)
129
+ if (fs.existsSync(SKILL_PATH)) {
130
+ let skill = fs.readFileSync(SKILL_PATH, 'utf8');
131
+ // Strip YAML frontmatter (---...---) — it's metadata for Claude Code, not prompt content
132
+ skill = skill.replace(/^---[\s\S]*?---\s*\n/, '');
133
+ parts.push(skill.trim());
134
+ } else {
135
+ // Fallback if skill not installed
136
+ parts.push(
137
+ '# Yuri — Meta-Orchestrator\n\n' +
138
+ 'You are **Yuri**, a Meta-Orchestrator and Technical Chief of Staff.\n' +
139
+ 'You manage the user\'s entire project portfolio via Orchestrix agents.\n' +
140
+ 'NEVER say you are Claude or Claude Code. You are Yuri.'
141
+ );
142
+ }
143
+
144
+ // Layer 2: L1 global memory
145
+ const l1 = loadL1Context();
146
+ if (l1) parts.push(l1);
147
+
148
+ // Layer 3: Channel mode instructions
149
+ parts.push(CHANNEL_MODE_INSTRUCTIONS);
150
+
151
+ return parts.join('\n\n---\n\n');
152
+ }
153
+
154
+ // ── Session Persistence ────────────────────────────────────────────────────────
155
+
156
+ // Session format version — increment when system prompt changes significantly
157
+ // to force fresh session creation instead of resuming with stale identity.
158
+ const SESSION_VERSION = 2;
159
+
160
+ function saveSessionState() {
161
+ try {
162
+ const dir = path.dirname(SESSION_FILE);
163
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
164
+ fs.writeFileSync(SESSION_FILE, JSON.stringify({
165
+ version: SESSION_VERSION,
166
+ sessionId: _sessionId,
167
+ messageCount: _messageCount,
168
+ savedAt: new Date().toISOString(),
169
+ }));
170
+ } catch { /* best effort */ }
171
+ }
172
+
173
+ function loadSessionState() {
174
+ if (!fs.existsSync(SESSION_FILE)) return null;
175
+ try {
176
+ const state = JSON.parse(fs.readFileSync(SESSION_FILE, 'utf8'));
177
+ // Reject: wrong version (system prompt changed), or expired (>24h)
178
+ if (state.version !== SESSION_VERSION) return null;
179
+ const age = Date.now() - new Date(state.savedAt).getTime();
180
+ if (age > 24 * 3600_000) return null;
181
+ return state;
182
+ } catch { return null; }
183
+ }
184
+
185
+ function clearSessionState() {
186
+ _sessionId = null;
187
+ _messageCount = 0;
188
+ try { fs.unlinkSync(SESSION_FILE); } catch { /* ok */ }
189
+ }
190
+
191
+ // ── Core: Run Claude CLI ───────────────────────────────────────────────────────
192
+
193
+ /**
194
+ * Execute `claude -p --output-format json` and return parsed result.
195
+ */
196
+ function runClaude(args, cwd, timeout) {
197
+ return new Promise((resolve) => {
198
+ const binary = getClaudeBinary();
199
+
200
+ log.engine(`Calling: claude ${args.slice(0, 4).join(' ')}... (cwd: ${cwd})`);
201
+
202
+ const proc = execFile(binary, args, {
203
+ cwd,
204
+ timeout: timeout || 300000,
205
+ maxBuffer: 10 * 1024 * 1024, // 10MB
206
+ encoding: 'utf8',
207
+ env: {
208
+ ...process.env,
209
+ CLAUDE_AUTOCOMPACT_PCT_OVERRIDE: '80',
210
+ },
211
+ }, (err, stdout, stderr) => {
212
+ if (err && err.killed) {
213
+ log.warn('Claude CLI timed out');
214
+ return resolve({ reply: '⏱ Response timed out.', raw: '' });
215
+ }
216
+ if (err) {
217
+ log.error(`Claude CLI error: ${err.message}`);
218
+ if (stderr) log.info(`stderr: ${stderr.slice(0, 200)}`);
219
+ return resolve({ reply: `❌ Claude CLI error: ${err.message}`, raw: stderr });
220
+ }
221
+
222
+ try {
223
+ const result = JSON.parse(stdout);
224
+ if (result.is_error) {
225
+ return resolve({ reply: `❌ ${result.result || 'Unknown error'}`, raw: stdout });
226
+ }
227
+
228
+ const duration = result.duration_ms ? `${(result.duration_ms / 1000).toFixed(1)}s` : '?';
229
+ const cost = result.total_cost_usd ? `$${result.total_cost_usd.toFixed(4)}` : '';
230
+ log.engine(`Done in ${duration} ${cost}`);
231
+
232
+ resolve({
233
+ reply: result.result || '(empty response)',
234
+ raw: stdout,
235
+ sessionId: result.session_id,
236
+ cost: result.total_cost_usd,
237
+ duration: result.duration_ms,
238
+ });
239
+ } catch (parseErr) {
240
+ // Non-JSON output — return raw
241
+ log.warn(`Failed to parse JSON response: ${parseErr.message}`);
242
+ resolve({ reply: stdout.trim() || '(empty response)', raw: stdout });
243
+ }
244
+ });
245
+ });
246
+ }
247
+
248
+ // ── Public API ─────────────────────────────────────────────────────────────────
249
+
250
+ /**
251
+ * Send a message to Claude Code via `claude -p --output-format json`.
252
+ * First call creates a session with --system-prompt.
253
+ * Subsequent calls use --resume SESSION_ID for context continuity.
254
+ */
255
+ async function callClaude(opts) {
256
+ const { prompt, engineConfig, timeout } = opts;
257
+ const cwd = resolveProjectRoot() || os.homedir();
258
+ const callTimeout = timeout || engineConfig.timeout || 300000;
259
+
260
+ return new Promise((resolve, reject) => {
261
+ _messageQueue = _messageQueue.then(async () => {
262
+ try {
263
+ // Try to restore session from disk on first call
264
+ if (!_sessionId) {
265
+ const saved = loadSessionState();
266
+ if (saved) {
267
+ log.engine(`Restoring session ${saved.sessionId.slice(0, 8)}...`);
268
+ _sessionId = saved.sessionId;
269
+ _messageCount = saved.messageCount || 0;
270
+ }
271
+ }
272
+
273
+ // Proactive compact
274
+ const compactEvery = (engineConfig && engineConfig.compact_every) || 50;
275
+ if (_messageCount > 0 && _messageCount % compactEvery === 0 && _sessionId) {
276
+ log.engine('Proactive /compact...');
277
+ await runClaude([
278
+ '-p', '--output-format', 'json',
279
+ '--dangerously-skip-permissions',
280
+ '--resume', _sessionId,
281
+ '/compact focus on the most recent user conversation',
282
+ ], cwd, 120000);
283
+ log.engine('/compact done');
284
+ }
285
+
286
+ // Build args
287
+ const args = ['-p', '--output-format', 'json', '--dangerously-skip-permissions'];
288
+
289
+ if (_sessionId) {
290
+ args.push('--resume', _sessionId);
291
+ } else {
292
+ args.push('--system-prompt', buildSystemPrompt());
293
+ }
294
+
295
+ args.push(prompt);
296
+
297
+ // Execute
298
+ const result = await runClaude(args, cwd, callTimeout);
299
+
300
+ // Store session ID for subsequent calls
301
+ if (result.sessionId) {
302
+ _sessionId = result.sessionId;
303
+ }
304
+
305
+ _messageCount++;
306
+ saveSessionState();
307
+
308
+ // If --resume failed (session expired), retry with fresh session
309
+ if (result.reply && result.reply.includes('❌') && _sessionId) {
310
+ log.warn('Session may have expired, retrying with fresh session...');
311
+ clearSessionState();
312
+
313
+ const freshArgs = [
314
+ '-p', '--output-format', 'json', '--dangerously-skip-permissions',
315
+ '--system-prompt', buildSystemPrompt(),
316
+ prompt,
317
+ ];
318
+ const freshResult = await runClaude(freshArgs, cwd, callTimeout);
319
+ if (freshResult.sessionId) {
320
+ _sessionId = freshResult.sessionId;
321
+ _messageCount = 1;
322
+ saveSessionState();
323
+ }
324
+ return resolve(freshResult);
325
+ }
326
+
327
+ resolve(result);
328
+ } catch (err) {
329
+ log.error(`callClaude error: ${err.message}`);
330
+ resolve({ reply: `❌ Engine error: ${err.message}`, raw: '' });
331
+ }
332
+ }).catch(reject);
333
+ });
334
+ }
335
+
336
+ /**
337
+ * Compose prompt — just returns the raw user message.
338
+ * L1 context and channel instructions are handled by --system-prompt.
339
+ */
340
+ function composePrompt(userMessage, _chatHistory) {
341
+ return userMessage;
342
+ }
343
+
344
+ /**
345
+ * Destroy session state. Called on gateway shutdown.
346
+ */
347
+ function destroySession() {
348
+ // Don't clear session file — allow restart to resume
349
+ log.engine('Gateway shutting down (session preserved for restart)');
350
+ }
351
+
352
+ module.exports = {
353
+ callClaude,
354
+ composePrompt,
355
+ loadL1Context,
356
+ resolveProjectRoot,
357
+ findClaudeBinary,
358
+ destroySession,
359
+ };
@@ -26,7 +26,7 @@ const log = {
26
26
  // Tagged module loggers
27
27
  gateway: (msg) => console.log(`${tag(c.magenta, 'gateway')} ${msg}`),
28
28
  router: (msg) => console.log(`${tag(c.blue, 'router')} ${msg}`),
29
- tmux: (msg) => console.log(`${tag(c.cyan, 'tmux')} ${msg}`),
29
+ engine: (msg) => console.log(`${tag(c.cyan, 'engine')} ${msg}`),
30
30
  telegram:(msg) => console.log(`${tag(c.green, 'telegram')} ${msg}`),
31
31
 
32
32
  // Levels
@@ -7,7 +7,7 @@ const yaml = require('js-yaml');
7
7
 
8
8
  const { ChatHistory } = require('./history');
9
9
  const { OwnerBinding } = require('./binding');
10
- const engine = require('./engine/claude-tmux');
10
+ const engine = require('./engine/claude-sdk');
11
11
  const { log } = require('./log');
12
12
 
13
13
  const YURI_GLOBAL = path.join(os.homedir(), '.yuri');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orchestrix-yuri",
3
- "version": "2.7.0",
3
+ "version": "3.0.1",
4
4
  "description": "Yuri — Meta-Orchestrator for Orchestrix. Drive your entire project lifecycle with natural language.",
5
5
  "main": "lib/installer.js",
6
6
  "bin": {
@@ -1,684 +0,0 @@
1
- 'use strict';
2
-
3
- const { execSync } = require('child_process');
4
- const fs = require('fs');
5
- const path = require('path');
6
- const os = require('os');
7
- const crypto = require('crypto');
8
- const yaml = require('js-yaml');
9
-
10
- const YURI_GLOBAL = path.join(os.homedir(), '.yuri');
11
- const { log } = require('../log');
12
-
13
- // ── Shared Utilities (formerly in claude-cli.js) ───────────────────────────────
14
-
15
- /**
16
- * Load L1 (global context) files and compose them into a context block.
17
- * Injected into the prompt so Claude does not need to "remember" to read them.
18
- */
19
- function loadL1Context() {
20
- const files = [
21
- { label: 'Yuri Identity', path: path.join(YURI_GLOBAL, 'self.yaml') },
22
- { label: 'Boss Profile', path: path.join(YURI_GLOBAL, 'boss', 'profile.yaml') },
23
- { label: 'Boss Preferences', path: path.join(YURI_GLOBAL, 'boss', 'preferences.yaml') },
24
- { label: 'Portfolio Registry', path: path.join(YURI_GLOBAL, 'portfolio', 'registry.yaml') },
25
- { label: 'Global Focus', path: path.join(YURI_GLOBAL, 'focus.yaml') },
26
- ];
27
-
28
- const sections = [];
29
- for (const f of files) {
30
- if (fs.existsSync(f.path)) {
31
- const content = fs.readFileSync(f.path, 'utf8').trim();
32
- if (content) {
33
- sections.push(`### ${f.label}\n\`\`\`yaml\n${content}\n\`\`\``);
34
- }
35
- }
36
- }
37
-
38
- return sections.length > 0
39
- ? `## Yuri Global Memory (L1 — pre-loaded)\n\n${sections.join('\n\n')}`
40
- : '';
41
- }
42
-
43
- /**
44
- * Determine which project the message likely relates to, based on portfolio.
45
- */
46
- function resolveProjectRoot() {
47
- const registryPath = path.join(YURI_GLOBAL, 'portfolio', 'registry.yaml');
48
- if (!fs.existsSync(registryPath)) return null;
49
-
50
- const registry = yaml.load(fs.readFileSync(registryPath, 'utf8')) || {};
51
- const projects = registry.projects || [];
52
- const active = projects.filter((p) => p.status === 'active');
53
-
54
- if (active.length === 0) return null;
55
-
56
- // Check global focus for active project
57
- const focusPath = path.join(YURI_GLOBAL, 'focus.yaml');
58
- if (fs.existsSync(focusPath)) {
59
- const focus = yaml.load(fs.readFileSync(focusPath, 'utf8')) || {};
60
- if (focus.active_project) {
61
- const match = active.find((p) => p.id === focus.active_project);
62
- if (match && fs.existsSync(match.root)) return match.root;
63
- }
64
- }
65
-
66
- // Fallback: first active project
67
- if (active[0] && fs.existsSync(active[0].root)) return active[0].root;
68
-
69
- return null;
70
- }
71
-
72
- /**
73
- * Find the claude binary path.
74
- * Shell aliases (like `cc`) are not available in child_process, so we
75
- * resolve the actual binary via the user's login shell PATH.
76
- */
77
- function findClaudeBinary() {
78
- // Primary: resolve via user's login shell (handles all install methods)
79
- try {
80
- const resolved = execSync('zsh -lc "which claude" 2>/dev/null', { encoding: 'utf8' }).trim();
81
- if (resolved && fs.existsSync(resolved)) {
82
- return resolved;
83
- }
84
- } catch {
85
- // fall through
86
- }
87
-
88
- // Fallback: check common install locations
89
- const candidates = [
90
- '/usr/local/bin/claude',
91
- '/opt/homebrew/bin/claude',
92
- path.join(os.homedir(), '.npm-global', 'bin', 'claude'),
93
- path.join(os.homedir(), '.local', 'bin', 'claude'),
94
- path.join(os.homedir(), '.claude', 'bin', 'claude'),
95
- ];
96
-
97
- for (const candidate of candidates) {
98
- if (fs.existsSync(candidate)) {
99
- return candidate;
100
- }
101
- }
102
-
103
- // Last resort: let the shell find it
104
- return 'claude';
105
- }
106
-
107
- // Cache the binary path
108
- let _claudeBinary = null;
109
- function getClaudeBinary() {
110
- if (!_claudeBinary) {
111
- _claudeBinary = findClaudeBinary();
112
- log.tmux(`Using binary: ${_claudeBinary}`);
113
- }
114
- return _claudeBinary;
115
- }
116
-
117
- // ── Session Configuration ──────────────────────────────────────────────────────
118
-
119
- const DEFAULT_SESSION = 'yuri-gateway';
120
- const HISTORY_LIMIT = 10000;
121
-
122
- // ── Singleton State ────────────────────────────────────────────────────────────
123
-
124
- let _sessionName = null;
125
- let _sessionReady = false;
126
- let _initPromise = null;
127
- let _messageQueue = Promise.resolve();
128
- let _messageCount = 0; // messages since last compact/session start
129
-
130
- // ── Utilities ──────────────────────────────────────────────────────────────────
131
-
132
- function tmux(cmd) {
133
- return execSync(`tmux ${cmd}`, { encoding: 'utf8', timeout: 10000 }).trim();
134
- }
135
-
136
- function tmuxSafe(cmd) {
137
- try {
138
- return tmux(cmd);
139
- } catch {
140
- return null;
141
- }
142
- }
143
-
144
- // ── Claude Code TUI Indicators ─────────────────────────────────────────────────
145
- //
146
- // Claude Code TUI state indicators vary by version and statusline config:
147
- //
148
- // Idle indicators (any of these = ready for input):
149
- // ○ (U+25CB) — circle idle indicator (shown with certain statusline configs)
150
- // ❯ — prompt cursor (always shown when idle, most reliable)
151
- //
152
- // Processing indicators:
153
- // ● (U+25CF) — filled circle, active generation
154
- // Braille spinner: ⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏
155
- //
156
- // Status line elements (NOT state indicators):
157
- // ◐ — effort level indicator (e.g. "◐ medium · /effort"), NOT approval prompt
158
- //
159
- // Completion message (past-tense verb + duration):
160
- // "Baked for 31s", "Worked for 2m 45s"
161
- // Pattern: /[A-Z][a-z]*ed for \d+/
162
- //
163
- // ────────────────────────────────────────────────────────────────────────────────
164
-
165
- const BRAILLE_SPINNER = /[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/;
166
- const COMPLETION_RE = /[A-Z][a-z]*ed for \d+/;
167
- const IDLE_RE = /[○❯]/;
168
- const PROCESSING_RE = /●/;
169
-
170
- /**
171
- * Strip TUI chrome from captured pane output.
172
- * `tmux capture-pane -p` (without -e) already strips most ANSI codes,
173
- * but we clean up residual artifacts and Claude Code UI elements.
174
- */
175
- function stripChrome(raw) {
176
- return raw
177
- // ANSI escape sequences
178
- .replace(/\x1B\[[0-9;]*[a-zA-Z]/g, '')
179
- .replace(/\x1B\].*?\x07/g, '')
180
- // TUI indicators and decorations
181
- .replace(/[○●◐◑⏺]/g, '')
182
- .replace(/[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/g, '')
183
- .replace(/[⏵━─█·…→]/g, '')
184
- // Claude Code banner
185
- .replace(/^.*▐▛.*$/gm, '')
186
- .replace(/^.*▝▜.*$/gm, '')
187
- .replace(/^.*▘▘.*$/gm, '')
188
- .replace(/^.*Claude Code v[\d.]+.*$/gm, '')
189
- .replace(/^.*Opus.*context.*$/gm, '')
190
- // Status line elements
191
- .replace(/^.*bypass permissions.*$/gm, '')
192
- .replace(/^.*shift\+tab to cycle.*$/gm, '')
193
- .replace(/^.*◐\s*(min|medium|max|low|high).*$/gm, '')
194
- .replace(/^.*\/effort.*$/gm, '')
195
- .replace(/^.*Proxy\s*-\s*(On|Off).*$/gm, '')
196
- // Shell commands that leaked
197
- .replace(/^.*export\s+CLAUDE_AUTOCOMPACT.*$/gm, '')
198
- .replace(/^.*dangerously-skip-permissions.*$/gm, '')
199
- // Completion stats and spinner verbs
200
- .replace(/^.*[A-Z][a-z]*ed for \d+.*$/gm, '')
201
- .replace(/^.*[A-Z][a-z]*ing\.{3}.*$/gm, '')
202
- // Prompt cursor and line decorations
203
- .replace(/^❯\s*$/gm, '')
204
- .replace(/^─+$/gm, '')
205
- // Line-number gutter
206
- .replace(/^\s*\d+\s*[│|]\s*/gm, '')
207
- // Collapse blank lines
208
- .replace(/^\s*$/gm, '')
209
- .replace(/\n{3,}/g, '\n\n')
210
- .trim();
211
- }
212
-
213
- // ── Session Lifecycle ──────────────────────────────────────────────────────────
214
-
215
- function hasSession(name) {
216
- return tmuxSafe(`has-session -t ${name} 2>/dev/null`) !== null;
217
- }
218
-
219
- function capturePaneRaw(name, lines) {
220
- return tmuxSafe(`capture-pane -t ${name}:0 -p -S -${lines || 500}`) || '';
221
- }
222
-
223
- /**
224
- * Get the last N lines of the pane output for state detection.
225
- */
226
- function paneTail(name, n) {
227
- return capturePaneRaw(name, n || 10);
228
- }
229
-
230
- /**
231
- * Check if Claude Code has started up and is ready for input.
232
- * Used ONLY during session initialization — looks for the ❯ input prompt
233
- * which appears once Claude Code has fully loaded.
234
- *
235
- * DO NOT use this for response completion detection — ❯ is always visible.
236
- */
237
- function isStarted(name) {
238
- const tail = paneTail(name, 15);
239
- return IDLE_RE.test(tail);
240
- }
241
-
242
- /**
243
- * Check if a completion message is present in the pane output.
244
- * e.g. "Baked for 31s", "Worked for 2m 45s"
245
- * This is the most reliable signal that Claude has finished responding.
246
- */
247
- function hasCompletionMessage(text) {
248
- return COMPLETION_RE.test(text);
249
- }
250
-
251
- /**
252
- * Check if Claude Code is actively processing (● spinner visible).
253
- */
254
- function isProcessing(text) {
255
- return PROCESSING_RE.test(text) || BRAILLE_SPINNER.test(text);
256
- }
257
-
258
- // ── Context Management ─────────────────────────────────────────────────────────
259
- //
260
- // Claude Code has built-in auto-compact that triggers at ~95% context capacity.
261
- // We improve on this with a 3-layer strategy:
262
- //
263
- // Layer 1: CLAUDE.md persistence
264
- // Channel Mode Instructions are written to the project's CLAUDE.md.
265
- // CLAUDE.md survives compaction — it's re-read from disk after compact.
266
- // This means our core instructions are never lost.
267
- //
268
- // Layer 2: CLAUDE_AUTOCOMPACT_PCT_OVERRIDE=80
269
- // Set at session launch to trigger auto-compact at 80% instead of 95%.
270
- // This gives a comfortable buffer before context pressure causes issues.
271
- //
272
- // Layer 3: Proactive /compact
273
- // After every N messages (configurable, default 50), we proactively
274
- // send /compact to keep the context lean. This prevents gradual
275
- // degradation in response quality from context bloat.
276
- //
277
- // Session rebuild is only used as a last resort when the session crashes.
278
- // ────────────────────────────────────────────────────────────────────────────────
279
-
280
- const CHANNEL_MODE_INSTRUCTIONS = [
281
- '## Channel Mode (Yuri Gateway)',
282
- '',
283
- 'You are responding via a messaging channel (Telegram/Feishu), not a terminal.',
284
- '- Keep responses concise and mobile-friendly.',
285
- '- Use markdown formatting sparingly (Telegram supports basic markdown).',
286
- '- If you need to perform operations, do so and report the result.',
287
- '- At the end of your response, if you observed any memory-worthy signals',
288
- ' (user preferences, priority changes, tech lessons, corrections),',
289
- ' write them to ~/.yuri/inbox.jsonl.',
290
- '- Update ~/.yuri/focus.yaml and the project\'s focus.yaml after any operation.',
291
- ].join('\n');
292
-
293
- /**
294
- * Ensure Channel Mode Instructions exist in the project's CLAUDE.md.
295
- * This guarantees instructions survive auto-compact (CLAUDE.md is re-read from disk).
296
- */
297
- function ensureClaudeMd(projectRoot) {
298
- if (!projectRoot) return;
299
-
300
- const claudeMdPath = path.join(projectRoot, 'CLAUDE.md');
301
- const marker = '## Channel Mode (Yuri Gateway)';
302
-
303
- let content = '';
304
- if (fs.existsSync(claudeMdPath)) {
305
- content = fs.readFileSync(claudeMdPath, 'utf8');
306
- if (content.includes(marker)) return; // already present
307
- }
308
-
309
- // Append channel mode instructions
310
- const separator = content.trim() ? '\n\n' : '';
311
- fs.writeFileSync(claudeMdPath, content + separator + CHANNEL_MODE_INSTRUCTIONS + '\n');
312
- log.tmux(`Channel Mode Instructions written to ${claudeMdPath}`);
313
- }
314
-
315
- /**
316
- * Send /compact to Claude Code to proactively free context space.
317
- * Returns true if compact completed successfully.
318
- */
319
- async function proactiveCompact(name) {
320
- log.tmux('Proactive /compact triggered');
321
- injectMessage(name, '/compact focus on the most recent user conversation and any pending operations');
322
-
323
- const ok = await waitForReady(name, 120000); // compact can take up to 2min
324
- if (ok) {
325
- _messageCount = 0;
326
- log.tmux('Proactive /compact completed');
327
- } else {
328
- log.warn('Proactive /compact timed out');
329
- }
330
- return ok;
331
- }
332
-
333
- /**
334
- * Create a new tmux session and start Claude Code inside it.
335
- */
336
- async function createSession(engineConfig) {
337
- const sessionName = engineConfig.tmux_session || DEFAULT_SESSION;
338
- _sessionName = sessionName;
339
- _sessionReady = false;
340
- _messageCount = 0;
341
-
342
- const binary = getClaudeBinary();
343
- const projectRoot = resolveProjectRoot() || os.homedir();
344
- log.tmux(`Binary: ${binary}`);
345
- log.tmux(`Project root: ${projectRoot}`);
346
-
347
- // Ensure CLAUDE.md has channel mode instructions (survives compact)
348
- ensureClaudeMd(projectRoot);
349
-
350
- // Kill existing broken session (ensureSession already checked if it was healthy)
351
- if (hasSession(sessionName)) {
352
- log.tmux(`Replacing unhealthy session "${sessionName}"`);
353
- tmuxSafe(`kill-session -t ${sessionName}`);
354
- }
355
-
356
- // Create session with generous scrollback
357
- tmux(`new-session -d -s ${sessionName} -n claude -c "${projectRoot}"`);
358
- tmux(`set-option -t ${sessionName} history-limit ${HISTORY_LIMIT}`);
359
- log.tmux(`Session "${sessionName}" created, launching Claude Code...`);
360
-
361
- // Set env var and launch Claude Code in a single command to keep pane clean.
362
- // Using && chains avoids separate shell prompt lines polluting capture output.
363
- const compactPct = engineConfig.autocompact_pct || 80;
364
- tmux(`send-keys -t ${sessionName}:0 'export CLAUDE_AUTOCOMPACT_PCT_OVERRIDE=${compactPct} && "${binary}" --dangerously-skip-permissions' Enter`);
365
-
366
- // Wait for Claude Code to initialize (detect ❯ prompt)
367
- const startupTimeout = engineConfig.startup_timeout || 60000;
368
- log.tmux(`Waiting for Claude Code to start (timeout: ${startupTimeout / 1000}s)...`);
369
- const started = await waitForReady(sessionName, startupTimeout);
370
- if (!started) {
371
- const tail = paneTail(sessionName, 10);
372
- log.error(`Claude Code did not start within ${startupTimeout / 1000}s`);
373
- log.error(`Last pane output:\n${tail}`);
374
- log.info(`Debug: tmux attach -t ${sessionName}`);
375
- throw new Error(`Claude Code did not start within ${startupTimeout / 1000}s`);
376
- }
377
-
378
- // Clear tmux scrollback so session setup commands don't pollute response capture.
379
- // The pane currently contains: shell commands, banner, status line.
380
- // We want captureResponse to only see content from user messages onward.
381
- await new Promise((r) => setTimeout(r, 1000)); // let TUI fully render
382
- tmuxSafe(`clear-history -t ${sessionName}:0`);
383
-
384
- // NOTE: We do NOT inject L1 context here. Instead, composePrompt() prepends
385
- // L1 to the first user message. This avoids: (1) scrollback pollution from
386
- // a huge YAML block, (2) waitForReady returning immediately because ❯ is
387
- // always visible, (3) race conditions between L1 processing and first message.
388
-
389
- _sessionReady = true;
390
- log.tmux(`Session "${sessionName}" ready (cwd: ${projectRoot})`);
391
- }
392
-
393
- /**
394
- * Wait for Claude Code to be ready (❯ prompt visible).
395
- * Used for session init and after /compact — NOT for response capture.
396
- *
397
- * @returns {Promise<boolean>} true if ready detected, false if timeout
398
- */
399
- function waitForReady(name, timeoutMs) {
400
- const pollInterval = 2000;
401
- return new Promise((resolve) => {
402
- const deadline = Date.now() + timeoutMs;
403
- const poll = () => {
404
- if (Date.now() > deadline) {
405
- return resolve(false);
406
- }
407
- if (!hasSession(name)) {
408
- return resolve(false);
409
- }
410
-
411
- if (isStarted(name)) {
412
- return resolve(true);
413
- }
414
- setTimeout(poll, pollInterval);
415
- };
416
- setTimeout(poll, pollInterval); // initial delay
417
- });
418
- }
419
-
420
- /**
421
- * Inject a message into the tmux pane via load-buffer (avoids shell escaping issues).
422
- */
423
- function injectMessage(name, text) {
424
- const tmpFile = path.join(os.tmpdir(), `yuri-tmux-msg-${Date.now()}.txt`);
425
- fs.writeFileSync(tmpFile, text);
426
-
427
- try {
428
- tmux(`load-buffer -b yuri-input "${tmpFile}"`);
429
- tmux(`paste-buffer -b yuri-input -t ${name}:0`);
430
- tmux(`send-keys -t ${name}:0 Enter`);
431
- } finally {
432
- try { fs.unlinkSync(tmpFile); } catch {}
433
- }
434
- }
435
-
436
- /**
437
- * Capture the response after injecting a message.
438
- *
439
- * Detection strategy (❯ prompt is always visible, so we CANNOT use idle detection):
440
- * P1: Completion message — "[Verb]ed for [N]s/m" (e.g. "Baked for 31s")
441
- * Most reliable signal. Appears exactly once when Claude finishes.
442
- * P2: Content stability — pane output unchanged for N consecutive polls.
443
- * Fallback for edge cases where completion message is missed.
444
- *
445
- * We also track whether content has changed since injection (via marker)
446
- * to avoid returning before Claude has even started responding.
447
- */
448
- async function captureResponse(name, engineConfig) {
449
- const timeout = engineConfig.timeout || 300000;
450
- const pollInterval = engineConfig.poll_interval || 2000;
451
- const stableThreshold = engineConfig.stable_count || 3;
452
-
453
- const deadline = Date.now() + timeout;
454
- let lastHash = '';
455
- let stableCount = 0;
456
- let contentChanged = false;
457
-
458
- // Capture baseline right after injection
459
- const baselineRaw = capturePaneRaw(name, 500);
460
- const baselineHash = crypto.createHash('md5').update(baselineRaw).digest('hex');
461
- lastHash = baselineHash;
462
-
463
- return new Promise((resolve) => {
464
- const poll = () => {
465
- // Timeout: return whatever we have
466
- if (Date.now() > deadline) {
467
- log.warn('Response capture timed out');
468
- const raw = capturePaneRaw(name, 500);
469
- return resolve(extractResponse(raw, baselineRaw));
470
- }
471
-
472
- // Session died
473
- if (!hasSession(name)) {
474
- return resolve({ reply: '❌ Claude Code session terminated unexpectedly.', raw: '' });
475
- }
476
-
477
- const raw = capturePaneRaw(name, 500);
478
- const hash = crypto.createHash('md5').update(raw).digest('hex');
479
-
480
- // Track if content has changed since injection
481
- if (hash !== baselineHash) {
482
- contentChanged = true;
483
- }
484
-
485
- // P1: Completion message — most reliable done signal
486
- if (contentChanged && hasCompletionMessage(paneTail(name, 15))) {
487
- return resolve(extractResponse(raw, baselineRaw));
488
- }
489
-
490
- // P2: Content stability — pane unchanged for N polls
491
- if (hash === lastHash) {
492
- stableCount++;
493
- } else {
494
- stableCount = 0;
495
- lastHash = hash;
496
- }
497
-
498
- if (contentChanged && stableCount >= stableThreshold) {
499
- log.tmux('Response detected via content stability');
500
- return resolve(extractResponse(raw, baselineRaw));
501
- }
502
-
503
- setTimeout(poll, pollInterval);
504
- };
505
-
506
- // Initial delay: give Claude time to start processing
507
- setTimeout(poll, Math.max(pollInterval, 3000));
508
- });
509
- }
510
-
511
- /**
512
- * Extract the assistant's response by diffing current pane against baseline.
513
- *
514
- * Strategy: the baseline was captured right after message injection (before
515
- * Claude started responding). The current capture has Claude's response + TUI
516
- * chrome. We diff the two to find only the new content.
517
- *
518
- * @param {string} currentRaw - Current pane capture
519
- * @param {string} baselineRaw - Pane capture from right after injection
520
- * @returns {{reply: string, raw: string}}
521
- */
522
- function extractResponse(currentRaw, baselineRaw) {
523
- const currentLines = currentRaw.split('\n');
524
- const baselineLines = new Set(baselineRaw.split('\n'));
525
-
526
- // Find lines that are new (not in baseline)
527
- const newLines = currentLines.filter((line) => !baselineLines.has(line));
528
- let responseText = stripChrome(newLines.join('\n'));
529
-
530
- // Remove trailing indicators and collapse blank lines
531
- responseText = responseText
532
- .replace(/[○●◐◑]\s*$/g, '')
533
- .replace(/\n{3,}/g, '\n\n')
534
- .trim();
535
-
536
- if (!responseText) {
537
- log.warn('No new content found after diffing pane output');
538
- // Fallback: strip the whole current output
539
- responseText = stripChrome(currentLines.slice(-30).join('\n')).trim();
540
- }
541
-
542
- return { reply: responseText || '(no response captured)', raw: currentRaw };
543
- }
544
-
545
- // ── Public API ─────────────────────────────────────────────────────────────────
546
-
547
- /**
548
- * Ensure the tmux session is alive and ready.
549
- * - If session already exists with Claude Code running → reuse it
550
- * - If session doesn't exist → create fresh
551
- * - If session exists but Claude Code crashed → recreate
552
- */
553
- async function ensureSession(engineConfig) {
554
- const sessionName = engineConfig.tmux_session || DEFAULT_SESSION;
555
-
556
- // Fast path: session alive and marked ready in this process
557
- if (_sessionName === sessionName && hasSession(sessionName) && _sessionReady) {
558
- return;
559
- }
560
-
561
- // Check if session exists from a previous gateway run
562
- if (hasSession(sessionName) && isStarted(sessionName)) {
563
- log.tmux(`Reusing existing session "${sessionName}" (Claude Code is running)`);
564
- _sessionName = sessionName;
565
- _sessionReady = true;
566
- return;
567
- }
568
-
569
- // Prevent concurrent initialization
570
- if (_initPromise) {
571
- return _initPromise;
572
- }
573
-
574
- const maxRetries = engineConfig.max_retries || 3;
575
- _initPromise = (async () => {
576
- for (let attempt = 1; attempt <= maxRetries; attempt++) {
577
- try {
578
- await createSession(engineConfig);
579
- return;
580
- } catch (err) {
581
- log.warn(`Session init attempt ${attempt}/${maxRetries} failed: ${err.message}`);
582
- if (attempt === maxRetries) {
583
- log.error('All init attempts failed. Check Claude Code installation and tmux.');
584
- log.info(`Debug: tmux attach -t ${sessionName}`);
585
- throw err;
586
- }
587
- if (hasSession(sessionName)) tmuxSafe(`kill-session -t ${sessionName}`);
588
- await new Promise((r) => setTimeout(r, 3000));
589
- }
590
- }
591
- })();
592
-
593
- try {
594
- await _initPromise;
595
- } finally {
596
- _initPromise = null;
597
- }
598
- }
599
-
600
- /**
601
- * Send a message to Claude Code via the persistent tmux session.
602
- *
603
- * @param {object} opts
604
- * @param {string} opts.prompt - User message to send
605
- * @param {string} opts.cwd - Working directory (used for session init, not per-message)
606
- * @param {object} opts.engineConfig - Engine configuration
607
- * @param {number} [opts.timeout=300000] - Timeout in ms
608
- * @returns {Promise<{reply: string, raw: string}>}
609
- */
610
- async function callClaude(opts) {
611
- const { prompt, engineConfig, timeout } = opts;
612
- const config = { ...engineConfig, timeout: timeout || engineConfig.timeout || 300000 };
613
-
614
- // Queue messages to prevent concurrent injection into the same pane
615
- return new Promise((resolve, reject) => {
616
- _messageQueue = _messageQueue.then(async () => {
617
- try {
618
- await ensureSession(config);
619
-
620
- // Layer 3: Proactive compact after N messages
621
- const compactEvery = config.compact_every || 50;
622
- if (_messageCount >= compactEvery) {
623
- await proactiveCompact(_sessionName);
624
- }
625
-
626
- // Inject user message (no marker — extraction uses completion message position)
627
- injectMessage(_sessionName, prompt);
628
- const result = await captureResponse(_sessionName, config);
629
-
630
- _messageCount++;
631
- resolve(result);
632
- } catch (err) {
633
- log.error(`callClaude error: ${err.message}`);
634
-
635
- // Mark session as not ready so it gets recreated next time
636
- _sessionReady = false;
637
- resolve({ reply: `❌ tmux engine error: ${err.message}`, raw: '' });
638
- }
639
- }).catch(reject);
640
- });
641
- }
642
-
643
- /**
644
- * Compose prompt for the persistent session.
645
- * First message includes L1 context to prime the session.
646
- * Subsequent messages send only the raw user text.
647
- *
648
- * @param {string} userMessage - The user's message text
649
- * @param {Array} _chatHistory - Unused (Claude keeps its own context)
650
- * @returns {string}
651
- */
652
- function composePrompt(userMessage, _chatHistory) {
653
- // First message: prepend L1 context so Claude knows who it is
654
- if (_messageCount === 0) {
655
- const l1 = loadL1Context();
656
- if (l1) {
657
- return `${l1}\n\n---\n\nUser message: ${userMessage}`;
658
- }
659
- }
660
- return userMessage;
661
- }
662
-
663
- /**
664
- * Destroy the tmux session. Called on gateway shutdown.
665
- */
666
- function destroySession() {
667
- if (_sessionName && hasSession(_sessionName)) {
668
- tmuxSafe(`kill-session -t ${_sessionName}`);
669
- log.tmux(`Session "${_sessionName}" destroyed.`);
670
- }
671
- _sessionName = null;
672
- _sessionReady = false;
673
- _initPromise = null;
674
- }
675
-
676
- module.exports = {
677
- callClaude,
678
- composePrompt,
679
- loadL1Context,
680
- resolveProjectRoot,
681
- findClaudeBinary,
682
- ensureSession,
683
- destroySession,
684
- };