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 +23 -19
- package/bin/stop.js +0 -12
- package/lib/gateway/config.js +0 -6
- package/lib/gateway/engine/claude-sdk.js +359 -0
- package/lib/gateway/log.js +1 -1
- package/lib/gateway/router.js +1 -1
- package/package.json +1 -1
- package/lib/gateway/engine/claude-tmux.js +0 -684
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
|
|
27
|
-
let
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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();
|
package/lib/gateway/config.js
CHANGED
|
@@ -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
|
+
};
|
package/lib/gateway/log.js
CHANGED
|
@@ -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
|
-
|
|
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
|
package/lib/gateway/router.js
CHANGED
|
@@ -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-
|
|
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,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
|
-
};
|